From 3d95d43fd7769d998038ea5079d0b637c57c96ac Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 28 Jun 2018 15:01:44 -0700 Subject: [PATCH 001/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] [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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] 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/673] [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/673] 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/673] 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/673] 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/673] 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/673] 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/673] [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/673] 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/673] [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 2e10d9231fd32bc62f47b535f75ea13fd99eea9f Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Fri, 6 Dec 2019 21:59:06 +0000 Subject: [PATCH 210/673] [ch57101] Remove country code support (5.0) (#153) --- .../launchdarkly/client/LDCountryCode.java | 2658 ----------------- .../java/com/launchdarkly/client/LDUser.java | 74 +- .../com/launchdarkly/client/LDUserTest.java | 52 +- 3 files changed, 14 insertions(+), 2770 deletions(-) delete mode 100644 src/main/java/com/launchdarkly/client/LDCountryCode.java diff --git a/src/main/java/com/launchdarkly/client/LDCountryCode.java b/src/main/java/com/launchdarkly/client/LDCountryCode.java deleted file mode 100644 index ad344e4dc..000000000 --- a/src/main/java/com/launchdarkly/client/LDCountryCode.java +++ /dev/null @@ -1,2658 +0,0 @@ -/* - * Copyright (C) 2012-2014 Neo Visionaries Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * Taken verbatim from https://github.com/TakahikoKawasaki/nv-i18n and moved to - * the com.launchdarkly.client package to avoid class loading issues. - */ -package com.launchdarkly.client; - - -import java.util.ArrayList; -import java.util.Currency; -import java.util.HashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.regex.Pattern; - - -/** - * ISO 3166-1 country code. - * - *

    - * Enum names of this enum themselves are represented by - * ISO 3166-1 alpha-2 - * code (2-letter upper-case alphabets). There are instance methods to get the - * country name ({@link #getName()}), the - * ISO 3166-1 alpha-3 - * code ({@link #getAlpha3()}) and the - * ISO 3166-1 numeric - * code ({@link #getNumeric()}). - * In addition, there are static methods to get a {@code CountryCode} instance that - * corresponds to a given alpha-2/alpha-3/numeric code ({@link #getByCode(String)}, - * {@link #getByCode(int)}). - *

    - * - *
    - * // List all the country codes.
    - * for (CountryCode code : CountryCode.values())
    - * {
    - *     // For example, "[US] United States" is printed.
    - *     System.out.format("[%s] %s\n", code, code.{@link #getName()});
    - * }
    - *
    - * // Get a CountryCode instance by ISO 3166-1 code.
    - * CountryCode code = CountryCode.{@link #getByCode(String) getByCode}("JP");
    - *
    - * // Print all the information. Output will be:
    - * //
    - * //     Country name            = Japan
    - * //     ISO 3166-1 alpha-2 code = JP
    - * //     ISO 3166-1 alpha-3 code = JPN
    - * //     ISO 3166-1 numeric code = 392
    - * //     Assignment state        = OFFICIALLY_ASSIGNED
    - * //
    - * System.out.println("Country name            = " + code.{@link #getName()});
    - * System.out.println("ISO 3166-1 alpha-2 code = " + code.{@link #getAlpha2()});
    - * System.out.println("ISO 3166-1 alpha-3 code = " + code.{@link #getAlpha3()});
    - * System.out.println("ISO 3166-1 numeric code = " + code.{@link #getNumeric()});
    - * System.out.println("Assignment state        = " + code.{@link #getAssignment()});
    - *
    - * // Convert to a Locale instance.
    - * {@link Locale} locale = code.{@link #toLocale()};
    - *
    - * // Get a CountryCode by a Locale instance.
    - * code = CountryCode.{@link #getByLocale(Locale) getByLocale}(locale);
    - *
    - * // Get the currency of the country.
    - * {@link Currency} currency = code.{@link #getCurrency()};
    - *
    - * // Get a list by a regular expression for names.
    - * //
    - * // The list will contain:
    - * //
    - * //     CountryCode.AE : United Arab Emirates
    - * //     CountryCode.GB : United Kingdom
    - * //     CountryCode.TZ : Tanzania, United Republic of
    - * //     CountryCode.UK : United Kingdom
    - * //     CountryCode.UM : United States Minor Outlying Islands
    - * //     CountryCode.US : United States
    - * //
    - * List<CountryCode> list = CountryCode.{@link #findByName(String) findByName}(".*United.*");
    - * 
    - * - * @author Takahiko Kawasaki - */ -@SuppressWarnings({"deprecation", "DeprecatedIsStillUsed"}) -@Deprecated -public enum LDCountryCode -{ - /** - * Ascension Island - * [AC, ASC, -1, - * Exceptionally reserved] - */ - AC("Ascension Island", "ASC", -1, Assignment.EXCEPTIONALLY_RESERVED), - - /** - * Andorra - * [AD, AND, 16, - * Officially assigned] - */ - AD("Andorra", "AND", 20, Assignment.OFFICIALLY_ASSIGNED), - - /** - * United Arab Emirates - * [AE, AE, 784, - * Officially assigned] - */ - AE("United Arab Emirates", "ARE", 784, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Afghanistan - * [AF, AFG, 4, - * Officially assigned] - */ - AF("Afghanistan", "AFG", 4, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Antigua and Barbuda - * [AG, ATG, 28, - * Officially assigned] - */ - AG("Antigua and Barbuda", "ATG", 28, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Anguilla - * [AI, AIA, 660, - * Officially assigned] - */ - AI("Anguilla", "AIA", 660, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Albania - * [AL, ALB, 8, - * Officially assigned] - */ - AL("Albania", "ALB", 8, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Armenia - * [AM, ARM, 51, - * Officially assigned] - */ - AM("Armenia", "ARM", 51, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Netherlands Antilles - * [AN, ANHH, 530, - * Traditionally reserved] - */ - AN("Netherlands Antilles", "ANHH", 530, Assignment.TRANSITIONALLY_RESERVED), - - /** - * Angola - * [AO, AGO, 24, - * Officially assigned] - */ - AO("Angola", "AGO", 24, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Antarctica - * [AQ, ATA, 10, - * Officially assigned] - */ - AQ("Antarctica", "ATA", 10, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Argentina - * [AR, ARG, 32, - * Officially assigned] - */ - AR("Argentina", "ARG", 32, Assignment.OFFICIALLY_ASSIGNED), - - /** - * American Samoa - * [AS, ASM, 16, - * Officially assigned] - */ - AS("American Samoa", "ASM", 16, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Austria - * [AT, AUT, 40, - * Officially assigned] - */ - AT("Austria", "AUT", 40, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Australia - * [AU, AUS, 36, - * Officially assigned] - */ - AU("Australia", "AUS", 36, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Aruba - * [AW, ABW, 533, - * Officially assigned] - */ - AW("Aruba", "ABW", 533, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Åland Islands - * [AX, ALA, 248, - * Officially assigned] - */ - AX("\u212Bland Islands", "ALA", 248, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Azerbaijan - * [AZ, AZE, 31, - * Officially assigned] - */ - AZ("Azerbaijan", "AZE", 31, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Bosnia and Herzegovina - * [BA, BIH, 70, - * Officially assigned] - */ - BA("Bosnia and Herzegovina", "BIH", 70, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Barbados - * [BB, BRB, 52, - * Officially assigned] - */ - BB("Barbados", "BRB", 52, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Bangladesh - * [BD, BGD, 50, - * Officially assigned] - */ - BD("Bangladesh", "BGD", 50, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Belgium - * [BE, BEL, 56, - * Officially assigned] - */ - BE("Belgium", "BEL", 56, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Burkina Faso - * [BF, BFA, 854, - * Officially assigned] - */ - BF("Burkina Faso", "BFA", 854, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Bulgaria - * [BG, BGR, 100, - * Officially assigned] - */ - BG("Bulgaria", "BGR", 100, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Bahrain - * [BH, BHR, 48, - * Officially assigned] - */ - BH("Bahrain", "BHR", 48, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Burundi - * [BI, BDI, 108, - * Officially assigned] - */ - BI("Burundi", "BDI", 108, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Benin - * [BJ, BEN, 204, - * Officially assigned] - */ - BJ("Benin", "BEN", 204, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Saint Barthélemy - * [BL, BLM, 652, - * Officially assigned] - */ - BL("Saint Barth\u00E9lemy", "BLM", 652, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Bermuda - * [BM, BMU, 60, - * Officially assigned] - */ - BM("Bermuda", "BMU", 60, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Brunei Darussalam - * [BN, BRN, 96, - * Officially assigned] - */ - BN("Brunei Darussalam", "BRN", 96, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Bolivia, Plurinational State of - * [BO, BOL, 68, - * Officially assigned] - */ - BO("Bolivia, Plurinational State of", "BOL", 68, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Bonaire, Sint Eustatius and Saba - * [BQ, BES, 535, - * Officially assigned] - */ - BQ("Bonaire, Sint Eustatius and Saba", "BES", 535, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Brazil - * [BR, BRA, 76, - * Officially assigned] - */ - BR("Brazil", "BRA", 76, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Bahamas - * [BS, BHS, 44, - * Officially assigned] - */ - BS("Bahamas", "BHS", 44, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Bhutan - * [BT, BTN, 64, - * Officially assigned] - */ - BT("Bhutan", "BTN", 64, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Burma - * [BU, BUMM, 104, - * Officially assigned] - * - * @see #MM - */ - BU("Burma", "BUMM", 104, Assignment.TRANSITIONALLY_RESERVED), - - /** - * Bouvet Island - * [BV, BVT, 74, - * Officially assigned] - */ - BV("Bouvet Island", "BVT", 74, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Botswana - * [BW, BWA, 72, - * Officially assigned] - */ - BW("Botswana", "BWA", 72, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Belarus - * [BY, BLR, 112, - * Officially assigned] - */ - BY("Belarus", "BLR", 112, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Belize - * [BZ, BLZ, 84, - * Officially assigned] - */ - BZ("Belize", "BLZ", 84, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Canada - * [CA, CAN, 124, - * Officially assigned] - */ - CA("Canada", "CAN", 124, Assignment.OFFICIALLY_ASSIGNED) - { - @Override - public Locale toLocale() - { - return Locale.CANADA; - } - }, - - /** - * Cocos (Keeling) Islands - * [CC, CCK, 166, - * Officially assigned] - */ - CC("Cocos (Keeling) Islands", "CCK", 166, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Congo, the Democratic Republic of the - * [CD, COD, 180, - * Officially assigned] - */ - CD("Congo, the Democratic Republic of the", "COD", 180, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Central African Republic - * [CF, CAF, 140, - * Officially assigned] - */ - CF("Central African Republic", "CAF", 140, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Congo - * [CG, COG, 178, - * Officially assigned] - */ - CG("Congo", "COG", 178, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Switzerland - * [CH, CHE, 756, - * Officially assigned] - */ - CH("Switzerland", "CHE", 756, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Côte d'Ivoire - * [CI, CIV, 384, - * Officially assigned] - */ - CI("C\u00F4te d'Ivoire", "CIV", 384, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Cook Islands - * [CK, COK, 184, - * Officially assigned] - */ - CK("Cook Islands", "COK", 184, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Chile - * [CL, CHL, 152, - * Officially assigned] - */ - CL("Chile", "CHL", 152, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Cameroon - * [CM, CMR, 120, - * Officially assigned] - */ - CM("Cameroon", "CMR", 120, Assignment.OFFICIALLY_ASSIGNED), - - /** - * China - * [CN, CHN, 156, - * Officially assigned] - */ - CN("China", "CHN", 156, Assignment.OFFICIALLY_ASSIGNED) - { - @Override - public Locale toLocale() - { - return Locale.CHINA; - } - }, - - /** - * Colombia - * [CO, COL, 170, - * Officially assigned] - */ - CO("Colombia", "COL", 170, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Clipperton Island - * [CP, CPT, -1, - * Exceptionally reserved] - */ - CP("Clipperton Island", "CPT", -1, Assignment.EXCEPTIONALLY_RESERVED), - - /** - * Costa Rica - * [CR, CRI, 188, - * Officially assigned] - */ - CR("Costa Rica", "CRI", 188, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Serbia and Montenegro - * [CS, CSXX, 891, - * Traditionally reserved] - */ - CS("Serbia and Montenegro", "CSXX", 891, Assignment.TRANSITIONALLY_RESERVED), - - /** - * Cuba - * [CU, CUB, 192, - * Officially assigned] - */ - CU("Cuba", "CUB", 192, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Cape Verde - * [CV, CPV, 132, - * Officially assigned] - */ - CV("Cape Verde", "CPV", 132, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Curaçao - * [CW, CUW, 531, - * Officially assigned] - */ - CW("Cura\u00E7ao", "CUW", 531, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Christmas Island - * [CX, CXR, 162, - * Officially assigned] - */ - CX("Christmas Island", "CXR", 162, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Cyprus - * [CY, CYP, 196, - * Officially assigned] - */ - CY("Cyprus", "CYP", 196, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Czech Republic - * [CZ, CZE, 203, - * Officially assigned] - */ - CZ("Czech Republic", "CZE", 203, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Germany - * [DE, DEU, 276, - * Officially assigned] - */ - DE("Germany", "DEU", 276, Assignment.OFFICIALLY_ASSIGNED) - { - @Override - public Locale toLocale() - { - return Locale.GERMANY; - } - }, - - /** - * Diego Garcia - * [DG, DGA, -1, - * Exceptionally reserved] - */ - DG("Diego Garcia", "DGA", -1, Assignment.EXCEPTIONALLY_RESERVED), - - /** - * Djibouti - * [DJ, DJI, 262, - * Officially assigned] - */ - DJ("Djibouti", "DJI", 262, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Denmark - * [DK, DNK, 208, - * Officially assigned] - */ - DK("Denmark", "DNK", 208, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Dominica - * [DM, DMA, 212, - * Officially assigned] - */ - DM("Dominica", "DMA", 212, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Dominican Republic - * [DO, DOM, 214, - * Officially assigned] - */ - DO("Dominican Republic", "DOM", 214, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Algeria - * [DZ, DZA, 12, - * Officially assigned] - */ - DZ("Algeria", "DZA", 12, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Ceuta, - * Melilla - * [EA, null, -1, - * Exceptionally reserved] - */ - EA("Ceuta, Melilla", null, -1, Assignment.EXCEPTIONALLY_RESERVED), - - /** - * Ecuador - * [EC, ECU, 218, - * Officially assigned] - */ - EC("Ecuador", "ECU", 218, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Estonia - * [EE, EST, 233, - * Officially assigned] - */ - EE("Estonia", "EST", 233, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Egypt - * [EG, EGY, 818, - * Officially assigned] - */ - EG("Egypt", "EGY", 818, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Western Sahara - * [EH, ESH, 732, - * Officially assigned] - */ - EH("Western Sahara", "ESH", 732, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Eritrea - * [ER, ERI, 232, - * Officially assigned] - */ - ER("Eritrea", "ERI", 232, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Spain - * [ES, ESP, 724, - * Officially assigned] - */ - ES("Spain", "ESP", 724, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Ethiopia - * [ET, ETH, 231, - * Officially assigned] - */ - ET("Ethiopia", "ETH", 231, Assignment.OFFICIALLY_ASSIGNED), - - /** - * European Union - * [EU, null, -1, - * Exceptionally reserved] - */ - EU("European Union", null, -1, Assignment.EXCEPTIONALLY_RESERVED), - - /** - * Finland - * [FI, FIN, 246, - * Officially assigned] - * - * @see #SF - */ - FI("Finland", "FIN", 246, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Fiji - * [FJ, FJI, 242, - * Officially assigned] - */ - FJ("Fiji", "FJI", 242, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Falkland Islands (Malvinas) - * [FK, FLK, 238, - * Officially assigned] - */ - FK("Falkland Islands (Malvinas)", "FLK", 238, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Micronesia, Federated States of - * [FM, FSM, 583, - * Officially assigned] - */ - FM("Micronesia, Federated States of", "FSM", 583, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Faroe Islands - * [FO, FRO, 234, - * Officially assigned] - */ - FO("Faroe Islands", "FRO", 234, Assignment.OFFICIALLY_ASSIGNED), - - /** - * France - * [FR, FRA, 250, - * Officially assigned] - */ - FR("France", "FRA", 250, Assignment.OFFICIALLY_ASSIGNED) - { - @Override - public Locale toLocale() - { - return Locale.FRANCE; - } - }, - - /** - * France, Metropolitan - * [FX, FXX, -1, - * Exceptionally reserved] - */ - FX("France, Metropolitan", "FXX", -1, Assignment.EXCEPTIONALLY_RESERVED), - - /** - * Gabon - * [GA, GAB, 266, - * Officially assigned] - */ - GA("Gabon", "GAB", 266, Assignment.OFFICIALLY_ASSIGNED), - - /** - * United Kingdom - * [GB, GBR, 826, - * Officially assigned] - */ - GB("United Kingdom", "GBR", 826, Assignment.OFFICIALLY_ASSIGNED) - { - @Override - public Locale toLocale() - { - return Locale.UK; - } - }, - - /** - * Grenada - * [GD, GRD, 308, - * Officially assigned] - */ - GD("Grenada", "GRD", 308, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Georgia - * [GE, GEO, 268, - * Officially assigned] - */ - GE("Georgia", "GEO", 268, Assignment.OFFICIALLY_ASSIGNED), - - /** - * French Guiana - * [GF, GUF, 254, - * Officially assigned] - */ - GF("French Guiana", "GUF", 254, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Guernsey - * [GG, GGY, 831, - * Officially assigned] - */ - GG("Guernsey", "GGY", 831, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Ghana - * [GH, GHA, 288, - * Officially assigned] - */ - GH("Ghana", "GHA", 288, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Gibraltar - * [GI, GIB, 292, - * Officially assigned] - */ - GI("Gibraltar", "GIB", 292, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Greenland - * [GL, GRL, 304, - * Officially assigned] - */ - GL("Greenland", "GRL", 304, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Gambia - * [GM, GMB, 270, - * Officially assigned] - */ - GM("Gambia", "GMB", 270, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Guinea - * [GN, GIN, 324, - * Officially assigned] - */ - GN("Guinea", "GIN", 324, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Guadeloupe - * [GP, GLP, 312, - * Officially assigned] - */ - GP("Guadeloupe", "GLP", 312, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Equatorial Guinea - * [GQ, GNQ, 226, - * Officially assigned] - */ - GQ("Equatorial Guinea", "GNQ", 226, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Greece - * [GR, GRC, 300, - * Officially assigned] - */ - GR("Greece", "GRC", 300, Assignment.OFFICIALLY_ASSIGNED), - - /** - * South Georgia and the South Sandwich Islands - * [GS, SGS, 239, - * Officially assigned] - */ - GS("South Georgia and the South Sandwich Islands", "SGS", 239, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Guatemala - * [GT, GTM, 320, - * Officially assigned] - */ - GT("Guatemala", "GTM", 320, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Guam - * [GU, GUM, 316, - * Officially assigned] - */ - GU("Guam", "GUM", 316, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Guinea-Bissau - * [GW, GNB, 624, - * Officially assigned] - */ - GW("Guinea-Bissau", "GNB", 624, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Guyana - * [GY, GUY, 328, - * Officially assigned] - */ - GY("Guyana", "GUY", 328, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Hong Kong - * [HK, HKG, 344, - * Officially assigned] - */ - HK("Hong Kong", "HKG", 344, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Heard Island and McDonald Islands - * [HM, HMD, 334, - * Officially assigned] - */ - HM("Heard Island and McDonald Islands", "HMD", 334, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Honduras - * [HN, HND, 340, - * Officially assigned] - */ - HN("Honduras", "HND", 340, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Croatia - * [HR, HRV, 191, - * Officially assigned] - */ - HR("Croatia", "HRV", 191, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Haiti - * [HT, HTI, 332, - * Officially assigned] - */ - HT("Haiti", "HTI", 332, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Hungary - * [HU, HUN, 348, - * Officially assigned] - */ - HU("Hungary", "HUN", 348, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Canary Islands - * [IC, null, -1, - * Exceptionally reserved] - */ - IC("Canary Islands", null, -1, Assignment.EXCEPTIONALLY_RESERVED), - - /** - * Indonesia - * [ID, IDN, 360, - * Officially assigned] - */ - ID("Indonesia", "IDN", 360, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Ireland - * [IE, IRL, 372, - * Officially assigned] - */ - IE("Ireland", "IRL", 372, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Israel - * [IL, ISR, 376, - * Officially assigned] - */ - IL("Israel", "ISR", 376, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Isle of Man - * [IM, IMN, 833, - * Officially assigned] - */ - IM("Isle of Man", "IMN", 833, Assignment.OFFICIALLY_ASSIGNED), - - /** - * India - * [IN, IND, 356, - * Officially assigned] - */ - IN("India", "IND", 356, Assignment.OFFICIALLY_ASSIGNED), - - /** - * British Indian Ocean Territory - * [IO, IOT, 86, - * Officially assigned] - */ - IO("British Indian Ocean Territory", "IOT", 86, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Iraq - * [IQ, IRQ, 368, - * Officially assigned] - */ - IQ("Iraq", "IRQ", 368, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Iran, Islamic Republic of - * [IR, IRN, 364, - * Officially assigned] - */ - IR("Iran, Islamic Republic of", "IRN", 364, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Iceland - * [IS, ISL, 352, - * Officially assigned] - */ - IS("Iceland", "ISL", 352, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Italy - * [IT, ITA, 380, - * Officially assigned] - */ - IT("Italy", "ITA", 380, Assignment.OFFICIALLY_ASSIGNED) - { - @Override - public Locale toLocale() - { - return Locale.ITALY; - } - }, - - /** - * Jersey - * [JE, JEY, 832, - * Officially assigned] - */ - JE("Jersey", "JEY", 832, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Jamaica - * [JM, JAM, 388, - * Officially assigned] - */ - JM("Jamaica", "JAM", 388, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Jordan - * [JO, JOR, 400, - * Officially assigned] - */ - JO("Jordan", "JOR", 400, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Japan - * [JP, JPN, 392, - * Officially assigned] - */ - JP("Japan", "JPN", 392, Assignment.OFFICIALLY_ASSIGNED) - { - @Override - public Locale toLocale() - { - return Locale.JAPAN; - } - }, - - /** - * Kenya - * [KE, KEN, 404, - * Officially assigned] - */ - KE("Kenya", "KEN", 404, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Kyrgyzstan - * [KG, KGZ, 417, - * Officially assigned] - */ - KG("Kyrgyzstan", "KGZ", 417, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Cambodia - * [KH, KHM, 116, - * Officially assigned] - */ - KH("Cambodia", "KHM", 116, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Kiribati - * [KI, KIR, 296, - * Officially assigned] - */ - KI("Kiribati", "KIR", 296, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Comoros - * [KM, COM, 174, - * Officially assigned] - */ - KM("Comoros", "COM", 174, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Saint Kitts and Nevis - * [KN, KNA, 659, - * Officially assigned] - */ - KN("Saint Kitts and Nevis", "KNA", 659, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Korea, Democratic People's Republic of - * [KP, PRK, 408, - * Officially assigned] - */ - KP("Korea, Democratic People's Republic of", "PRK", 408, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Korea, Republic of - * [KR, KOR, 410, - * Officially assigned] - */ - KR("Korea, Republic of", "KOR", 410, Assignment.OFFICIALLY_ASSIGNED) - { - @Override - public Locale toLocale() - { - return Locale.KOREA; - } - }, - - /** - * Kuwait - * [KW, KWT, 414, - * Officially assigned] - */ - KW("Kuwait", "KWT", 414, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Cayman Islands - * [KY, CYM, 136, - * Officially assigned] - */ - KY("Cayman Islands", "CYM", 136, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Kazakhstan - * [KZ, KAZ, 398, - * Officially assigned] - */ - KZ("Kazakhstan", "KAZ", 398, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Lao People's Democratic Republic - * [LA, LAO, 418, - * Officially assigned] - */ - LA("Lao People's Democratic Republic", "LAO", 418, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Lebanon - * [LB, LBN, 422, - * Officially assigned] - */ - LB("Lebanon", "LBN", 422, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Saint Lucia - * [LC, LCA, 662, - * Officially assigned] - */ - LC("Saint Lucia", "LCA", 662, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Liechtenstein - * [LI, LIE, 438, - * Officially assigned] - */ - LI("Liechtenstein", "LIE", 438, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Sri Lanka - * [LK, LKA, 144, - * Officially assigned] - */ - LK("Sri Lanka", "LKA", 144, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Liberia - * [LR, LBR, 430, - * Officially assigned] - */ - LR("Liberia", "LBR", 430, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Lesotho - * [LS, LSO, 426, - * Officially assigned] - */ - LS("Lesotho", "LSO", 426, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Lithuania - * [LT, LTU, 440, - * Officially assigned] - */ - LT("Lithuania", "LTU", 440, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Luxembourg - * [LU, LUX, 442, - * Officially assigned] - */ - LU("Luxembourg", "LUX", 442, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Latvia - * [LV, LVA, 428, - * Officially assigned] - */ - LV("Latvia", "LVA", 428, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Libya - * [LY, LBY, 434, - * Officially assigned] - */ - LY("Libya", "LBY", 434, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Morocco - * [MA, MAR, 504, - * Officially assigned] - */ - MA("Morocco", "MAR", 504, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Monaco - * [MC, MCO, 492, - * Officially assigned] - */ - MC("Monaco", "MCO", 492, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Moldova, Republic of - * [MD, MDA, 498, - * Officially assigned] - */ - MD("Moldova, Republic of", "MDA", 498, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Montenegro - * [ME, MNE, 499, - * Officially assigned] - */ - ME("Montenegro", "MNE", 499, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Saint Martin (French part) - * [MF, MAF, 663, - * Officially assigned] - */ - MF("Saint Martin (French part)", "MAF", 663, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Madagascar - * [MG, MDG, 450, - * Officially assigned] - */ - MG("Madagascar", "MDG", 450, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Marshall Islands - * [MH, MHL, 584, - * Officially assigned] - */ - MH("Marshall Islands", "MHL", 584, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Macedonia, the former Yugoslav Republic of - * [MK, MKD, 807, - * Officially assigned] - */ - MK("Macedonia, the former Yugoslav Republic of", "MKD", 807, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Mali - * [ML, MLI, 466, - * Officially assigned] - */ - ML("Mali", "MLI", 466, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Myanmar - * [MM, MMR, 104, - * Officially assigned] - * - * @see #BU - */ - MM("Myanmar", "MMR", 104, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Mongolia - * [MN, MNG, 496, - * Officially assigned] - */ - MN("Mongolia", "MNG", 496, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Macao - * [MO, MCO, 492, - * Officially assigned] - */ - MO("Macao", "MAC", 446, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Northern Mariana Islands - * [MP, MNP, 580, - * Officially assigned] - */ - MP("Northern Mariana Islands", "MNP", 580, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Martinique - * [MQ, MTQ, 474, - * Officially assigned] - */ - MQ("Martinique", "MTQ", 474, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Mauritania - * [MR, MRT, 478, - * Officially assigned] - */ - MR("Mauritania", "MRT", 478, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Montserrat - * [MS, MSR, 500, - * Officially assigned] - */ - MS("Montserrat", "MSR", 500, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Malta - * [MT, MLT, 470, - * Officially assigned] - */ - MT("Malta", "MLT", 470, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Mauritius - * [MU, MUS, 480, - * Officially assigned]] - */ - MU("Mauritius", "MUS", 480, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Maldives - * [MV, MDV, 462, - * Officially assigned] - */ - MV("Maldives", "MDV", 462, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Malawi - * [MW, MWI, 454, - * Officially assigned] - */ - MW("Malawi", "MWI", 454, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Mexico - * [MX, MEX, 484, - * Officially assigned] - */ - MX("Mexico", "MEX", 484, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Malaysia - * [MY, MYS, 458, - * Officially assigned] - */ - MY("Malaysia", "MYS", 458, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Mozambique - * [MZ, MOZ, 508, - * Officially assigned] - */ - MZ("Mozambique", "MOZ", 508, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Namibia - * [NA, NAM, 516, - * Officially assigned] - */ - NA("Namibia", "NAM", 516, Assignment.OFFICIALLY_ASSIGNED), - - /** - * New Caledonia - * [NC, NCL, 540, - * Officially assigned] - */ - NC("New Caledonia", "NCL", 540, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Niger - * [NE, NER, 562, - * Officially assigned] - */ - NE("Niger", "NER", 562, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Norfolk Island - * [NF, NFK, 574, - * Officially assigned] - */ - NF("Norfolk Island", "NFK", 574, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Nigeria - * [NG, NGA, 566, - * Officially assigned] - */ - NG("Nigeria","NGA", 566, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Nicaragua - * [NI, NIC, 558, - * Officially assigned] - */ - NI("Nicaragua", "NIC", 558, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Netherlands - * [NL, NLD, 528, - * Officially assigned] - */ - NL("Netherlands", "NLD", 528, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Norway - * [NO, NOR, 578, - * Officially assigned] - */ - NO("Norway", "NOR", 578, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Nepal - * [NP, NPL, 524, - * Officially assigned] - */ - NP("Nepal", "NPL", 524, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Nauru - * [NR, NRU, 520, - * Officially assigned] - */ - NR("Nauru", "NRU", 520, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Neutral Zone - * [NT, NTHH, 536, - * Traditionally reserved] - */ - NT("Neutral Zone", "NTHH", 536, Assignment.TRANSITIONALLY_RESERVED), - - /** - * Niue - * [NU, NIU, 570, - * Officially assigned] - */ - NU("Niue", "NIU", 570, Assignment.OFFICIALLY_ASSIGNED), - - /** - * New Zealand - * [NZ, NZL, 554, - * Officially assigned] - */ - NZ("New Zealand", "NZL", 554, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Oman - * [OM, OMN, 512, - * Officially assigned] - */ - OM("Oman", "OMN", 512, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Panama - * [PA, PAN, 591, - * Officially assigned] - */ - PA("Panama", "PAN", 591, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Peru - * [PE, PER, 604, - * Officially assigned] - */ - PE("Peru", "PER", 604, Assignment.OFFICIALLY_ASSIGNED), - - /** - * French Polynesia - * [PF, PYF, 258, - * Officially assigned] - */ - PF("French Polynesia", "PYF", 258, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Papua New Guinea - * [PG, PNG, 598, - * Officially assigned] - */ - PG("Papua New Guinea", "PNG", 598, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Philippines - * [PH, PHL, 608, - * Officially assigned] - */ - PH("Philippines", "PHL", 608, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Pakistan - * [PK, PAK, 586, - * Officially assigned] - */ - PK("Pakistan", "PAK", 586, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Poland - * [PL, POL, 616, - * Officially assigned] - */ - PL("Poland", "POL", 616, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Saint Pierre and Miquelon - * [PM, SPM, 666, - * Officially assigned] - */ - PM("Saint Pierre and Miquelon", "SPM", 666, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Pitcairn - * [PN, PCN, 612, - * Officially assigned] - */ - PN("Pitcairn", "PCN", 612, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Puerto Rico - * [PR, PRI, 630, - * Officially assigned] - */ - PR("Puerto Rico", "PRI", 630, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Palestine, State of - * [PS, PSE, 275, - * Officially assigned] - */ - PS("Palestine, State of", "PSE", 275, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Portugal - * [PT, PRT, 620, - * Officially assigned] - */ - PT("Portugal", "PRT", 620, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Palau - * [PW, PLW, 585, - * Officially assigned] - */ - PW("Palau", "PLW", 585, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Paraguay - * [PY, PRY, 600, - * Officially assigned] - */ - PY("Paraguay", "PRY", 600, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Qatar - * [QA, QAT, 634, - * Officially assigned] - */ - QA("Qatar", "QAT", 634, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Réunion - * [RE, REU, 638, - * Officially assigned] - */ - RE("R\u00E9union", "REU", 638, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Romania - * [RO, ROU, 642, - * Officially assigned] - */ - RO("Romania", "ROU", 642, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Serbia - * [RS, SRB, 688, - * Officially assigned] - */ - RS("Serbia", "SRB", 688, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Russian Federation - * [RU, RUS, 643, - * Officially assigned] - */ - RU("Russian Federation", "RUS", 643, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Rwanda - * [RW, RWA, 646, - * Officially assigned] - */ - RW("Rwanda", "RWA", 646, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Saudi Arabia - * [SA, SAU, 682, - * Officially assigned] - */ - SA("Saudi Arabia", "SAU", 682, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Solomon Islands - * [SB, SLB, 90, - * Officially assigned] - */ - SB("Solomon Islands", "SLB", 90, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Seychelles - * [SC, SYC, 690, - * Officially assigned] - */ - SC("Seychelles", "SYC", 690, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Sudan - * [SD, SDN, 729, - * Officially assigned] - */ - SD("Sudan", "SDN", 729, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Sweden - * [SE, SWE, 752, - * Officially assigned] - */ - SE("Sweden", "SWE", 752, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Finland - * [SF, FIN, 246, - * Traditionally reserved] - * - * @see #FI - */ - SF("Finland", "FIN", 246, Assignment.TRANSITIONALLY_RESERVED), - - /** - * Singapore - * [SG, SGP, 702, - * Officially assigned] - */ - SG("Singapore", "SGP", 702, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Saint Helena, Ascension and Tristan da Cunha - * [SH, SHN, 654, - * Officially assigned] - */ - SH("Saint Helena, Ascension and Tristan da Cunha", "SHN", 654, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Slovenia - * [SI, SVN, 705, - * Officially assigned] - */ - SI("Slovenia", "SVN", 705, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Svalbard and Jan Mayen - * [SJ, SJM, 744, - * Officially assigned] - */ - SJ("Svalbard and Jan Mayen", "SJM", 744, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Slovakia - * [SK, SVK, 703, - * Officially assigned] - */ - SK("Slovakia", "SVK", 703, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Sierra Leone - * [SL, SLE, 694, - * Officially assigned] - */ - SL("Sierra Leone", "SLE", 694, Assignment.OFFICIALLY_ASSIGNED), - - /** - * San Marino - * [SM, SMR, 674, - * Officially assigned] - */ - SM("San Marino", "SMR", 674, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Senegal - * [SN, SEN, 686, - * Officially assigned] - */ - SN("Senegal", "SEN", 686, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Somalia - * [SO, SOM, 706, - * Officially assigned] - */ - SO("Somalia", "SOM", 706, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Suriname - * [SR, SUR, 740, - * Officially assigned] - */ - SR("Suriname", "SUR", 740, Assignment.OFFICIALLY_ASSIGNED), - - /** - * South Sudan - * [SS, SSD, 728, - * Officially assigned] - */ - SS("South Sudan", "SSD", 728, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Sao Tome and Principe - * [ST, STP, 678, - * Officially assigned] - */ - ST("Sao Tome and Principe", "STP", 678, Assignment.OFFICIALLY_ASSIGNED), - - /** - * USSR - * [SU, SUN, -1, - * Exceptionally reserved] - */ - SU("USSR", "SUN", -1, Assignment.EXCEPTIONALLY_RESERVED), - - /** - * El Salvador - * [SV, SLV, 222, - * Officially assigned] - */ - SV("El Salvador", "SLV", 222, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Sint Maarten (Dutch part) - * [SX, SXM, 534, - * Officially assigned] - */ - SX("Sint Maarten (Dutch part)", "SXM", 534, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Syrian Arab Republic - * [SY, SYR, 760, - * Officially assigned] - */ - SY("Syrian Arab Republic", "SYR", 760, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Swaziland - * [SZ, SWZ, 748, - * Officially assigned] - */ - SZ("Swaziland", "SWZ", 748, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Tristan da Cunha - * [TA, TAA, -1, - * Exceptionally reserved. - */ - TA("Tristan da Cunha", "TAA", -1, Assignment.EXCEPTIONALLY_RESERVED), - - /** - * Turks and Caicos Islands - * [TC, TCA, 796, - * Officially assigned] - */ - TC("Turks and Caicos Islands", "TCA", 796, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Chad - * [TD, TCD, 148, - * Officially assigned] - */ - TD("Chad", "TCD", 148, Assignment.OFFICIALLY_ASSIGNED), - - /** - * French Southern Territories - * [TF, ATF, 260, - * Officially assigned] - */ - TF("French Southern Territories", "ATF", 260, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Togo - * [TG, TGO, 768, - * Officially assigned] - */ - TG("Togo", "TGO", 768, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Thailand - * [TH, THA, 764, - * Officially assigned] - */ - TH("Thailand", "THA", 764, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Tajikistan - * [TJ, TJK, 762, - * Officially assigned] - */ - TJ("Tajikistan", "TJK", 762, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Tokelau - * [TK, TKL, 772, - * Officially assigned] - */ - TK("Tokelau", "TKL", 772, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Timor-Leste - * [TL, TLS, 626, - * Officially assigned] - */ - TL("Timor-Leste", "TLS", 626, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Turkmenistan - * [TM, TKM, 795, - * Officially assigned] - */ - TM("Turkmenistan", "TKM", 795, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Tunisia - * [TN, TUN, 788, - * Officially assigned] - */ - TN("Tunisia", "TUN", 788, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Tonga - * [TO, TON, 776, - * Officially assigned] - */ - TO("Tonga", "TON", 776, Assignment.OFFICIALLY_ASSIGNED), - - /** - * East Timor - * [TP, TPTL, 0, - * Traditionally reserved] - * - *

    - * ISO 3166-1 numeric code is unknown. - *

    - */ - TP("East Timor", "TPTL", 0, Assignment.TRANSITIONALLY_RESERVED), - - /** - * Turkey - * [TR, TUR, 792, - * Officially assigned] - */ - TR("Turkey", "TUR", 792, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Trinidad and Tobago - * [TT, TTO, 780, - * Officially assigned] - */ - TT("Trinidad and Tobago", "TTO", 780, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Tuvalu - * [TV, TUV, 798, - * Officially assigned] - */ - TV("Tuvalu", "TUV", 798, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Taiwan, Province of China - * [TW, TWN, 158, - * Officially assigned] - */ - TW("Taiwan, Province of China", "TWN", 158, Assignment.OFFICIALLY_ASSIGNED) - { - @Override - public Locale toLocale() - { - return Locale.TAIWAN; - } - }, - - /** - * Tanzania, United Republic of - * [TZ, TZA, 834, - * Officially assigned] - */ - TZ("Tanzania, United Republic of", "TZA", 834, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Ukraine - * [UA, UKR, 804, - * Officially assigned] - */ - UA("Ukraine", "UKR", 804, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Uganda - * [UG, UGA, 800, - * Officially assigned] - */ - UG("Uganda", "UGA", 800, Assignment.OFFICIALLY_ASSIGNED), - - /** - * United Kingdom - * [UK, null, -1, - * Exceptionally reserved] - */ - UK("United Kingdom", null, -1, Assignment.EXCEPTIONALLY_RESERVED) - { - @Override - public Locale toLocale() - { - return Locale.UK; - } - }, - - /** - * United States Minor Outlying Islands - * [UM, UMI, 581, - * Officially assigned] - */ - UM("United States Minor Outlying Islands", "UMI", 581, Assignment.OFFICIALLY_ASSIGNED), - - /** - * United States - * [US, USA, 840, - * Officially assigned] - */ - US("United States", "USA", 840, Assignment.OFFICIALLY_ASSIGNED) - { - @Override - public Locale toLocale() - { - return Locale.US; - } - }, - - /** - * Uruguay - * [UY, URY, 858, - * Officially assigned] - */ - UY("Uruguay", "URY", 858, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Uzbekistan - * [UZ, UZB, 860, - * Officially assigned] - */ - UZ("Uzbekistan", "UZB", 860, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Holy See (Vatican City State) - * [VA, VAT, 336, - * Officially assigned] - */ - VA("Holy See (Vatican City State)", "VAT", 336, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Saint Vincent and the Grenadines - * [VC, VCT, 670, - * Officially assigned] - */ - VC("Saint Vincent and the Grenadines", "VCT", 670, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Venezuela, Bolivarian Republic of - * [VE, VEN, 862, - * Officially assigned] - */ - VE("Venezuela, Bolivarian Republic of", "VEN", 862, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Virgin Islands, British - * [VG, VGB, 92, - * Officially assigned] - */ - VG("Virgin Islands, British", "VGB", 92, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Virgin Islands, U.S. - * [VI, VIR, 850, - * Officially assigned] - */ - VI("Virgin Islands, U.S.", "VIR", 850, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Viet Nam - * [VN, VNM, 704, - * Officially assigned] - */ - VN("Viet Nam", "VNM", 704, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Vanuatu - * [VU, VUT, 548, - * Officially assigned] - */ - VU("Vanuatu", "VUT", 548, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Wallis and Futuna - * [WF, WLF, 876, - * Officially assigned] - */ - WF("Wallis and Futuna", "WLF", 876, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Samoa - * [WS, WSM, 882, - * Officially assigned] - */ - WS("Samoa", "WSM", 882, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Kosovo, Republic of - * [XK, XXK, -1, - * User assigned] - */ - XK("Kosovo, Republic of", "XXK", -1, Assignment.USER_ASSIGNED), - - /** - * Yemen - * [YE, YEM, 887, - * Officially assigned] - */ - YE("Yemen", "YEM", 887, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Mayotte - * [YT, MYT, 175, - * Officially assigned] - */ - YT("Mayotte", "MYT", 175, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Yugoslavia - * [YU, YUCS, 890, - * Traditionally reserved] - */ - YU("Yugoslavia", "YUCS", 890, Assignment.TRANSITIONALLY_RESERVED), - - /** - * South Africa - * [ZA, ZAF, 710, - * Officially assigned] - */ - ZA("South Africa", "ZAF", 710, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Zambia - * [ZM, ZMB, 894, - * Officially assigned] - */ - ZM("Zambia", "ZMB", 894, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Zaire - * [ZR, ZRCD, 0, - * Traditionally reserved] - * - *

    - * ISO 3166-1 numeric code is unknown. - *

    - */ - ZR("Zaire", "ZRCD", 0, Assignment.TRANSITIONALLY_RESERVED), - - /** - * Zimbabwe - * [ZW, ZWE, 716, - * Officially assigned] - */ - ZW("Zimbabwe", "ZWE", 716, Assignment.OFFICIALLY_ASSIGNED), - ; - - - /** - * Code assignment state in ISO 3166-1. - * - * @see Decoding table of ISO 3166-1 alpha-2 codes - */ - enum Assignment - { - /** - * Officially assigned. - * - * Assigned to a country, territory, or area of geographical interest. - */ - OFFICIALLY_ASSIGNED, - - /** - * User assigned. - * - * Free for assignment at the disposal of users. - */ - USER_ASSIGNED, - - /** - * Exceptionally reserved. - * - * Reserved on request for restricted use. - */ - EXCEPTIONALLY_RESERVED, - - /** - * Transitionally reserved. - * - * Deleted from ISO 3166-1 but reserved transitionally. - */ - TRANSITIONALLY_RESERVED, - - /** - * Indeterminately reserved. - * - * Used in coding systems associated with ISO 3166-1. - */ - INDETERMINATELY_RESERVED, - - /** - * Not used. - * - * Not used in ISO 3166-1 in deference to international property - * organization names. - */ - NOT_USED - } - - - private static final Map alpha3Map = new HashMap<>(); - private static final Map numericMap = new HashMap<>(); - - - static - { - for (LDCountryCode cc : values()) - { - if (cc.getAlpha3() != null) - { - alpha3Map.put(cc.getAlpha3(), cc); - } - - if (cc.getNumeric() != -1) - { - numericMap.put(cc.getNumeric(), cc); - } - } - } - - - private final String name; - private final String alpha3; - private final int numeric; - private final Assignment assignment; - - - private LDCountryCode(String name, String alpha3, int numeric, Assignment assignment) - { - this.name = name; - this.alpha3 = alpha3; - this.numeric = numeric; - this.assignment = assignment; - } - - - /** - * Get the country name. - * - * @return - * The country name. - */ - public String getName() - { - return name; - } - - - /** - * Get the ISO 3166-1 alpha-2 code. - * - * @return - * The ISO 3166-1 alpha-2 code. - */ - public String getAlpha2() - { - return name(); - } - - - /** - * Get the ISO 3166-1 alpha-3 code. - * - * @return - * The ISO 3166-1 alpha-3 code. - * Some country codes reserved exceptionally (such as {@link #EU}) - * returns {@code null}. - */ - public String getAlpha3() - { - return alpha3; - } - - - /** - * Get the ISO 3166-1 numeric code. - * - * @return - * The ISO 3166-1 numeric code. - * Country codes reserved exceptionally (such as {@link #EU}) - * returns {@code -1}. - */ - public int getNumeric() - { - return numeric; - } - - - /** - * Get the assignment state of this country code in ISO 3166-1. - * - * @return - * The assignment state. - * - * @see Decoding table of ISO 3166-1 alpha-2 codes - */ - public Assignment getAssignment() - { - return assignment; - } - - - /** - * Convert this {@code CountryCode} instance to a {@link Locale} instance. - * - *

    - * In most cases, this method creates a new {@code Locale} instance - * every time it is called, but some {@code CountryCode} instances return - * their corresponding entries in {@code Locale} class. For example, - * {@link #CA CountryCode.CA} always returns {@link Locale#CANADA}. - *

    - * - *

    - * The table below lists {@code CountryCode} entries whose {@code toLocale()} - * do not create new Locale instances but return entries in - * {@code Locale} class. - *

    - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - *
    CountryCodeLocale
    {@link LDCountryCode#CA CountryCode.CA}{@link Locale#CANADA}
    {@link LDCountryCode#CN CountryCode.CN}{@link Locale#CHINA}
    {@link LDCountryCode#DE CountryCode.DE}{@link Locale#GERMANY}
    {@link LDCountryCode#FR CountryCode.FR}{@link Locale#FRANCE}
    {@link LDCountryCode#GB CountryCode.GB}{@link Locale#UK}
    {@link LDCountryCode#IT CountryCode.IT}{@link Locale#ITALY}
    {@link LDCountryCode#JP CountryCode.JP}{@link Locale#JAPAN}
    {@link LDCountryCode#KR CountryCode.KR}{@link Locale#KOREA}
    {@link LDCountryCode#TW CountryCode.TW}{@link Locale#TAIWAN}
    {@link LDCountryCode#US CountryCode.US}{@link Locale#US}
    - * - * @return - * A {@code Locale} instance that matches this {@code CountryCode}. - */ - public Locale toLocale() - { - return new Locale("", name()); - } - - - /** - * Get the currency. - * - *

    - * This method is an alias of {@link Currency}{@code .}{@link - * Currency#getInstance(Locale) getInstance}{@code (}{@link - * #toLocale()}{@code )}. The only difference is that this method - * returns {@code null} when {@code Currency.getInstance(Locale)} - * throws {@code IllegalArgumentException}. - *

    - * - *

    - * This method returns {@code null} when the territory represented by - * this {@code CountryCode} instance does not have a currency. - * {@link #AQ} (Antarctica) is one example. - *

    - * - *

    - * In addition, this method returns {@code null} also when the ISO 3166 - * code represented by this {@code CountryCode} instance is not - * supported by the implementation of {@link - * Currency#getInstance(Locale)}. At the time of this writing, - * {@link #SS} (South Sudan) is one example. - *

    - * - * @return - * A {@code Currency} instance. In some cases, null - * is returned. - * - * @since 1.4 - * - * @see Currency#getInstance(Locale) - */ - public Currency getCurrency() - { - try - { - return Currency.getInstance(toLocale()); - } - catch (IllegalArgumentException e) - { - // Currency.getInstance(Locale) throws IllegalArgumentException - // when the given ISO 3166 code is not supported. - return null; - } - } - - - /** - * Get a {@code CountryCode} that corresponds to the given ISO 3166-1 - * alpha-2 or - * alpha-3 code. - * - *

    - * This method calls {@link #getByCode(String, boolean) getByCode}{@code (code, true)}. - * Note that the behavior has changed since the version 1.13. In the older versions, - * this method was an alias of {@code getByCode(code, false)}. - *

    - * - * @param code - * An ISO 3166-1 alpha-2 or alpha-3 code. - * - * @return - * A {@code CountryCode} instance, or {@code null} if not found. - * - * @see #getByCode(String, boolean) - */ - public static LDCountryCode getByCode(String code) - { - return getByCode(code, true); - } - - - /** - * Get a {@code CountryCode} that corresponds to the given ISO 3166-1 - * alpha-2 or - * alpha-3 code. - * - *

    - * This method calls {@link #getByCode(String, boolean) getByCode}{@code (code, false)}. - *

    - * - * @param code - * An ISO 3166-1 alpha-2 or alpha-3 code. - * - * @return - * A {@code CountryCode} instance, or {@code null} if not found. - * - * @since 1.13 - * - * @see #getByCode(String, boolean) - */ - public static LDCountryCode getByCodeIgnoreCase(String code) - { - return getByCode(code, false); - } - - - /** - * Get a {@code CountryCode} that corresponds to the given ISO 3166-1 - * alpha-2 or - * alpha-3 code. - * - * @param code - * An ISO 3166-1 alpha-2 or alpha-3 code. - * - * @param caseSensitive - * If {@code true}, the given code should consist of upper-case letters only. - * If {@code false}, this method internally canonicalizes the given code by - * {@link String#toUpperCase()} and then performs search. For example, - * {@code getByCode("jp", true)} returns {@code null}, but on the other hand, - * {@code getByCode("jp", false)} returns {@link #JP CountryCode.JP}. - * - * @return - * A {@code CountryCode} instance, or {@code null} if not found. - */ - public static LDCountryCode getByCode(String code, boolean caseSensitive) - { - if (code == null) - { - return null; - } - - switch (code.length()) - { - case 2: - code = canonicalize(code, caseSensitive); - return getByAlpha2Code(code); - - case 3: - code = canonicalize(code, caseSensitive); - return getByAlpha3Code(code); - - default: - return null; - } - } - - - /** - * Get a {@code CountryCode} that corresponds to the country code of - * the given {@link Locale} instance. - * - * @param locale - * A {@code Locale} instance. - * - * @return - * A {@code CountryCode} instance, or {@code null} if not found. - * - * @see Locale#getCountry() - */ - public static LDCountryCode getByLocale(Locale locale) - { - if (locale == null) - { - return null; - } - - // Locale.getCountry() returns either an empty string or - // an uppercase ISO 3166 2-letter code. - return getByCode(locale.getCountry(), true); - } - - - /** - * Canonicalize the given country code. - * - * @param code - * ISO 3166-1 alpha-2 or alpha-3 country code. - * - * @param caseSensitive - * {@code true} if the code should be handled case-sensitively. - * - * @return - * If {@code code} is {@code null} or an empty string, - * {@code null} is returned. - * Otherwise, if {@code caseSensitive} is {@code true}, - * {@code code} is returned as is. - * Otherwise, {@code code.toUpperCase()} is returned. - */ - static String canonicalize(String code, boolean caseSensitive) - { - if (code == null || code.length() == 0) - { - return null; - } - - if (caseSensitive) - { - return code; - } - else - { - return code.toUpperCase(); - } - } - - - private static LDCountryCode getByAlpha2Code(String code) - { - try - { - return Enum.valueOf(LDCountryCode.class, code); - } - catch (IllegalArgumentException e) - { - return null; - } - } - - - private static LDCountryCode getByAlpha3Code(String code) - { - return alpha3Map.get(code); - } - - - /** - * Get a {@code CountryCode} that corresponds to the given - * ISO 3166-1 - * numeric code. - * - * @param code - * An ISO 3166-1 numeric code. - * - * @return - * A {@code CountryCode} instance, or {@code null} if not found. - * If 0 or a negative value is given, {@code null} is returned. - */ - public static LDCountryCode getByCode(int code) - { - if (code <= 0) - { - return null; - } - - return numericMap.get(code); - } - - - /** - * Get a list of {@code CountryCode} by a name regular expression. - * - *

    - * This method is almost equivalent to {@link #findByName(Pattern) - * findByName}{@code (Pattern.compile(regex))}. - *

    - * - * @param regex - * Regular expression for names. - * - * @return - * List of {@code CountryCode}. If nothing has matched, - * an empty list is returned. - * - * @throws IllegalArgumentException - * {@code regex} is {@code null}. - * - * @throws java.util.regex.PatternSyntaxException - * {@code regex} failed to be compiled. - * - * @since 1.11 - */ - public static List findByName(String regex) - { - if (regex == null) - { - throw new IllegalArgumentException("regex is null."); - } - - // Compile the regular expression. This may throw - // java.util.regex.PatternSyntaxException. - Pattern pattern = Pattern.compile(regex); - - return findByName(pattern); - } - - - /** - * Get a list of {@code CountryCode} by a name pattern. - * - *

    - * For example, the list obtained by the code snippet below: - *

    - * - *
    -   * Pattern pattern = Pattern.compile(".*United.*");
    -   * List<CountryCode> list = CountryCode.findByName(pattern);
    - * - *

    - * contains 6 {@code CountryCode}s as listed below. - *

    - * - *
      - *
    1. {@link #AE} : United Arab Emirates - *
    2. {@link #GB} : United Kingdom - *
    3. {@link #TZ} : Tanzania, United Republic of - *
    4. {@link #UK} : United Kingdom - *
    5. {@link #UM} : United States Minor Outlying Islands - *
    6. {@link #US} : United States - *
    - * - * @param pattern - * Pattern to match names. - * - * @return - * List of {@code CountryCode}. If nothing has matched, - * an empty list is returned. - * - * @throws IllegalArgumentException - * {@code pattern} is {@code null}. - * - * @since 1.11 - */ - public static List findByName(Pattern pattern) - { - if (pattern == null) - { - throw new IllegalArgumentException("pattern is null."); - } - - List list = new ArrayList<>(); - - for (LDCountryCode entry : values()) - { - // If the name matches the given pattern. - if (pattern.matcher(entry.getName()).matches()) - { - list.add(entry); - } - } - - return list; - } -} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/LDUser.java b/src/main/java/com/launchdarkly/client/LDUser.java index d9473bec2..4458679b1 100644 --- a/src/main/java/com/launchdarkly/client/LDUser.java +++ b/src/main/java/com/launchdarkly/client/LDUser.java @@ -22,7 +22,6 @@ import java.util.Objects; import java.util.Set; import java.util.TreeSet; -import java.util.regex.Pattern; /** * A {@code LDUser} object contains specific attributes of a user browsing your site. The only mandatory property property is the {@code key}, @@ -397,52 +396,23 @@ public Builder privateSecondary(String s) { } /** - * Set the country for a user. - *

    - * 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. In the next major release, this validation - * will be removed, and the country field will be treated as a normal string. + * Set the country for a user. Before version 5.0.0, this field was validated and normalized by the SDK + * as an ISO-3166-1 country code before assignment. This behavior has been removed so that the SDK can + * treat this field as a normal string, leaving the meaning of this field up to the application. * * @param s the country for the user * @return the builder */ - @SuppressWarnings("deprecation") public Builder country(String s) { - LDCountryCode countryCode = LDCountryCode.getByCode(s, false); - - if (countryCode == null) { - List codes = LDCountryCode.findByName("^" + Pattern.quote(s) + ".*"); - - if (codes.isEmpty()) { - logger.warn("Invalid country. Expected valid ISO-3166-1 code: " + s); - } else if (codes.size() > 1) { - // See if any of the codes is an exact match - for (LDCountryCode c : codes) { - if (c.getName().equals(s)) { - country = c.getAlpha2(); - return this; - } - } - logger.warn("Ambiguous country. Provided code matches multiple countries: " + s); - country = codes.get(0).getAlpha2(); - } else { - country = codes.get(0).getAlpha2(); - } - } else { - country = countryCode.getAlpha2(); - } - + this.country = s; return this; } /** * Set the country for a user, and ensures that the country attribute will not be sent back to LaunchDarkly. - *

    - * 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. In the next major release, this validation - * will be removed, and the country field will be treated as a normal string. + * Before version 5.0.0, this field was validated and normalized by the SDK as an ISO-3166-1 country code + * before assignment. This behavior has been removed so that the SDK can treat this field as a normal string, + * leaving the meaning of this field up to the application. * * @param s the country for the user * @return the builder @@ -452,36 +422,6 @@ public Builder privateCountry(String s) { return country(s); } - /** - * Set the country for a user. - * - * @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 == null ? null : country.getAlpha2(); - return this; - } - - /** - * Set the country for a user, and ensures that the country attribute will not be sent back to LaunchDarkly. - * - * @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); - } - /** * Sets the user's first name * diff --git a/src/test/java/com/launchdarkly/client/LDUserTest.java b/src/test/java/com/launchdarkly/client/LDUserTest.java index ef0471525..5487ff71f 100644 --- a/src/test/java/com/launchdarkly/client/LDUserTest.java +++ b/src/test/java/com/launchdarkly/client/LDUserTest.java @@ -170,52 +170,14 @@ public void canSetAnonymous() { @Test public void canSetCountry() { - LDUser user = new LDUser.Builder("key").country(LDCountryCode.US).build(); - assertEquals("US", user.getCountry().stringValue()); - } - - @Test - public void canSetCountryAsString() { - LDUser user = new LDUser.Builder("key").country("US").build(); - assertEquals("US", user.getCountry().stringValue()); - } - - @Test - public void canSetCountryAs3CharacterString() { - LDUser user = new LDUser.Builder("key").country("USA").build(); - 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().stringValue()); - } - - @Test - public void ambiguousCountryNameSetsCountryWithPartialMatch() { - // For an ambiguous match, we return the first match - LDUser user = new LDUser.Builder("key").country("United St").build(); - assertNotNull(user.getCountry()); - } - - @Test - public void partialUniqueMatchSetsCountry() { - LDUser user = new LDUser.Builder("key").country("United States Minor").build(); - assertEquals("UM", user.getCountry().stringValue()); - } - - @Test - public void invalidCountryNameDoesNotSetCountry() { - LDUser user = new LDUser.Builder("key").country("East Jibip").build(); - assertEquals(LDValue.ofNull(), user.getCountry()); + LDUser user = new LDUser.Builder("key").country("u").build(); + assertEquals("u", user.getCountry().stringValue()); } @Test public void canSetPrivateCountry() { - LDUser user = new LDUser.Builder("key").privateCountry(LDCountryCode.US).build(); - assertEquals("US", user.getCountry().stringValue()); + LDUser user = new LDUser.Builder("key").privateCountry("u").build(); + assertEquals("u", user.getCountry().stringValue()); assertEquals(ImmutableSet.of("country"), user.privateAttributeNames); } @@ -327,8 +289,8 @@ private Map getUserPropertiesJsonMap() { "{\"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").country("value").build(), + "{\"key\":\"userkey\",\"country\":\"value\"}"); builder.put(new LDUser.Builder("userkey").custom("thing", "value").build(), "{\"key\":\"userkey\",\"custom\":{\"thing\":\"value\"}}"); return builder.build(); @@ -353,7 +315,7 @@ public void privateAttributeEncodingRedactsAllPrivateAttributes() { .firstName("f") .lastName("l") .anonymous(true) - .country(LDCountryCode.US) + .country("USA") .custom("thing", "value") .build(); Set redacted = ImmutableSet.of("secondary", "ip", "email", "name", "avatar", "firstName", "lastName", "country", "thing"); From 3a800a8fb657093ece84803668cacb732218ea9f Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 10 Dec 2019 15:09:15 -0800 Subject: [PATCH 211/673] 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 1021d3994274de3e86d86f5371da6f7c52e4b249 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 10 Dec 2019 15:12:54 -0800 Subject: [PATCH 212/673] (5.0) refactor model classes and evaluation logic --- .../java/com/launchdarkly/client/Clause.java | 93 ---- .../client/DefaultFeatureRequestor.java | 8 +- .../client/EvaluationException.java | 11 - .../com/launchdarkly/client/Evaluator.java | 332 ++++++++++++ .../client/EvaluatorBucketing.java | 59 ++ .../com/launchdarkly/client/EventFactory.java | 18 +- .../com/launchdarkly/client/FeatureFlag.java | 239 --------- .../client/FeatureFlagBuilder.java | 128 ----- .../client/FeatureFlagsState.java | 2 +- .../launchdarkly/client/FeatureRequestor.java | 10 +- .../com/launchdarkly/client/FlagModel.java | 366 +++++++++++++ .../com/launchdarkly/client/LDClient.java | 100 ++-- .../com/launchdarkly/client/Prerequisite.java | 22 - .../java/com/launchdarkly/client/Rule.java | 51 -- .../java/com/launchdarkly/client/Segment.java | 137 ----- .../com/launchdarkly/client/SegmentRule.java | 34 -- .../launchdarkly/client/StreamProcessor.java | 8 +- .../java/com/launchdarkly/client/Target.java | 24 - .../launchdarkly/client/TestFeatureStore.java | 44 +- .../client/VariationOrRollout.java | 97 ---- .../client/VersionedDataKind.java | 18 +- .../client/DefaultEventProcessorTest.java | 37 +- ...tTest.java => EvaluatorBucketingTest.java} | 11 +- .../client/EvaluatorClauseTest.java | 131 +++++ .../client/EvaluatorRuleTest.java | 77 +++ ...st.java => EvaluatorSegmentMatchTest.java} | 76 +-- .../launchdarkly/client/EvaluatorTest.java | 342 ++++++++++++ .../client/EvaluatorTestUtil.java | 102 ++++ .../launchdarkly/client/EventOutputTest.java | 17 +- .../client/EventSummarizerTest.java | 9 +- .../launchdarkly/client/FeatureFlagTest.java | 505 ------------------ .../client/FeatureFlagsStateTest.java | 42 +- .../client/FeatureRequestorTest.java | 16 +- .../client/FeatureStoreDatabaseTestBase.java | 29 +- .../client/FeatureStoreTestBase.java | 37 +- .../client/LDClientEndToEndTest.java | 3 +- .../client/LDClientEvaluationTest.java | 42 +- .../client/LDClientEventTest.java | 89 +-- .../client/LDClientLddModeTest.java | 4 +- .../client/LDClientOfflineTest.java | 2 +- .../com/launchdarkly/client/LDClientTest.java | 29 +- .../launchdarkly/client/ModelBuilders.java | 282 ++++++++++ .../client/PollingProcessorTest.java | 6 +- .../com/launchdarkly/client/RuleBuilder.java | 44 -- .../client/StreamProcessorTest.java | 14 +- .../com/launchdarkly/client/TestUtil.java | 36 +- 46 files changed, 2049 insertions(+), 1734 deletions(-) delete mode 100644 src/main/java/com/launchdarkly/client/Clause.java delete mode 100644 src/main/java/com/launchdarkly/client/EvaluationException.java create mode 100644 src/main/java/com/launchdarkly/client/Evaluator.java create mode 100644 src/main/java/com/launchdarkly/client/EvaluatorBucketing.java delete mode 100644 src/main/java/com/launchdarkly/client/FeatureFlag.java delete mode 100644 src/main/java/com/launchdarkly/client/FeatureFlagBuilder.java create mode 100644 src/main/java/com/launchdarkly/client/FlagModel.java delete mode 100644 src/main/java/com/launchdarkly/client/Prerequisite.java delete mode 100644 src/main/java/com/launchdarkly/client/Rule.java delete mode 100644 src/main/java/com/launchdarkly/client/Segment.java delete mode 100644 src/main/java/com/launchdarkly/client/SegmentRule.java delete mode 100644 src/main/java/com/launchdarkly/client/Target.java delete mode 100644 src/main/java/com/launchdarkly/client/VariationOrRollout.java rename src/test/java/com/launchdarkly/client/{VariationOrRolloutTest.java => EvaluatorBucketingTest.java} (73%) create mode 100644 src/test/java/com/launchdarkly/client/EvaluatorClauseTest.java create mode 100644 src/test/java/com/launchdarkly/client/EvaluatorRuleTest.java rename src/test/java/com/launchdarkly/client/{SegmentTest.java => EvaluatorSegmentMatchTest.java} (53%) create mode 100644 src/test/java/com/launchdarkly/client/EvaluatorTest.java create mode 100644 src/test/java/com/launchdarkly/client/EvaluatorTestUtil.java delete mode 100644 src/test/java/com/launchdarkly/client/FeatureFlagTest.java create mode 100644 src/test/java/com/launchdarkly/client/ModelBuilders.java delete mode 100644 src/test/java/com/launchdarkly/client/RuleBuilder.java diff --git a/src/main/java/com/launchdarkly/client/Clause.java b/src/main/java/com/launchdarkly/client/Clause.java deleted file mode 100644 index 3b8278fbc..000000000 --- a/src/main/java/com/launchdarkly/client/Clause.java +++ /dev/null @@ -1,93 +0,0 @@ -package com.launchdarkly.client; - -import com.launchdarkly.client.value.LDValue; -import com.launchdarkly.client.value.LDValueType; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -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 boolean negate; - - public Clause() { - } - - public Clause(String attribute, Operator op, List values, boolean negate) { - this.attribute = attribute; - this.op = op; - this.values = values; - this.negate = negate; - } - - boolean matchesUserNoSegments(LDUser user) { - LDValue userValue = user.getValueForEvaluation(attribute); - if (userValue.isNull()) { - return false; - } - - 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(value)) { - return maybeNegate(true); - } - } - return maybeNegate(false); - } else if (userValue.getType() != LDValueType.OBJECT) { - return maybeNegate(matchAny(userValue)); - } - logger.warn("Got unexpected user attribute type \"{}\" for user key \"{}\" and attribute \"{}\"", - userValue.getType(), user.getKey(), attribute); - return false; - } - - 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 (LDValue j: values) { - if (j.isString()) { - Segment segment = store.get(SEGMENTS, j.stringValue()); - if (segment != null) { - if (segment.matchesUser(user)) { - return maybeNegate(true); - } - } - } - } - return maybeNegate(false); - } - - return matchesUserNoSegments(user); - } - - private boolean matchAny(LDValue userValue) { - if (op != null) { - for (LDValue v : values) { - if (op.apply(userValue, v)) { - return true; - } - } - } - return false; - } - - private boolean maybeNegate(boolean b) { - if (negate) - return !b; - else - return b; - } -} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java b/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java index 99bbd9535..f7b05ee5f 100644 --- a/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java +++ b/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java @@ -54,14 +54,14 @@ public void close() { shutdownHttpClient(httpClient); } - public FeatureFlag getFlag(String featureKey) throws IOException, HttpErrorException { + public FlagModel.FeatureFlag getFlag(String featureKey) throws IOException, HttpErrorException { String body = get(GET_LATEST_FLAGS_PATH + "/" + featureKey); - return config.gson.fromJson(body, FeatureFlag.class); + return config.gson.fromJson(body, FlagModel.FeatureFlag.class); } - public Segment getSegment(String segmentKey) throws IOException, HttpErrorException { + public FlagModel.Segment getSegment(String segmentKey) throws IOException, HttpErrorException { String body = get(GET_LATEST_SEGMENTS_PATH + "/" + segmentKey); - return config.gson.fromJson(body, Segment.class); + return config.gson.fromJson(body, FlagModel.Segment.class); } public AllData getAllData() throws IOException, HttpErrorException { diff --git a/src/main/java/com/launchdarkly/client/EvaluationException.java b/src/main/java/com/launchdarkly/client/EvaluationException.java deleted file mode 100644 index 174a2417e..000000000 --- a/src/main/java/com/launchdarkly/client/EvaluationException.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.launchdarkly.client; - -/** - * 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/Evaluator.java b/src/main/java/com/launchdarkly/client/Evaluator.java new file mode 100644 index 000000000..c20c5c47c --- /dev/null +++ b/src/main/java/com/launchdarkly/client/Evaluator.java @@ -0,0 +1,332 @@ +package com.launchdarkly.client; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Iterables; +import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.client.value.LDValueType; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; + +/** + * Encapsulates the feature flag evaluation logic. The Evaluator has no knowledge of the rest of the SDK environment; + * if it needs to retrieve flags or segments that are referenced by a flag, it does so through a read-only interface + * that is provided in the constructor. It also produces feature requests as appropriate for any referenced prerequisite + * flags, but does not send them. + */ +class Evaluator { + private final static Logger logger = LoggerFactory.getLogger(Evaluator.class); + + private final Getters getters; + + /** + * An abstraction of getting flags or segments by key. This ensures that Evaluator cannot modify the feature store, + * and simplifies testing. + */ + static interface Getters { + FlagModel.FeatureFlag getFlag(String key); + FlagModel.Segment getSegment(String key); + } + + /** + * Internal container for the results of an evaluation. This consists of the same information that is in an + * {@link EvaluationDetail}, plus a list of any feature request events generated by prerequisite flags. + * + * Unlike all the other simple data containers in the SDK, this is mutable. The reason is that flag evaluations + * may be done very frequently and we would like to minimize the amount of heap churn from intermediate objects, + * and Java does not support multiple return values as Go does, or value types as C# does. + * + * We never expose an EvalResult to application code and we never preserve a reference to it outside of a single + * xxxVariation() or xxxVariationDetail() call, so the risks from mutability are minimal. The only setter method + * that is accessible from outside of the Evaluator class is setValue(), which is exposed so that LDClient can + * replace null values with default values, + */ + static class EvalResult { + private LDValue value = LDValue.ofNull(); + private Integer variationIndex = null; + private EvaluationReason reason = null; + private List prerequisiteEvents; + + public EvalResult(LDValue value, Integer variationIndex, EvaluationReason reason) { + this.value = value; + this.variationIndex = variationIndex; + this.reason = reason; + } + + public static EvalResult error(EvaluationReason.ErrorKind errorKind) { + return new EvalResult(LDValue.ofNull(), null, EvaluationReason.error(errorKind)); + } + + LDValue getValue() { + return LDValue.normalize(value); + } + + void setValue(LDValue value) { + this.value = value; + } + + Integer getVariationIndex() { + return variationIndex; + } + + boolean isDefault() { + return variationIndex == null; + } + + EvaluationReason getReason() { + return reason; + } + + EvaluationDetail getDetails() { + return EvaluationDetail.fromValue(LDValue.normalize(value), variationIndex, reason); + } + + Iterable getPrerequisiteEvents() { + return prerequisiteEvents == null ? ImmutableList.of() : prerequisiteEvents; + } + + private void setPrerequisiteEvents(List prerequisiteEvents) { + this.prerequisiteEvents = prerequisiteEvents; + } + } + + Evaluator(Getters getters) { + this.getters = getters; + } + + /** + * The client's entry point for evaluating a flag. No other Evaluator methods should be exposed. + * + * @param flag an existing feature flag; any other referenced flags or segments will be queried via {@link Getters} + * @param user the user to evaluate against + * @param eventFactory produces feature request events + * @return an {@link EvalResult} + */ + EvalResult evaluate(FlagModel.FeatureFlag flag, LDUser user, EventFactory eventFactory) { + 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", flag.getKey()); + return new EvalResult(null, null, EvaluationReason.error(EvaluationReason.ErrorKind.USER_NOT_SPECIFIED)); + } + + // If the flag doesn't have any prerequisites (which most flags don't) then it cannot generate any feature + // request events for prerequisites and we can skip allocating a List. + List prerequisiteEvents = (flag.getPrerequisites() == null || flag.getPrerequisites().isEmpty()) ? + null : new ArrayList(); + EvalResult result = evaluateInternal(flag, user, eventFactory, prerequisiteEvents); + if (prerequisiteEvents != null) { + result.setPrerequisiteEvents(prerequisiteEvents); + } + return result; + } + + private EvalResult evaluateInternal(FlagModel.FeatureFlag flag, LDUser user, EventFactory eventFactory, + List eventsOut) { + if (!flag.isOn()) { + return getOffValue(flag, EvaluationReason.off()); + } + + EvaluationReason prereqFailureReason = checkPrerequisites(flag, user, eventFactory, eventsOut); + if (prereqFailureReason != null) { + return getOffValue(flag, prereqFailureReason); + } + + // Check to see if targets match + List targets = flag.getTargets(); + if (targets != null) { + for (FlagModel.Target target: targets) { + for (String v : target.getValues()) { + if (v.equals(user.getKey().stringValue())) { + return getVariation(flag, target.getVariation(), EvaluationReason.targetMatch()); + } + } + } + } + // Now walk through the rules and see if any match + List rules = flag.getRules(); + if (rules != null) { + for (int i = 0; i < rules.size(); i++) { + FlagModel.Rule rule = rules.get(i); + if (ruleMatchesUser(flag, rule, user)) { + return getValueForVariationOrRollout(flag, rule, user, EvaluationReason.ruleMatch(i, rule.getId())); + } + } + } + // Walk through the fallthrough and see if it matches + return getValueForVariationOrRollout(flag, flag.getFallthrough(), 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(FlagModel.FeatureFlag flag, LDUser user, EventFactory eventFactory, + List eventsOut) { + List prerequisites = flag.getPrerequisites(); + if (prerequisites == null) { + return null; + } + for (FlagModel.Prerequisite prereq: prerequisites) { + boolean prereqOk = true; + FlagModel.FeatureFlag prereqFeatureFlag = getters.getFlag(prereq.getKey()); + if (prereqFeatureFlag == null) { + logger.error("Could not retrieve prerequisite flag \"{}\" when evaluating \"{}\"", prereq.getKey(), flag.getKey()); + prereqOk = false; + } else { + EvalResult prereqEvalResult = evaluateInternal(prereqFeatureFlag, user, eventFactory, eventsOut); + // 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; + } + if (eventsOut != null) { + eventsOut.add(eventFactory.newPrerequisiteFeatureRequestEvent(prereqFeatureFlag, user, prereqEvalResult, flag)); + } + } + if (!prereqOk) { + return EvaluationReason.prerequisiteFailed(prereq.getKey()); + } + } + return null; + } + + private EvalResult getVariation(FlagModel.FeatureFlag flag, int variation, EvaluationReason reason) { + List variations = flag.getVariations(); + if (variation < 0 || variation >= variations.size()) { + logger.error("Data inconsistency in feature flag \"{}\": invalid variation index", flag.getKey()); + return EvalResult.error(EvaluationReason.ErrorKind.MALFORMED_FLAG); + } else { + return new EvalResult(variations.get(variation), variation, reason); + } + } + + private EvalResult getOffValue(FlagModel.FeatureFlag flag, EvaluationReason reason) { + Integer offVariation = flag.getOffVariation(); + if (offVariation == null) { // off variation unspecified - return default value + return new EvalResult(null, null, reason); + } else { + return getVariation(flag, offVariation, reason); + } + } + + private EvalResult getValueForVariationOrRollout(FlagModel.FeatureFlag flag, FlagModel.VariationOrRollout vr, LDUser user, EvaluationReason reason) { + Integer index = EvaluatorBucketing.variationIndexForUser(vr, user, flag.getKey(), flag.getSalt()); + if (index == null) { + logger.error("Data inconsistency in feature flag \"{}\": variation/rollout object with no variation or rollout", flag.getKey()); + return EvalResult.error(EvaluationReason.ErrorKind.MALFORMED_FLAG); + } else { + return getVariation(flag, index, reason); + } + } + + private boolean ruleMatchesUser(FlagModel.FeatureFlag flag, FlagModel.Rule rule, LDUser user) { + Iterable clauses = rule.getClauses(); + if (clauses != null) { + for (FlagModel.Clause clause: clauses) { + if (!clauseMatchesUser(clause, user)) { + return false; + } + } + } + return true; + } + + private boolean clauseMatchesUser(FlagModel.Clause clause, 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 (clause.getOp() == Operator.segmentMatch) { + for (LDValue j: clause.getValues()) { + if (j.isString()) { + FlagModel.Segment segment = getters.getSegment(j.stringValue()); + if (segment != null) { + if (segmentMatchesUser(segment, user)) { + return maybeNegate(clause, true); + } + } + } + } + return maybeNegate(clause, false); + } + + return clauseMatchesUserNoSegments(clause, user); + } + + private boolean clauseMatchesUserNoSegments(FlagModel.Clause clause, LDUser user) { + LDValue userValue = user.getValueForEvaluation(clause.getAttribute()); + if (userValue.isNull()) { + return false; + } + + 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 (clauseMatchAny(clause, value)) { + return maybeNegate(clause, true); + } + } + return maybeNegate(clause, false); + } else if (userValue.getType() != LDValueType.OBJECT) { + return maybeNegate(clause, clauseMatchAny(clause, userValue)); + } + logger.warn("Got unexpected user attribute type \"{}\" for user key \"{}\" and attribute \"{}\"", + userValue.getType(), user.getKey(), clause.getAttribute()); + return false; + } + + private boolean clauseMatchAny(FlagModel.Clause clause, LDValue userValue) { + Operator op = clause.getOp(); + if (op != null) { + for (LDValue v : clause.getValues()) { + if (op.apply(userValue, v)) { + return true; + } + } + } + return false; + } + + private boolean maybeNegate(FlagModel.Clause clause, boolean b) { + return clause.isNegate() ? !b : b; + } + + private boolean segmentMatchesUser(FlagModel.Segment segment, LDUser user) { + String userKey = user.getKeyAsString(); + if (userKey == null) { + return false; + } + if (Iterables.contains(segment.getIncluded(), userKey)) { + return true; + } + if (Iterables.contains(segment.getExcluded(), userKey)) { + return false; + } + for (FlagModel.SegmentRule rule: segment.getRules()) { + if (segmentRuleMatchesUser(rule, user, segment.getKey(), segment.getSalt())) { + return true; + } + } + return false; + } + + private boolean segmentRuleMatchesUser(FlagModel.SegmentRule segmentRule, LDUser user, String segmentKey, String salt) { + for (FlagModel.Clause c: segmentRule.getClauses()) { + if (!clauseMatchesUserNoSegments(c, user)) { + return false; + } + } + + // If the Weight is absent, this rule matches + if (segmentRule.getWeight() == null) { + return true; + } + + // All of the clauses are met. See if the user buckets in + double bucket = EvaluatorBucketing.bucketUser(user, segmentKey, segmentRule.getBucketBy(), salt); + double weight = (double)segmentRule.getWeight() / 100000.0; + return bucket < weight; + } +} diff --git a/src/main/java/com/launchdarkly/client/EvaluatorBucketing.java b/src/main/java/com/launchdarkly/client/EvaluatorBucketing.java new file mode 100644 index 000000000..6994e8181 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/EvaluatorBucketing.java @@ -0,0 +1,59 @@ +package com.launchdarkly.client; + +import com.launchdarkly.client.value.LDValue; + +import org.apache.commons.codec.digest.DigestUtils; + +/** + * Encapsulates the logic for percentage rollouts. + */ +abstract class EvaluatorBucketing { + private static final float LONG_SCALE = (float) 0xFFFFFFFFFFFFFFFL; + + // 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). + static Integer variationIndexForUser(FlagModel.VariationOrRollout vr, LDUser user, String key, String salt) { + Integer variation = vr.getVariation(); + if (variation != null) { + return variation; + } else { + FlagModel.Rollout rollout = vr.getRollout(); + if (rollout != null) { + float bucket = bucketUser(user, key, rollout.getBucketBy(), salt); + float sum = 0F; + for (FlagModel.WeightedVariation wv : rollout.getVariations()) { + sum += (float) wv.getWeight() / 100000F; + if (bucket < sum) { + return wv.getVariation(); + } + } + } + } + return null; + } + + static float bucketUser(LDUser user, String key, String attr, String salt) { + LDValue userValue = user.getValueForEvaluation(attr == null ? "key" : attr); + String idHash = getBucketableStringValue(userValue); + if (idHash != null) { + 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); + return (float) longVal / LONG_SCALE; + } + return 0F; + } + + 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; + } + } +} diff --git a/src/main/java/com/launchdarkly/client/EventFactory.java b/src/main/java/com/launchdarkly/client/EventFactory.java index 4afc7240f..4fe75a681 100644 --- a/src/main/java/com/launchdarkly/client/EventFactory.java +++ b/src/main/java/com/launchdarkly/client/EventFactory.java @@ -9,7 +9,7 @@ abstract class EventFactory { protected abstract long getTimestamp(); protected abstract boolean isIncludeReasons(); - public Event.FeatureRequest newFeatureRequestEvent(FeatureFlag flag, LDUser user, LDValue value, + public Event.FeatureRequest newFeatureRequestEvent(FlagModel.FeatureFlag flag, LDUser user, LDValue value, Integer variationIndex, EvaluationReason reason, LDValue defaultValue, String prereqOf) { boolean requireExperimentData = isExperiment(flag, reason); return new Event.FeatureRequest( @@ -28,13 +28,13 @@ public Event.FeatureRequest newFeatureRequestEvent(FeatureFlag flag, LDUser user ); } - public Event.FeatureRequest newFeatureRequestEvent(FeatureFlag flag, LDUser user, EvaluationDetail result, LDValue defaultVal) { + public Event.FeatureRequest newFeatureRequestEvent(FlagModel.FeatureFlag flag, LDUser user, Evaluator.EvalResult 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, LDValue defaultValue, + public Event.FeatureRequest newDefaultFeatureRequestEvent(FlagModel.FeatureFlag flag, LDUser user, LDValue defaultValue, EvaluationReason.ErrorKind errorKind) { return new Event.FeatureRequest(getTimestamp(), flag.getKey(), user, flag.getVersion(), null, defaultValue, defaultValue, isIncludeReasons() ? EvaluationReason.error(errorKind) : null, @@ -47,10 +47,10 @@ public Event.FeatureRequest newUnknownFeatureRequestEvent(String key, LDUser use isIncludeReasons() ? EvaluationReason.error(errorKind) : null, null, false, null, false); } - 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(), + public Event.FeatureRequest newPrerequisiteFeatureRequestEvent(FlagModel.FeatureFlag prereqFlag, LDUser user, + Evaluator.EvalResult details, FlagModel.FeatureFlag prereqOf) { + return newFeatureRequestEvent(prereqFlag, user, details == null ? null : details.getValue(), + details == null ? null : details.getVariationIndex(), details == null ? null : details.getReason(), LDValue.ofNull(), prereqOf.getKey()); } @@ -67,7 +67,7 @@ public Event.Identify newIdentifyEvent(LDUser user) { return new Event.Identify(getTimestamp(), user); } - private boolean isExperiment(FeatureFlag flag, EvaluationReason reason) { + private boolean isExperiment(FlagModel.FeatureFlag flag, EvaluationReason reason) { if (reason == null) { // doesn't happen in real life, but possible in testing return false; @@ -86,7 +86,7 @@ private boolean isExperiment(FeatureFlag flag, EvaluationReason reason) { // 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); + FlagModel.Rule rule = flag.getRules().get(ruleIndex); return rule.isTrackEvents(); } return false; diff --git a/src/main/java/com/launchdarkly/client/FeatureFlag.java b/src/main/java/com/launchdarkly/client/FeatureFlag.java deleted file mode 100644 index 62a9786a5..000000000 --- a/src/main/java/com/launchdarkly/client/FeatureFlag.java +++ /dev/null @@ -1,239 +0,0 @@ -package com.launchdarkly.client; - -import com.launchdarkly.client.value.LDValue; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.ArrayList; -import java.util.List; - -import static com.google.common.base.Preconditions.checkNotNull; -import static com.launchdarkly.client.VersionedDataKind.FEATURES; - -class FeatureFlag implements VersionedData { - private final static Logger logger = LoggerFactory.getLogger(FeatureFlag.class); - - private String key; - private int version; - private boolean on; - private List prerequisites; - private String salt; - private List targets; - private List rules; - private VariationOrRollout fallthrough; - private Integer offVariation; //optional - private List variations; - private boolean clientSide; - private boolean trackEvents; - private boolean trackEventsFallthrough; - private Long debugEventsUntilDate; - private boolean deleted; - - // We need this so Gson doesn't complain in certain java environments that restrict unsafe allocation - FeatureFlag() {} - - 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) { - this.key = key; - this.version = version; - this.on = on; - this.prerequisites = prerequisites; - this.salt = salt; - this.targets = targets; - this.rules = rules; - this.fallthrough = fallthrough; - this.offVariation = offVariation; - this.variations = variations; - this.clientSide = clientSide; - this.trackEvents = trackEvents; - this.trackEventsFallthrough = trackEventsFallthrough; - this.debugEventsUntilDate = debugEventsUntilDate; - this.deleted = deleted; - } - - EvalResult evaluate(LDUser user, FeatureStore featureStore, EventFactory eventFactory) { - List prereqEvents = new ArrayList<>(); - - 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, LDValue.ofNull()), 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); - } - - // 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()); - } - } - } - } - // Now walk through the rules and see if any match - if (rules != null) { - 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())); - } - } - } - // Walk through the fallthrough and see if it matches - 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) { - if (prerequisites == null) { - return null; - } - for (int i = 0; i < prerequisites.size(); i++) { - boolean prereqOk = true; - Prerequisite prereq = prerequisites.get(i); - FeatureFlag prereqFeatureFlag = featureStore.get(FEATURES, prereq.getKey()); - if (prereqFeatureFlag == null) { - logger.error("Could not retrieve prerequisite flag \"{}\" when evaluating \"{}\"", prereq.getKey(), key); - prereqOk = false; - } 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; - } - events.add(eventFactory.newPrerequisiteFeatureRequestEvent(prereqFeatureFlag, user, prereqEvalResult, this)); - } - if (!prereqOk) { - return EvaluationReason.prerequisiteFailed(prereq.getKey()); - } - } - 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, LDValue.ofNull()); - } - LDValue value = LDValue.normalize(variations.get(variation)); - // normalize() ensures that nulls become LDValue.ofNull() - Gson may give us nulls - return EvaluationDetail.fromValue(value, variation, reason); - } - - private EvaluationDetail getOffValue(EvaluationReason reason) { - if (offVariation == null) { // off variation unspecified - return default value - return EvaluationDetail.fromValue(LDValue.ofNull(), null, reason); - } - return getVariation(offVariation, 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, LDValue.ofNull()); - } - return getVariation(index, reason); - } - - public int getVersion() { - return version; - } - - public String getKey() { - return key; - } - - public boolean isTrackEvents() { - return trackEvents; - } - - public boolean isTrackEventsFallthrough() { - return trackEventsFallthrough; - } - - public Long getDebugEventsUntilDate() { - return debugEventsUntilDate; - } - - public boolean isDeleted() { - return deleted; - } - - boolean isOn() { - return on; - } - - List getPrerequisites() { - return prerequisites; - } - - String getSalt() { - return salt; - } - - List getTargets() { - return targets; - } - - List getRules() { - return rules; - } - - VariationOrRollout getFallthrough() { - return fallthrough; - } - - List getVariations() { - return variations; - } - - Integer getOffVariation() { - return offVariation; - } - - boolean isClientSide() { - return clientSide; - } - - static class EvalResult { - private final EvaluationDetail details; - private final List prerequisiteEvents; - - private EvalResult(EvaluationDetail details, List prerequisiteEvents) { - checkNotNull(details); - checkNotNull(prerequisiteEvents); - this.details = details; - this.prerequisiteEvents = prerequisiteEvents; - } - - EvaluationDetail getDetails() { - return details; - } - - List getPrerequisiteEvents() { - return prerequisiteEvents; - } - } -} diff --git a/src/main/java/com/launchdarkly/client/FeatureFlagBuilder.java b/src/main/java/com/launchdarkly/client/FeatureFlagBuilder.java deleted file mode 100644 index e97245985..000000000 --- a/src/main/java/com/launchdarkly/client/FeatureFlagBuilder.java +++ /dev/null @@ -1,128 +0,0 @@ -package com.launchdarkly.client; - -import com.launchdarkly.client.value.LDValue; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - -class FeatureFlagBuilder { - private String key; - private int version; - private boolean on; - private List prerequisites = new ArrayList<>(); - private String salt; - private List targets = new ArrayList<>(); - private List rules = new ArrayList<>(); - private VariationOrRollout fallthrough; - private Integer offVariation; - private List variations = new ArrayList<>(); - private boolean clientSide; - private boolean trackEvents; - private boolean trackEventsFallthrough; - private Long debugEventsUntilDate; - private boolean deleted; - - FeatureFlagBuilder(String key) { - this.key = key; - } - - FeatureFlagBuilder(FeatureFlag f) { - if (f != null) { - this.key = f.getKey(); - this.version = f.getVersion(); - this.on = f.isOn(); - this.prerequisites = f.getPrerequisites(); - this.salt = f.getSalt(); - this.targets = f.getTargets(); - this.rules = f.getRules(); - this.fallthrough = f.getFallthrough(); - this.offVariation = f.getOffVariation(); - this.variations = f.getVariations(); - this.clientSide = f.isClientSide(); - this.trackEvents = f.isTrackEvents(); - this.trackEventsFallthrough = f.isTrackEventsFallthrough(); - this.debugEventsUntilDate = f.getDebugEventsUntilDate(); - this.deleted = f.isDeleted(); - } - } - - FeatureFlagBuilder version(int version) { - this.version = version; - return this; - } - - FeatureFlagBuilder on(boolean on) { - this.on = on; - return this; - } - - FeatureFlagBuilder prerequisites(List prerequisites) { - this.prerequisites = prerequisites; - return this; - } - - FeatureFlagBuilder salt(String salt) { - this.salt = salt; - return this; - } - - FeatureFlagBuilder targets(List targets) { - this.targets = targets; - return this; - } - - FeatureFlagBuilder rules(List rules) { - this.rules = rules; - return this; - } - - FeatureFlagBuilder fallthrough(VariationOrRollout fallthrough) { - this.fallthrough = fallthrough; - return this; - } - - FeatureFlagBuilder offVariation(Integer offVariation) { - this.offVariation = offVariation; - return this; - } - - FeatureFlagBuilder variations(List variations) { - this.variations = variations; - return this; - } - - FeatureFlagBuilder variations(LDValue... variations) { - return variations(Arrays.asList(variations)); - } - - FeatureFlagBuilder clientSide(boolean clientSide) { - this.clientSide = clientSide; - return this; - } - - 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; - return this; - } - - FeatureFlagBuilder deleted(boolean deleted) { - this.deleted = deleted; - return this; - } - - FeatureFlag build() { - return new FeatureFlag(key, version, on, prerequisites, salt, targets, rules, fallthrough, offVariation, variations, - clientSide, trackEvents, trackEventsFallthrough, debugEventsUntilDate, deleted); - } -} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java b/src/main/java/com/launchdarkly/client/FeatureFlagsState.java index 0b9577496..80f915d1e 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlagsState.java @@ -146,7 +146,7 @@ Builder valid(boolean valid) { } @SuppressWarnings("deprecation") - Builder addFlag(FeatureFlag flag, EvaluationDetail eval) { + Builder addFlag(FlagModel.FeatureFlag flag, Evaluator.EvalResult eval) { flagValues.put(flag.getKey(), eval.getValue().asUnsafeJsonElement()); final boolean flagIsTracked = flag.isTrackEvents() || (flag.getDebugEventsUntilDate() != null && flag.getDebugEventsUntilDate() > System.currentTimeMillis()); diff --git a/src/main/java/com/launchdarkly/client/FeatureRequestor.java b/src/main/java/com/launchdarkly/client/FeatureRequestor.java index 9d8e7062c..5fb63d8e1 100644 --- a/src/main/java/com/launchdarkly/client/FeatureRequestor.java +++ b/src/main/java/com/launchdarkly/client/FeatureRequestor.java @@ -5,17 +5,17 @@ import java.util.Map; interface FeatureRequestor extends Closeable { - FeatureFlag getFlag(String featureKey) throws IOException, HttpErrorException; + FlagModel.FeatureFlag getFlag(String featureKey) throws IOException, HttpErrorException; - Segment getSegment(String segmentKey) throws IOException, HttpErrorException; + FlagModel.Segment getSegment(String segmentKey) throws IOException, HttpErrorException; AllData getAllData() throws IOException, HttpErrorException; static class AllData { - final Map flags; - final Map segments; + final Map flags; + final Map segments; - AllData(Map flags, Map segments) { + AllData(Map flags, Map segments) { this.flags = flags; this.segments = segments; } diff --git a/src/main/java/com/launchdarkly/client/FlagModel.java b/src/main/java/com/launchdarkly/client/FlagModel.java new file mode 100644 index 000000000..8347d6c8b --- /dev/null +++ b/src/main/java/com/launchdarkly/client/FlagModel.java @@ -0,0 +1,366 @@ +package com.launchdarkly.client; + +import com.launchdarkly.client.value.LDValue; + +import java.util.List; + +/** + * Defines the full data model for feature flags and user segments, in the format provided by the SDK endpoints of + * the LaunchDarkly service. All sub-objects contained within flags and segments are also defined here as inner + * classes. + * + * These classes should all have package-private scope. They should not provide any logic other than standard + * property getters; the evaluation logic is in Evaluator. + */ +abstract class FlagModel { + static final class FeatureFlag implements VersionedData { + private String key; + private int version; + private boolean on; + private List prerequisites; + private String salt; + private List targets; + private List rules; + private VariationOrRollout fallthrough; + private Integer offVariation; //optional + private List variations; + private boolean clientSide; + private boolean trackEvents; + private boolean trackEventsFallthrough; + private Long debugEventsUntilDate; + private boolean deleted; + + // We need this so Gson doesn't complain in certain java environments that restrict unsafe allocation + FeatureFlag() {} + + 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) { + this.key = key; + this.version = version; + this.on = on; + this.prerequisites = prerequisites; + this.salt = salt; + this.targets = targets; + this.rules = rules; + this.fallthrough = fallthrough; + this.offVariation = offVariation; + this.variations = variations; + this.clientSide = clientSide; + this.trackEvents = trackEvents; + this.trackEventsFallthrough = trackEventsFallthrough; + this.debugEventsUntilDate = debugEventsUntilDate; + this.deleted = deleted; + } + + public int getVersion() { + return version; + } + + public String getKey() { + return key; + } + + boolean isTrackEvents() { + return trackEvents; + } + + boolean isTrackEventsFallthrough() { + return trackEventsFallthrough; + } + + Long getDebugEventsUntilDate() { + return debugEventsUntilDate; + } + + public boolean isDeleted() { + return deleted; + } + + boolean isOn() { + return on; + } + + List getPrerequisites() { + return prerequisites; + } + + String getSalt() { + return salt; + } + + List getTargets() { + return targets; + } + + List getRules() { + return rules; + } + + VariationOrRollout getFallthrough() { + return fallthrough; + } + + List getVariations() { + return variations; + } + + Integer getOffVariation() { + return offVariation; + } + + boolean isClientSide() { + return clientSide; + } + } + + static final class Prerequisite { + private String key; + private int variation; + + Prerequisite() {} + + Prerequisite(String key, int variation) { + this.key = key; + this.variation = variation; + } + + String getKey() { + return key; + } + + int getVariation() { + return variation; + } + } + + static final class Target { + private List values; + private int variation; + + Target() {} + + Target(List values, int variation) { + this.values = values; + this.variation = variation; + } + + List getValues() { + return values; + } + + int getVariation() { + return variation; + } + } + + /** + * Expresses a set of AND-ed matching conditions for a user, along with either the fixed variation or percent rollout + * to serve if the conditions match. + * Invariant: one of the variation or rollout must be non-nil. + */ + static final class Rule extends VariationOrRollout { + private String id; + private List clauses; + private boolean trackEvents; + + Rule() { + super(); + } + + 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; + } + } + + static class Clause { + private String attribute; + private Operator op; + private List values; //interpreted as an OR of values + private boolean negate; + + Clause() { + } + + Clause(String attribute, Operator op, List values, boolean negate) { + this.attribute = attribute; + this.op = op; + this.values = values; + this.negate = negate; + } + + String getAttribute() { + return attribute; + } + + Operator getOp() { + return op; + } + + List getValues() { + return values; + } + + boolean isNegate() { + return negate; + } + } + + static final class Rollout { + private List variations; + private String bucketBy; + + Rollout() {} + + Rollout(List variations, String bucketBy) { + this.variations = variations; + this.bucketBy = bucketBy; + } + + List getVariations() { + return variations; + } + + String getBucketBy() { + return bucketBy; + } + } + + /** + * Contains either a fixed variation or percent rollout to serve. + * Invariant: one of the variation or rollout must be non-nil. + */ + static class VariationOrRollout { + private Integer variation; + private Rollout rollout; + + VariationOrRollout() {} + + VariationOrRollout(Integer variation, Rollout rollout) { + this.variation = variation; + this.rollout = rollout; + } + + Integer getVariation() { + return variation; + } + + Rollout getRollout() { + return rollout; + } + } + + static final class WeightedVariation { + private int variation; + private int weight; + + WeightedVariation() {} + + WeightedVariation(int variation, int weight) { + this.variation = variation; + this.weight = weight; + } + + int getVariation() { + return variation; + } + + int getWeight() { + return weight; + } + } + + static final class Segment implements VersionedData { + private String key; + private List included; + private List excluded; + private String salt; + private List rules; + private int version; + private boolean deleted; + + Segment() {} + + Segment(String key, List included, List excluded, String salt, List rules, int version, boolean deleted) { + this.key = key; + this.included = included; + this.excluded = excluded; + this.salt = salt; + this.rules = rules; + this.version = version; + this.deleted = deleted; + } + + public String getKey() { + return key; + } + + Iterable getIncluded() { + return included; + } + + Iterable getExcluded() { + return excluded; + } + + String getSalt() { + return salt; + } + + Iterable getRules() { + return rules; + } + + public int getVersion() { + return version; + } + + public boolean isDeleted() { + return deleted; + } + } + + static final class SegmentRule { + private final List clauses; + private final Integer weight; + private final String bucketBy; + + SegmentRule(List clauses, Integer weight, String bucketBy) { + this.clauses = clauses; + this.weight = weight; + this.bucketBy = bucketBy; + } + + List getClauses() { + return clauses; + } + + Integer getWeight() { + return weight; + } + + String getBucketBy() { + return bucketBy; + } + } +} diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index 24cf3cd73..edb8eefe3 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -36,6 +36,7 @@ public final class LDClient implements LDClientInterface { private final LDConfig config; private final String sdkKey; + private final Evaluator evaluator; final EventProcessor eventProcessor; final UpdateProcessor updateProcessor; final FeatureStore featureStore; @@ -79,6 +80,16 @@ public LDClient(String sdkKey, LDConfig config) { } this.featureStore = new FeatureStoreClientWrapper(store); + this.evaluator = new Evaluator(new Evaluator.Getters() { + public FlagModel.FeatureFlag getFlag(String key) { + return LDClient.this.featureStore.get(VersionedDataKind.FEATURES, key); + } + + public FlagModel.Segment getSegment(String key) { + return LDClient.this.featureStore.get(VersionedDataKind.SEGMENTS, key); + } + }); + EventProcessorFactory epFactory = config.eventProcessorFactory == null ? Components.defaultEventProcessor() : config.eventProcessorFactory; this.eventProcessor = epFactory.createEventProcessor(sdkKey, config); @@ -191,19 +202,19 @@ public FeatureFlagsState allFlagsState(LDUser user, FlagsStateOption... options) } boolean clientSideOnly = FlagsStateOption.hasOption(options, FlagsStateOption.CLIENT_SIDE_ONLY); - Map flags = featureStore.all(FEATURES); - for (Map.Entry entry : flags.entrySet()) { - FeatureFlag flag = entry.getValue(); + Map flags = featureStore.all(FEATURES); + for (Map.Entry entry : flags.entrySet()) { + FlagModel.FeatureFlag flag = entry.getValue(); if (clientSideOnly && !flag.isClientSide()) { continue; } try { - EvaluationDetail result = flag.evaluate(user, featureStore, EventFactory.DEFAULT).getDetails(); + Evaluator.EvalResult result = evaluator.evaluate(flag, user, EventFactory.DEFAULT); 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, LDValue.ofNull())); + builder.addFlag(entry.getValue(), errorResult(EvaluationReason.ErrorKind.EXCEPTION, LDValue.ofNull())); } } return builder.build(); @@ -242,48 +253,49 @@ public LDValue jsonValueVariation(String featureKey, LDUser user, LDValue defaul @Override public EvaluationDetail boolVariationDetail(String featureKey, LDUser user, boolean defaultValue) { - EvaluationDetail details = evaluateDetail(featureKey, user, LDValue.of(defaultValue), true, + Evaluator.EvalResult result = evaluateInternal(featureKey, user, LDValue.of(defaultValue), true, EventFactory.DEFAULT_WITH_REASONS); - return EvaluationDetail.fromValue(details.getValue().booleanValue(), - details.getVariationIndex(), details.getReason()); + return EvaluationDetail.fromValue(result.getValue().booleanValue(), + result.getVariationIndex(), result.getReason()); } @Override public EvaluationDetail intVariationDetail(String featureKey, LDUser user, int defaultValue) { - EvaluationDetail details = evaluateDetail(featureKey, user, LDValue.of(defaultValue), true, + Evaluator.EvalResult result = evaluateInternal(featureKey, user, LDValue.of(defaultValue), true, EventFactory.DEFAULT_WITH_REASONS); - return EvaluationDetail.fromValue(details.getValue().intValue(), - details.getVariationIndex(), details.getReason()); + return EvaluationDetail.fromValue(result.getValue().intValue(), + result.getVariationIndex(), result.getReason()); } @Override public EvaluationDetail doubleVariationDetail(String featureKey, LDUser user, double defaultValue) { - EvaluationDetail details = evaluateDetail(featureKey, user, LDValue.of(defaultValue), true, + Evaluator.EvalResult result = evaluateInternal(featureKey, user, LDValue.of(defaultValue), true, EventFactory.DEFAULT_WITH_REASONS); - return EvaluationDetail.fromValue(details.getValue().doubleValue(), - details.getVariationIndex(), details.getReason()); + return EvaluationDetail.fromValue(result.getValue().doubleValue(), + result.getVariationIndex(), result.getReason()); } @Override public EvaluationDetail stringVariationDetail(String featureKey, LDUser user, String defaultValue) { - EvaluationDetail details = evaluateDetail(featureKey, user, LDValue.of(defaultValue), true, + Evaluator.EvalResult result = evaluateInternal(featureKey, user, LDValue.of(defaultValue), true, EventFactory.DEFAULT_WITH_REASONS); - return EvaluationDetail.fromValue(details.getValue().stringValue(), - details.getVariationIndex(), details.getReason()); + return EvaluationDetail.fromValue(result.getValue().stringValue(), + result.getVariationIndex(), result.getReason()); } @SuppressWarnings("deprecation") @Override public EvaluationDetail jsonVariationDetail(String featureKey, LDUser user, JsonElement defaultValue) { - EvaluationDetail details = evaluateDetail(featureKey, user, LDValue.unsafeFromJsonElement(defaultValue), false, + Evaluator.EvalResult result = evaluateInternal(featureKey, user, LDValue.unsafeFromJsonElement(defaultValue), false, EventFactory.DEFAULT_WITH_REASONS); - return EvaluationDetail.fromValue(details.getValue().asUnsafeJsonElement(), - details.getVariationIndex(), details.getReason()); + return EvaluationDetail.fromValue(result.getValue().asUnsafeJsonElement(), + result.getVariationIndex(), result.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); + Evaluator.EvalResult result = evaluateInternal(featureKey, user, defaultValue == null ? LDValue.ofNull() : defaultValue, false, EventFactory.DEFAULT_WITH_REASONS); + return EvaluationDetail.fromValue(result.getValue(), result.getVariationIndex(), result.getReason()); } @Override @@ -310,22 +322,15 @@ public boolean isFlagKnown(String featureKey) { } private LDValue evaluate(String featureKey, LDUser user, LDValue defaultValue, boolean checkType) { - return evaluateDetail(featureKey, user, defaultValue, checkType, EventFactory.DEFAULT).getValue(); + return evaluateInternal(featureKey, user, defaultValue, checkType, EventFactory.DEFAULT).getValue(); } - 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 details; + private Evaluator.EvalResult errorResult(EvaluationReason.ErrorKind errorKind, final LDValue defaultValue) { + return new Evaluator.EvalResult(defaultValue, null, EvaluationReason.error(errorKind)); } - private EvaluationDetail evaluateInternal(String featureKey, LDUser user, LDValue defaultValue, EventFactory eventFactory) { + private Evaluator.EvalResult evaluateInternal(String featureKey, LDUser user, LDValue defaultValue, boolean checkType, + 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); @@ -333,38 +338,45 @@ private EvaluationDetail evaluateInternal(String featureKey, LDUser use logger.warn("Evaluation called before client initialized for feature flag \"{}\"; feature store unavailable, returning default value", featureKey); sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue, EvaluationReason.ErrorKind.CLIENT_NOT_READY)); - return EvaluationDetail.error(EvaluationReason.ErrorKind.CLIENT_NOT_READY, defaultValue); + return errorResult(EvaluationReason.ErrorKind.CLIENT_NOT_READY, defaultValue); } } - FeatureFlag featureFlag = null; + FlagModel.FeatureFlag featureFlag = null; try { featureFlag = featureStore.get(FEATURES, featureKey); if (featureFlag == null) { logger.info("Unknown feature flag \"{}\"; returning default value", featureKey); sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue, EvaluationReason.ErrorKind.FLAG_NOT_FOUND)); - return EvaluationDetail.error(EvaluationReason.ErrorKind.FLAG_NOT_FOUND, defaultValue); + return errorResult(EvaluationReason.ErrorKind.FLAG_NOT_FOUND, defaultValue); } 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)); - return EvaluationDetail.error(EvaluationReason.ErrorKind.USER_NOT_SPECIFIED, defaultValue); + return errorResult(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); + Evaluator.EvalResult evalResult = evaluator.evaluate(featureFlag, user, eventFactory); for (Event.FeatureRequest event : evalResult.getPrerequisiteEvents()) { eventProcessor.sendEvent(event); } - EvaluationDetail details = evalResult.getDetails(); - if (details.isDefaultValue()) { - details = EvaluationDetail.fromValue(defaultValue, null, details.getReason()); + if (evalResult.isDefault()) { + evalResult.setValue(defaultValue); + } else { + LDValue value = evalResult.getValue(); + if (checkType && value != null && !value.isNull() && defaultValue.getType() != value.getType()) { + logger.error("Feature flag evaluation expected result as {}, but got {}", defaultValue.getType(), value.getType()); + sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue, + EvaluationReason.ErrorKind.WRONG_TYPE)); + return errorResult(EvaluationReason.ErrorKind.WRONG_TYPE, defaultValue); + } } - sendFlagRequestEvent(eventFactory.newFeatureRequestEvent(featureFlag, user, details, defaultValue)); - return details; + sendFlagRequestEvent(eventFactory.newFeatureRequestEvent(featureFlag, user, evalResult, defaultValue)); + return evalResult; } catch (Exception e) { logger.error("Encountered exception while evaluating feature flag \"{}\": {}", featureKey, e.toString()); logger.debug(e.toString(), e); @@ -375,7 +387,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 errorResult(EvaluationReason.ErrorKind.EXCEPTION, defaultValue); } } diff --git a/src/main/java/com/launchdarkly/client/Prerequisite.java b/src/main/java/com/launchdarkly/client/Prerequisite.java deleted file mode 100644 index 8df50ea56..000000000 --- a/src/main/java/com/launchdarkly/client/Prerequisite.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.launchdarkly.client; - -class Prerequisite { - private String key; - private int variation; - - // We need this so Gson doesn't complain in certain java environments that restrict unsafe allocation - Prerequisite() {} - - Prerequisite(String key, int variation) { - this.key = key; - this.variation = variation; - } - - String getKey() { - return key; - } - - int getVariation() { - return variation; - } -} diff --git a/src/main/java/com/launchdarkly/client/Rule.java b/src/main/java/com/launchdarkly/client/Rule.java deleted file mode 100644 index 799340791..000000000 --- a/src/main/java/com/launchdarkly/client/Rule.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.launchdarkly.client; - -import java.util.List; - -/** - * Expresses a set of AND-ed matching conditions for a user, along with either the fixed variation or percent rollout - * to serve if the conditions match. - * Invariant: one of the variation or rollout must be non-nil. - */ -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) { - 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)) { - return false; - } - } - return true; - } -} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/Segment.java b/src/main/java/com/launchdarkly/client/Segment.java deleted file mode 100644 index 2febf6b65..000000000 --- a/src/main/java/com/launchdarkly/client/Segment.java +++ /dev/null @@ -1,137 +0,0 @@ -package com.launchdarkly.client; - -import java.lang.reflect.Type; -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; -import java.util.Map; - -import com.google.gson.reflect.TypeToken; - -class Segment implements VersionedData { - private String key; - private List included; - private List excluded; - private String salt; - private List rules; - private int version; - private boolean deleted; - - // We need this so Gson doesn't complain in certain java environments that restrict unsafe allocation - Segment() {} - - private Segment(Builder builder) { - this.key = builder.key; - this.included = builder.included; - this.excluded = builder.excluded; - this.salt = builder.salt; - this.rules = builder.rules; - this.version = builder.version; - this.deleted = builder.deleted; - } - - public String getKey() { - return key; - } - - public Iterable getIncluded() { - return included; - } - - public Iterable getExcluded() { - return excluded; - } - - public String getSalt() { - return salt; - } - - public Iterable getRules() { - return rules; - } - - public int getVersion() { - return version; - } - - public boolean isDeleted() { - return deleted; - } - - public boolean matchesUser(LDUser user) { - String key = user.getKeyAsString(); - if (key == null) { - return false; - } - if (included.contains(key)) { - return true; - } - if (excluded.contains(key)) { - return false; - } - for (SegmentRule rule: rules) { - if (rule.matchUser(user, key, salt)) { - return true; - } - } - return false; - } - - public static class Builder { - private String key; - private List included = new ArrayList<>(); - private List excluded = new ArrayList<>(); - private String salt = ""; - private List rules = new ArrayList<>(); - private int version = 0; - private boolean deleted; - - public Builder(String key) { - this.key = key; - } - - public Builder(Segment from) { - this.key = from.key; - this.included = new ArrayList<>(from.included); - this.excluded = new ArrayList<>(from.excluded); - this.salt = from.salt; - this.rules = new ArrayList<>(from.rules); - this.version = from.version; - this.deleted = from.deleted; - } - - public Segment build() { - return new Segment(this); - } - - public Builder included(Collection included) { - this.included = new ArrayList<>(included); - return this; - } - - public Builder excluded(Collection excluded) { - this.excluded = new ArrayList<>(excluded); - return this; - } - - public Builder salt(String salt) { - this.salt = salt; - return this; - } - - public Builder rules(Collection rules) { - this.rules = new ArrayList<>(rules); - return this; - } - - public Builder version(int version) { - this.version = version; - return this; - } - - public Builder deleted(boolean deleted) { - this.deleted = deleted; - return this; - } - } -} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/SegmentRule.java b/src/main/java/com/launchdarkly/client/SegmentRule.java deleted file mode 100644 index 4498eb7d6..000000000 --- a/src/main/java/com/launchdarkly/client/SegmentRule.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.launchdarkly.client; - -import java.util.List; - -public class SegmentRule { - private final List clauses; - private final Integer weight; - private final String bucketBy; - - public SegmentRule(List clauses, Integer weight, String bucketBy) { - this.clauses = clauses; - this.weight = weight; - this.bucketBy = bucketBy; - } - - public boolean matchUser(LDUser user, String segmentKey, String salt) { - for (Clause c: clauses) { - if (!c.matchesUserNoSegments(user)) { - return false; - } - } - - // If the Weight is absent, this rule matches - if (weight == null) { - return true; - } - - // All of the clauses are met. See if the user buckets in - String by = (bucketBy == null) ? "key" : bucketBy; - double bucket = VariationOrRollout.bucketUser(user, segmentKey, by, salt); - double weight = (double)this.weight / 100000.0; - return bucket < weight; - } -} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/StreamProcessor.java b/src/main/java/com/launchdarkly/client/StreamProcessor.java index ce76e09ac..9172fef18 100644 --- a/src/main/java/com/launchdarkly/client/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/client/StreamProcessor.java @@ -121,9 +121,9 @@ public void onMessage(String name, MessageEvent event) throws Exception { 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)); + store.upsert(FEATURES, gson.fromJson(data.data, FlagModel.FeatureFlag.class)); } else if (SEGMENTS.getKeyFromStreamApiPath(data.path) != null) { - store.upsert(SEGMENTS, gson.fromJson(data.data, Segment.class)); + store.upsert(SEGMENTS, gson.fromJson(data.data, FlagModel.Segment.class)); } break; } @@ -158,12 +158,12 @@ public void onMessage(String name, MessageEvent event) throws Exception { try { String featureKey = FEATURES.getKeyFromStreamApiPath(path); if (featureKey != null) { - FeatureFlag feature = requestor.getFlag(featureKey); + FlagModel.FeatureFlag feature = requestor.getFlag(featureKey); store.upsert(FEATURES, feature); } else { String segmentKey = SEGMENTS.getKeyFromStreamApiPath(path); if (segmentKey != null) { - Segment segment = requestor.getSegment(segmentKey); + FlagModel.Segment segment = requestor.getSegment(segmentKey); store.upsert(SEGMENTS, segment); } } diff --git a/src/main/java/com/launchdarkly/client/Target.java b/src/main/java/com/launchdarkly/client/Target.java deleted file mode 100644 index 57e6f6598..000000000 --- a/src/main/java/com/launchdarkly/client/Target.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.launchdarkly.client; - -import java.util.List; - -class Target { - private List 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) { - this.values = values; - this.variation = variation; - } - - List getValues() { - return values; - } - - int getVariation() { - return variation; - } -} diff --git a/src/main/java/com/launchdarkly/client/TestFeatureStore.java b/src/main/java/com/launchdarkly/client/TestFeatureStore.java index daf3cf000..0a65b94e2 100644 --- a/src/main/java/com/launchdarkly/client/TestFeatureStore.java +++ b/src/main/java/com/launchdarkly/client/TestFeatureStore.java @@ -32,15 +32,8 @@ public class TestFeatureStore extends InMemoryFeatureStore { * @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) - .on(false) - .offVariation(value ? 0 : 1) - .variations(TRUE_FALSE_VARIATIONS) - .version(version.incrementAndGet()) - .build(); - upsert(FEATURES, newFeature); - return newFeature; + public FlagModel.FeatureFlag setBooleanValue(String key, Boolean value) { + return setJsonValue(key, value == null ? null : new JsonPrimitive(value.booleanValue())); } /** @@ -50,7 +43,7 @@ public FeatureFlag setBooleanValue(String key, Boolean value) { * @param key the key of the feature flag to evaluate to true * @return the feature flag */ - public FeatureFlag setFeatureTrue(String key) { + public FlagModel.FeatureFlag setFeatureTrue(String key) { return setBooleanValue(key, true); } @@ -61,7 +54,7 @@ public FeatureFlag setFeatureTrue(String key) { * @param key the key of the feature flag to evaluate to false * @return the feature flag */ - public FeatureFlag setFeatureFalse(String key) { + public FlagModel.FeatureFlag setFeatureFalse(String key) { return setBooleanValue(key, false); } @@ -71,7 +64,7 @@ public FeatureFlag setFeatureFalse(String key) { * @param value the new value of the flag * @return the feature flag */ - public FeatureFlag setIntegerValue(String key, Integer value) { + public FlagModel.FeatureFlag setIntegerValue(String key, Integer value) { return setJsonValue(key, new JsonPrimitive(value)); } @@ -81,7 +74,7 @@ public FeatureFlag setIntegerValue(String key, Integer value) { * @param value the new value of the flag * @return the feature flag */ - public FeatureFlag setDoubleValue(String key, Double value) { + public FlagModel.FeatureFlag setDoubleValue(String key, Double value) { return setJsonValue(key, new JsonPrimitive(value)); } @@ -91,7 +84,7 @@ public FeatureFlag setDoubleValue(String key, Double value) { * @param value the new value of the flag * @return the feature flag */ - public FeatureFlag setStringValue(String key, String value) { + public FlagModel.FeatureFlag setStringValue(String key, String value) { return setJsonValue(key, new JsonPrimitive(value)); } @@ -101,13 +94,22 @@ public FeatureFlag setStringValue(String key, String value) { * @param value the new value of the flag * @return the feature flag */ - public FeatureFlag setJsonValue(String key, JsonElement value) { - FeatureFlag newFeature = new FeatureFlagBuilder(key) - .on(false) - .offVariation(0) - .variations(Arrays.asList(LDValue.fromJsonElement(value))) - .version(version.incrementAndGet()) - .build(); + public FlagModel.FeatureFlag setJsonValue(String key, JsonElement value) { + FlagModel.FeatureFlag newFeature = new FlagModel.FeatureFlag(key, + version.incrementAndGet(), + false, + null, + null, + null, + null, + null, + 0, + Arrays.asList(LDValue.fromJsonElement(value)), + false, + false, + false, + null, + false); upsert(FEATURES, newFeature); return newFeature; } diff --git a/src/main/java/com/launchdarkly/client/VariationOrRollout.java b/src/main/java/com/launchdarkly/client/VariationOrRollout.java deleted file mode 100644 index c9213e915..000000000 --- a/src/main/java/com/launchdarkly/client/VariationOrRollout.java +++ /dev/null @@ -1,97 +0,0 @@ -package com.launchdarkly.client; - - -import com.launchdarkly.client.value.LDValue; - -import org.apache.commons.codec.digest.DigestUtils; - -import java.util.List; - -/** - * Contains either a fixed variation or percent rollout to serve. - * Invariant: one of the variation or rollout must be non-nil. - */ -class VariationOrRollout { - private static final float long_scale = (float) 0xFFFFFFFFFFFFFFFL; - - private Integer variation; - private Rollout rollout; - - // We need this so Gson doesn't complain in certain java environments that restrict unsafe allocation - VariationOrRollout() {} - - VariationOrRollout(Integer variation, Rollout rollout) { - this.variation = variation; - 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; - } else if (rollout != null) { - String bucketBy = rollout.bucketBy == null ? "key" : rollout.bucketBy; - float bucket = bucketUser(user, key, bucketBy, salt); - float sum = 0F; - for (WeightedVariation wv : rollout.variations) { - sum += (float) wv.weight / 100000F; - if (bucket < sum) { - return wv.variation; - } - } - } - return null; - } - - static float bucketUser(LDUser user, String key, String attr, String salt) { - LDValue userValue = user.getValueForEvaluation(attr); - String idHash = getBucketableStringValue(userValue); - if (idHash != null) { - 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); - return (float) longVal / long_scale; - } - return 0F; - } - - 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; - } - } - - static class Rollout { - private List variations; - private String bucketBy; - - // We need this so Gson doesn't complain in certain java environments that restrict unsafe allocation - Rollout() {} - - Rollout(List variations, String bucketBy) { - this.variations = variations; - this.bucketBy = bucketBy; - } - } - - static class WeightedVariation { - private int variation; - private int weight; - - // We need this so Gson doesn't complain in certain java environments that restrict unsafe allocation - WeightedVariation() {} - - WeightedVariation(int variation, int weight) { - this.variation = variation; - this.weight = weight; - } - } -} diff --git a/src/main/java/com/launchdarkly/client/VersionedDataKind.java b/src/main/java/com/launchdarkly/client/VersionedDataKind.java index 16cf1badc..e745818b4 100644 --- a/src/main/java/com/launchdarkly/client/VersionedDataKind.java +++ b/src/main/java/com/launchdarkly/client/VersionedDataKind.java @@ -128,9 +128,9 @@ public int getPriority() { /** * 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 static VersionedDataKind FEATURES = new Impl("features", FlagModel.FeatureFlag.class, "/flags/", 1) { + public FlagModel.FeatureFlag makeDeletedItem(String key, int version) { + return new FlagModel.FeatureFlag(key, version, false, null, null, null, null, null, null, null, false, false, false, null, true); } public boolean isDependencyOrdered() { @@ -138,12 +138,12 @@ public boolean isDependencyOrdered() { } public Iterable getDependencyKeys(VersionedData item) { - FeatureFlag flag = (FeatureFlag)item; + FlagModel.FeatureFlag flag = (FlagModel.FeatureFlag)item; if (flag.getPrerequisites() == null || flag.getPrerequisites().isEmpty()) { return ImmutableList.of(); } - return transform(flag.getPrerequisites(), new Function() { - public String apply(Prerequisite p) { + return transform(flag.getPrerequisites(), new Function() { + public String apply(FlagModel.Prerequisite p) { return p.getKey(); } }); @@ -153,10 +153,10 @@ public String apply(Prerequisite p) { /** * The {@link VersionedDataKind} instance that describes user segment data. */ - public static VersionedDataKind SEGMENTS = new Impl("segments", Segment.class, "/segments/", 0) { + public static VersionedDataKind SEGMENTS = new Impl("segments", FlagModel.Segment.class, "/segments/", 0) { - public Segment makeDeletedItem(String key, int version) { - return new Segment.Builder(key).deleted(true).version(version).build(); + public FlagModel.Segment makeDeletedItem(String key, int version) { + return new FlagModel.Segment(key, null, null, null, null, version, true); } }; diff --git a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java index ce05c62fe..a38850ed4 100644 --- a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java @@ -13,6 +13,7 @@ import java.util.Date; import java.util.concurrent.TimeUnit; +import static com.launchdarkly.client.ModelBuilders.flagBuilder; import static com.launchdarkly.client.TestHttpUtil.httpsServerWithSelfSignedCert; import static com.launchdarkly.client.TestHttpUtil.makeStartedServer; import static com.launchdarkly.client.TestUtil.hasJsonProperty; @@ -80,7 +81,7 @@ public void userIsFilteredInIdentifyEvent() throws Exception { @SuppressWarnings("unchecked") @Test public void individualFeatureEventIsQueuedWithIndexEvent() throws Exception { - FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true).build(); + FlagModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); @@ -100,7 +101,7 @@ public void individualFeatureEventIsQueuedWithIndexEvent() throws Exception { @SuppressWarnings("unchecked") @Test public void userIsFilteredInIndexEvent() throws Exception { - FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true).build(); + FlagModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); @@ -122,7 +123,7 @@ public void userIsFilteredInIndexEvent() throws Exception { @SuppressWarnings("unchecked") @Test public void featureEventCanContainInlineUser() throws Exception { - FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true).build(); + FlagModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); @@ -143,7 +144,7 @@ public void featureEventCanContainInlineUser() throws Exception { @SuppressWarnings("unchecked") @Test public void userIsFilteredInFeatureEvent() throws Exception { - FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true).build(); + FlagModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); @@ -164,10 +165,10 @@ public void userIsFilteredInFeatureEvent() throws Exception { @SuppressWarnings("unchecked") @Test public void featureEventCanContainReason() throws Exception { - FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true).build(); + FlagModel.FeatureFlag flag = flagBuilder("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()); + new Evaluator.EvalResult(LDValue.of("value"), 1, reason), LDValue.ofNull()); try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { @@ -185,7 +186,7 @@ public void featureEventCanContainReason() throws Exception { @SuppressWarnings("unchecked") @Test public void indexEventIsStillGeneratedIfInlineUsersIsTrueButFeatureEventIsNotTracked() throws Exception { - FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(false).build(); + FlagModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(false).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), null); @@ -207,7 +208,7 @@ public void indexEventIsStillGeneratedIfInlineUsersIsTrueButFeatureEventIsNotTra @Test public void eventKindIsDebugIfFlagIsTemporarilyInDebugMode() throws Exception { long futureTime = System.currentTimeMillis() + 1000000; - FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).debugEventsUntilDate(futureTime).build(); + FlagModel.FeatureFlag flag = flagBuilder("flagkey").version(11).debugEventsUntilDate(futureTime).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); @@ -228,7 +229,7 @@ public void eventKindIsDebugIfFlagIsTemporarilyInDebugMode() throws Exception { @Test public void eventCanBeBothTrackedAndDebugged() throws Exception { long futureTime = System.currentTimeMillis() + 1000000; - FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true) + FlagModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true) .debugEventsUntilDate(futureTime).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); @@ -256,7 +257,7 @@ public void debugModeExpiresBasedOnClientTimeIfClientTimeIsLaterThanServerTime() MockResponse resp2 = eventsSuccessResponse(); long debugUntil = serverTime + 1000; - FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).debugEventsUntilDate(debugUntil).build(); + FlagModel.FeatureFlag flag = flagBuilder("flagkey").version(11).debugEventsUntilDate(debugUntil).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); @@ -289,7 +290,7 @@ public void debugModeExpiresBasedOnServerTimeIfServerTimeIsLaterThanClientTime() MockResponse resp2 = eventsSuccessResponse(); long debugUntil = serverTime - 1000; - FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).debugEventsUntilDate(debugUntil).build(); + FlagModel.FeatureFlag flag = flagBuilder("flagkey").version(11).debugEventsUntilDate(debugUntil).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); @@ -318,8 +319,8 @@ public void debugModeExpiresBasedOnServerTimeIfServerTimeIsLaterThanClientTime() @SuppressWarnings("unchecked") @Test 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(); + FlagModel.FeatureFlag flag1 = flagBuilder("flagkey1").version(11).trackEvents(true).build(); + FlagModel.FeatureFlag flag2 = flagBuilder("flagkey2").version(22).trackEvents(true).build(); LDValue value = LDValue.of("value"); Event.FeatureRequest fe1 = EventFactory.DEFAULT.newFeatureRequestEvent(flag1, user, simpleEvaluation(1, value), LDValue.ofNull()); @@ -344,8 +345,8 @@ public void twoFeatureEventsForSameUserGenerateOnlyOneIndexEvent() throws Except @SuppressWarnings("unchecked") @Test public void nonTrackedEventsAreSummarized() throws Exception { - FeatureFlag flag1 = new FeatureFlagBuilder("flagkey1").version(11).build(); - FeatureFlag flag2 = new FeatureFlagBuilder("flagkey2").version(22).build(); + FlagModel.FeatureFlag flag1 = flagBuilder("flagkey1").version(11).build(); + FlagModel.FeatureFlag flag2 = flagBuilder("flagkey2").version(22).build(); LDValue value1 = LDValue.of("value1"); LDValue value2 = LDValue.of("value2"); LDValue default1 = LDValue.of("default1"); @@ -629,12 +630,12 @@ private Matcher isIndexEvent(Event sourceEvent, JsonElement user) { ); } - private Matcher isFeatureEvent(Event.FeatureRequest sourceEvent, FeatureFlag flag, boolean debug, JsonElement inlineUser) { + private Matcher isFeatureEvent(Event.FeatureRequest sourceEvent, FlagModel.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, + private Matcher isFeatureEvent(Event.FeatureRequest sourceEvent, FlagModel.FeatureFlag flag, boolean debug, JsonElement inlineUser, EvaluationReason reason) { return allOf( hasJsonProperty("kind", debug ? "debug" : "feature"), @@ -688,7 +689,7 @@ private Matcher hasSummaryFlag(String key, LDValue defaultVal, Matc ))); } - private Matcher isSummaryEventCounter(FeatureFlag flag, Integer variation, LDValue value, int count) { + private Matcher isSummaryEventCounter(FlagModel.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/VariationOrRolloutTest.java b/src/test/java/com/launchdarkly/client/EvaluatorBucketingTest.java similarity index 73% rename from src/test/java/com/launchdarkly/client/VariationOrRolloutTest.java rename to src/test/java/com/launchdarkly/client/EvaluatorBucketingTest.java index 67d9d07f4..39a50156e 100644 --- a/src/test/java/com/launchdarkly/client/VariationOrRolloutTest.java +++ b/src/test/java/com/launchdarkly/client/EvaluatorBucketingTest.java @@ -4,15 +4,16 @@ import org.junit.Test; -public class VariationOrRolloutTest { +@SuppressWarnings("javadoc") +public class EvaluatorBucketingTest { @Test public void canBucketByIntAttributeSameAsString() { LDUser user = new LDUser.Builder("key") .custom("stringattr", "33333") .custom("intattr", 33333) .build(); - float resultForString = VariationOrRollout.bucketUser(user, "key", "stringattr", "salt"); - float resultForInt = VariationOrRollout.bucketUser(user, "key", "intattr", "salt"); + float resultForString = EvaluatorBucketing.bucketUser(user, "key", "stringattr", "salt"); + float resultForInt = EvaluatorBucketing.bucketUser(user, "key", "intattr", "salt"); assertEquals(resultForString, resultForInt, Float.MIN_VALUE); } @@ -21,7 +22,7 @@ public void cannotBucketByFloatAttribute() { LDUser user = new LDUser.Builder("key") .custom("floatattr", 33.5f) .build(); - float result = VariationOrRollout.bucketUser(user, "key", "floatattr", "salt"); + float result = EvaluatorBucketing.bucketUser(user, "key", "floatattr", "salt"); assertEquals(0f, result, Float.MIN_VALUE); } @@ -30,7 +31,7 @@ public void cannotBucketByBooleanAttribute() { LDUser user = new LDUser.Builder("key") .custom("boolattr", true) .build(); - float result = VariationOrRollout.bucketUser(user, "key", "boolattr", "salt"); + float result = EvaluatorBucketing.bucketUser(user, "key", "boolattr", "salt"); assertEquals(0f, result, Float.MIN_VALUE); } } diff --git a/src/test/java/com/launchdarkly/client/EvaluatorClauseTest.java b/src/test/java/com/launchdarkly/client/EvaluatorClauseTest.java new file mode 100644 index 000000000..1c08f0ed0 --- /dev/null +++ b/src/test/java/com/launchdarkly/client/EvaluatorClauseTest.java @@ -0,0 +1,131 @@ +package com.launchdarkly.client; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.launchdarkly.client.value.LDValue; + +import org.junit.Test; + +import java.util.Arrays; + +import static com.launchdarkly.client.EvaluationDetail.fromValue; +import static com.launchdarkly.client.EvaluatorTestUtil.BASE_EVALUATOR; +import static com.launchdarkly.client.EvaluatorTestUtil.evaluatorBuilder; +import static com.launchdarkly.client.ModelBuilders.booleanFlagWithClauses; +import static com.launchdarkly.client.ModelBuilders.fallthroughVariation; +import static com.launchdarkly.client.ModelBuilders.flagBuilder; +import static com.launchdarkly.client.ModelBuilders.segmentBuilder; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +@SuppressWarnings("javadoc") +public class EvaluatorClauseTest { + @Test + public void clauseCanMatchBuiltInAttribute() throws Exception { + FlagModel.Clause clause = new FlagModel.Clause("name", Operator.in, Arrays.asList(LDValue.of("Bob")), false); + FlagModel.FeatureFlag f = booleanFlagWithClauses("flag", clause); + LDUser user = new LDUser.Builder("key").name("Bob").build(); + + assertEquals(LDValue.of(true), BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT).getDetails().getValue()); + } + + @Test + public void clauseCanMatchCustomAttribute() throws Exception { + FlagModel.Clause clause = new FlagModel.Clause("legs", Operator.in, Arrays.asList(LDValue.of(4)), false); + FlagModel.FeatureFlag f = booleanFlagWithClauses("flag", clause); + LDUser user = new LDUser.Builder("key").custom("legs", 4).build(); + + assertEquals(LDValue.of(true), BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT).getDetails().getValue()); + } + + @Test + public void clauseReturnsFalseForMissingAttribute() throws Exception { + FlagModel.Clause clause = new FlagModel.Clause("legs", Operator.in, Arrays.asList(LDValue.of(4)), false); + FlagModel.FeatureFlag f = booleanFlagWithClauses("flag", clause); + LDUser user = new LDUser.Builder("key").name("Bob").build(); + + assertEquals(LDValue.of(false), BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT).getDetails().getValue()); + } + + @Test + public void clauseCanBeNegated() throws Exception { + FlagModel.Clause clause = new FlagModel.Clause("name", Operator.in, Arrays.asList(LDValue.of("Bob")), true); + FlagModel.FeatureFlag f = booleanFlagWithClauses("flag", clause); + LDUser user = new LDUser.Builder("key").name("Bob").build(); + + assertEquals(LDValue.of(false), BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT).getDetails().getValue()); + } + + @Test + public void clauseWithUnsupportedOperatorStringIsUnmarshalledWithNullOperator() throws Exception { + // This just verifies that GSON will give us a null in this case instead of throwing an exception, + // so we fail as gracefully as possible if a new operator type has been added in the application + // and the SDK hasn't been upgraded yet. + String badClauseJson = "{\"attribute\":\"name\",\"operator\":\"doesSomethingUnsupported\",\"values\":[\"x\"]}"; + Gson gson = new Gson(); + FlagModel.Clause clause = gson.fromJson(badClauseJson, FlagModel.Clause.class); + assertNotNull(clause); + + JsonElement json = gson.toJsonTree(clause); + String expectedJson = "{\"attribute\":\"name\",\"values\":[\"x\"],\"negate\":false}"; + assertEquals(gson.fromJson(expectedJson, JsonElement.class), json); + } + + @Test + public void clauseWithNullOperatorDoesNotMatch() throws Exception { + FlagModel.Clause badClause = new FlagModel.Clause("name", null, Arrays.asList(LDValue.of("Bob")), false); + FlagModel.FeatureFlag f = booleanFlagWithClauses("flag", badClause); + LDUser user = new LDUser.Builder("key").name("Bob").build(); + + assertEquals(LDValue.of(false), BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT).getDetails().getValue()); + } + + @Test + public void clauseWithNullOperatorDoesNotStopSubsequentRuleFromMatching() throws Exception { + FlagModel.Clause badClause = new FlagModel.Clause("name", null, Arrays.asList(LDValue.of("Bob")), false); + FlagModel.Rule badRule = new FlagModel.Rule("rule1", Arrays.asList(badClause), 1, null); + FlagModel.Clause goodClause = new FlagModel.Clause("name", Operator.in, Arrays.asList(LDValue.of("Bob")), false); + FlagModel.Rule goodRule = new FlagModel.Rule("rule2", Arrays.asList(goodClause), 1, null); + FlagModel.FeatureFlag f = flagBuilder("feature") + .on(true) + .rules(badRule, goodRule) + .fallthrough(fallthroughVariation(0)) + .offVariation(0) + .variations(LDValue.of(false), LDValue.of(true)) + .build(); + LDUser user = new LDUser.Builder("key").name("Bob").build(); + + EvaluationDetail details = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT).getDetails(); + assertEquals(fromValue(LDValue.of(true), 1, EvaluationReason.ruleMatch(1, "rule2")), details); + } + + @Test + public void testSegmentMatchClauseRetrievesSegmentFromStore() throws Exception { + FlagModel.Segment segment = segmentBuilder("segkey") + .included("foo") + .version(1) + .build(); + Evaluator e = evaluatorBuilder().withStoredSegments(segment).build(); + + FlagModel.FeatureFlag flag = segmentMatchBooleanFlag("segkey"); + LDUser user = new LDUser.Builder("foo").build(); + + Evaluator.EvalResult result = e.evaluate(flag, user, EventFactory.DEFAULT); + assertEquals(LDValue.of(true), result.getDetails().getValue()); + } + + @Test + public void testSegmentMatchClauseFallsThroughIfSegmentNotFound() throws Exception { + FlagModel.FeatureFlag flag = segmentMatchBooleanFlag("segkey"); + LDUser user = new LDUser.Builder("foo").build(); + + Evaluator e = evaluatorBuilder().withNonexistentSegment("segkey").build(); + Evaluator.EvalResult result = e.evaluate(flag, user, EventFactory.DEFAULT); + assertEquals(LDValue.of(false), result.getDetails().getValue()); + } + + private FlagModel.FeatureFlag segmentMatchBooleanFlag(String segmentKey) { + FlagModel.Clause clause = new FlagModel.Clause("", Operator.segmentMatch, Arrays.asList(LDValue.of(segmentKey)), false); + return booleanFlagWithClauses("flag", clause); + } +} diff --git a/src/test/java/com/launchdarkly/client/EvaluatorRuleTest.java b/src/test/java/com/launchdarkly/client/EvaluatorRuleTest.java new file mode 100644 index 000000000..f3c8a49f7 --- /dev/null +++ b/src/test/java/com/launchdarkly/client/EvaluatorRuleTest.java @@ -0,0 +1,77 @@ +package com.launchdarkly.client; + +import com.google.common.collect.ImmutableList; +import com.launchdarkly.client.value.LDValue; + +import org.junit.Test; + +import java.util.Arrays; + +import static com.launchdarkly.client.EvaluatorTestUtil.BASE_EVALUATOR; +import static com.launchdarkly.client.ModelBuilders.fallthroughVariation; +import static com.launchdarkly.client.ModelBuilders.flagBuilder; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.emptyIterable; +import static org.junit.Assert.assertEquals; + +@SuppressWarnings("javadoc") +public class EvaluatorRuleTest { + @Test + public void ruleWithTooHighVariationReturnsMalformedFlagError() { + FlagModel.Clause clause = new FlagModel.Clause("key", Operator.in, Arrays.asList(LDValue.of("userkey")), false); + FlagModel.Rule rule = new FlagModel.Rule("ruleid", Arrays.asList(clause), 999, null); + FlagModel.FeatureFlag f = featureFlagWithRules("feature", rule); + LDUser user = new LDUser.Builder("userkey").build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + + @Test + public void ruleWithNegativeVariationReturnsMalformedFlagError() { + FlagModel.Clause clause = new FlagModel.Clause("key", Operator.in, Arrays.asList(LDValue.of("userkey")), false); + FlagModel.Rule rule = new FlagModel.Rule("ruleid", Arrays.asList(clause), -1, null); + FlagModel.FeatureFlag f = featureFlagWithRules("feature", rule); + LDUser user = new LDUser.Builder("userkey").build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + + @Test + public void ruleWithNoVariationOrRolloutReturnsMalformedFlagError() { + FlagModel.Clause clause = new FlagModel.Clause("key", Operator.in, Arrays.asList(LDValue.of("userkey")), false); + FlagModel.Rule rule = new FlagModel.Rule("ruleid", Arrays.asList(clause), null, null); + FlagModel.FeatureFlag f = featureFlagWithRules("feature", rule); + LDUser user = new LDUser.Builder("userkey").build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + + @Test + public void ruleWithRolloutWithEmptyVariationsListReturnsMalformedFlagError() { + FlagModel.Clause clause = new FlagModel.Clause("key", Operator.in, Arrays.asList(LDValue.of("userkey")), false); + FlagModel.Rule rule = new FlagModel.Rule("ruleid", Arrays.asList(clause), null, + new FlagModel.Rollout(ImmutableList.of(), null)); + FlagModel.FeatureFlag f = featureFlagWithRules("feature", rule); + LDUser user = new LDUser.Builder("userkey").build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + + private FlagModel.FeatureFlag featureFlagWithRules(String flagKey, FlagModel.Rule... rules) { + return flagBuilder(flagKey) + .on(true) + .rules(rules) + .fallthrough(fallthroughVariation(0)) + .offVariation(1) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .build(); + } +} diff --git a/src/test/java/com/launchdarkly/client/SegmentTest.java b/src/test/java/com/launchdarkly/client/EvaluatorSegmentMatchTest.java similarity index 53% rename from src/test/java/com/launchdarkly/client/SegmentTest.java rename to src/test/java/com/launchdarkly/client/EvaluatorSegmentMatchTest.java index ccd9d5225..14098aefe 100644 --- a/src/test/java/com/launchdarkly/client/SegmentTest.java +++ b/src/test/java/com/launchdarkly/client/EvaluatorSegmentMatchTest.java @@ -6,137 +6,147 @@ import java.util.Arrays; +import static com.launchdarkly.client.EvaluatorTestUtil.evaluatorBuilder; +import static com.launchdarkly.client.ModelBuilders.booleanFlagWithClauses; +import static com.launchdarkly.client.ModelBuilders.segmentBuilder; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @SuppressWarnings("javadoc") -public class SegmentTest { +public class EvaluatorSegmentMatchTest { private int maxWeight = 100000; @Test public void explicitIncludeUser() { - Segment s = new Segment.Builder("test") - .included(Arrays.asList("foo")) + FlagModel.Segment s = segmentBuilder("test") + .included("foo") .salt("abcdef") .version(1) .build(); LDUser u = new LDUser.Builder("foo").build(); - assertTrue(s.matchesUser(u)); + assertTrue(segmentMatchesUser(s, u)); } @Test public void explicitExcludeUser() { - Segment s = new Segment.Builder("test") - .excluded(Arrays.asList("foo")) + FlagModel.Segment s = segmentBuilder("test") + .excluded("foo") .salt("abcdef") .version(1) .build(); LDUser u = new LDUser.Builder("foo").build(); - assertFalse(s.matchesUser(u)); + assertFalse(segmentMatchesUser(s, u)); } @Test public void explicitIncludeHasPrecedence() { - Segment s = new Segment.Builder("test") - .included(Arrays.asList("foo")) - .excluded(Arrays.asList("foo")) + FlagModel.Segment s = segmentBuilder("test") + .included("foo") + .excluded("foo") .salt("abcdef") .version(1) .build(); LDUser u = new LDUser.Builder("foo").build(); - assertTrue(s.matchesUser(u)); + assertTrue(segmentMatchesUser(s, u)); } @Test public void matchingRuleWithFullRollout() { - Clause clause = new Clause( + FlagModel.Clause clause = new FlagModel.Clause( "email", Operator.in, Arrays.asList(LDValue.of("test@example.com")), false); - SegmentRule rule = new SegmentRule( + FlagModel.SegmentRule rule = new FlagModel.SegmentRule( Arrays.asList(clause), maxWeight, null); - Segment s = new Segment.Builder("test") + FlagModel.Segment s = segmentBuilder("test") .salt("abcdef") - .rules(Arrays.asList(rule)) + .rules(rule) .build(); LDUser u = new LDUser.Builder("foo").email("test@example.com").build(); - assertTrue(s.matchesUser(u)); + assertTrue(segmentMatchesUser(s, u)); } @Test public void matchingRuleWithZeroRollout() { - Clause clause = new Clause( + FlagModel.Clause clause = new FlagModel.Clause( "email", Operator.in, Arrays.asList(LDValue.of("test@example.com")), false); - SegmentRule rule = new SegmentRule(Arrays.asList(clause), + FlagModel.SegmentRule rule = new FlagModel.SegmentRule(Arrays.asList(clause), 0, null); - Segment s = new Segment.Builder("test") + FlagModel.Segment s = segmentBuilder("test") .salt("abcdef") - .rules(Arrays.asList(rule)) + .rules(rule) .build(); LDUser u = new LDUser.Builder("foo").email("test@example.com").build(); - assertFalse(s.matchesUser(u)); + assertFalse(segmentMatchesUser(s, u)); } @Test public void matchingRuleWithMultipleClauses() { - Clause clause1 = new Clause( + FlagModel.Clause clause1 = new FlagModel.Clause( "email", Operator.in, Arrays.asList(LDValue.of("test@example.com")), false); - Clause clause2 = new Clause( + FlagModel.Clause clause2 = new FlagModel.Clause( "name", Operator.in, Arrays.asList(LDValue.of("bob")), false); - SegmentRule rule = new SegmentRule( + FlagModel.SegmentRule rule = new FlagModel.SegmentRule( Arrays.asList(clause1, clause2), null, null); - Segment s = new Segment.Builder("test") + FlagModel.Segment s = segmentBuilder("test") .salt("abcdef") - .rules(Arrays.asList(rule)) + .rules(rule) .build(); LDUser u = new LDUser.Builder("foo").email("test@example.com").name("bob").build(); - assertTrue(s.matchesUser(u)); + assertTrue(segmentMatchesUser(s, u)); } @Test public void nonMatchingRuleWithMultipleClauses() { - Clause clause1 = new Clause( + FlagModel.Clause clause1 = new FlagModel.Clause( "email", Operator.in, Arrays.asList(LDValue.of("test@example.com")), false); - Clause clause2 = new Clause( + FlagModel.Clause clause2 = new FlagModel.Clause( "name", Operator.in, Arrays.asList(LDValue.of("bill")), false); - SegmentRule rule = new SegmentRule( + FlagModel.SegmentRule rule = new FlagModel.SegmentRule( Arrays.asList(clause1, clause2), null, null); - Segment s = new Segment.Builder("test") + FlagModel.Segment s = segmentBuilder("test") .salt("abcdef") - .rules(Arrays.asList(rule)) + .rules(rule) .build(); LDUser u = new LDUser.Builder("foo").email("test@example.com").name("bob").build(); - assertFalse(s.matchesUser(u)); + assertFalse(segmentMatchesUser(s, u)); + } + + private static boolean segmentMatchesUser(FlagModel.Segment segment, LDUser user) { + FlagModel.Clause clause = new FlagModel.Clause("", Operator.segmentMatch, Arrays.asList(LDValue.of(segment.getKey())), false); + FlagModel.FeatureFlag flag = booleanFlagWithClauses("flag", clause); + Evaluator e = evaluatorBuilder().withStoredSegments(segment).build(); + return e.evaluate(flag, user, EventFactory.DEFAULT).getValue().booleanValue(); } } \ No newline at end of file diff --git a/src/test/java/com/launchdarkly/client/EvaluatorTest.java b/src/test/java/com/launchdarkly/client/EvaluatorTest.java new file mode 100644 index 000000000..86dc0f95b --- /dev/null +++ b/src/test/java/com/launchdarkly/client/EvaluatorTest.java @@ -0,0 +1,342 @@ +package com.launchdarkly.client; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Iterables; +import com.launchdarkly.client.value.LDValue; + +import org.junit.Test; + +import java.util.Arrays; + +import static com.launchdarkly.client.EvaluationDetail.fromValue; +import static com.launchdarkly.client.EvaluatorTestUtil.BASE_EVALUATOR; +import static com.launchdarkly.client.EvaluatorTestUtil.evaluatorBuilder; +import static com.launchdarkly.client.ModelBuilders.fallthroughVariation; +import static com.launchdarkly.client.ModelBuilders.flagBuilder; +import static com.launchdarkly.client.ModelBuilders.prerequisite; +import static com.launchdarkly.client.ModelBuilders.target; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.emptyIterable; +import static org.junit.Assert.assertEquals; + +@SuppressWarnings("javadoc") +public class EvaluatorTest { + + private static LDUser BASE_USER = new LDUser.Builder("x").build(); + + @Test + public void flagReturnsOffVariationIfFlagIsOff() throws Exception { + FlagModel.FeatureFlag f = flagBuilder("feature") + .on(false) + .offVariation(1) + .fallthrough(fallthroughVariation(0)) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + + assertEquals(fromValue(LDValue.of("off"), 1, EvaluationReason.off()), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + + @Test + public void flagReturnsNullIfFlagIsOffAndOffVariationIsUnspecified() throws Exception { + FlagModel.FeatureFlag f = flagBuilder("feature") + .on(false) + .fallthrough(fallthroughVariation(0)) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + + assertEquals(fromValue(LDValue.ofNull(), null, EvaluationReason.off()), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + + @Test + public void flagReturnsErrorIfFlagIsOffAndOffVariationIsTooHigh() throws Exception { + FlagModel.FeatureFlag f = flagBuilder("feature") + .on(false) + .offVariation(999) + .fallthrough(fallthroughVariation(0)) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + + @Test + public void flagReturnsErrorIfFlagIsOffAndOffVariationIsNegative() throws Exception { + FlagModel.FeatureFlag f = flagBuilder("feature") + .on(false) + .offVariation(-1) + .fallthrough(fallthroughVariation(0)) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + + @Test + public void flagReturnsFallthroughIfFlagIsOnAndThereAreNoRules() throws Exception { + FlagModel.FeatureFlag f = flagBuilder("feature") + .on(true) + .offVariation(1) + .fallthrough(fallthroughVariation(0)) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + + assertEquals(fromValue(LDValue.of("fall"), 0, EvaluationReason.fallthrough()), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + + @Test + public void flagReturnsErrorIfFallthroughHasTooHighVariation() throws Exception { + FlagModel.FeatureFlag f = flagBuilder("feature") + .on(true) + .offVariation(1) + .fallthrough(fallthroughVariation(999)) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + + @Test + public void flagReturnsErrorIfFallthroughHasNegativeVariation() throws Exception { + FlagModel.FeatureFlag f = flagBuilder("feature") + .on(true) + .offVariation(1) + .fallthrough(fallthroughVariation(-1)) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + + @Test + public void flagReturnsErrorIfFallthroughHasNeitherVariationNorRollout() throws Exception { + FlagModel.FeatureFlag f = flagBuilder("feature") + .on(true) + .offVariation(1) + .fallthrough(new FlagModel.VariationOrRollout(null, null)) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + + @Test + public void flagReturnsErrorIfFallthroughHasEmptyRolloutVariationList() throws Exception { + FlagModel.FeatureFlag f = flagBuilder("feature") + .on(true) + .offVariation(1) + .fallthrough(new FlagModel.VariationOrRollout(null, + new FlagModel.Rollout(ImmutableList.of(), null))) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + + @Test + public void flagReturnsOffVariationIfPrerequisiteIsNotFound() throws Exception { + FlagModel.FeatureFlag f0 = flagBuilder("feature0") + .on(true) + .prerequisites(prerequisite("feature1", 1)) + .fallthrough(fallthroughVariation(0)) + .offVariation(1) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .build(); + Evaluator e = evaluatorBuilder().withNonexistentFlag("feature1").build(); + Evaluator.EvalResult result = e.evaluate(f0, BASE_USER, EventFactory.DEFAULT); + + EvaluationReason expectedReason = EvaluationReason.prerequisiteFailed("feature1"); + assertEquals(fromValue(LDValue.of("off"), 1, expectedReason), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + + @Test + public void flagReturnsOffVariationAndEventIfPrerequisiteIsOff() throws Exception { + FlagModel.FeatureFlag f0 = flagBuilder("feature0") + .on(true) + .prerequisites(prerequisite("feature1", 1)) + .fallthrough(fallthroughVariation(0)) + .offVariation(1) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .version(1) + .build(); + FlagModel.FeatureFlag f1 = flagBuilder("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(LDValue.of("nogo"), LDValue.of("go")) + .version(2) + .build(); + Evaluator e = evaluatorBuilder().withStoredFlags(f1).build(); + Evaluator.EvalResult result = e.evaluate(f0, BASE_USER, EventFactory.DEFAULT); + + EvaluationReason expectedReason = EvaluationReason.prerequisiteFailed("feature1"); + assertEquals(fromValue(LDValue.of("off"), 1, expectedReason), result.getDetails()); + + assertEquals(1, Iterables.size(result.getPrerequisiteEvents())); + Event.FeatureRequest event = Iterables.get(result.getPrerequisiteEvents(), 0); + assertEquals(f1.getKey(), event.key); + assertEquals(LDValue.of("go"), event.value); + assertEquals(f1.getVersion(), event.version.intValue()); + assertEquals(f0.getKey(), event.prereqOf); + } + + @Test + public void flagReturnsOffVariationAndEventIfPrerequisiteIsNotMet() throws Exception { + FlagModel.FeatureFlag f0 = flagBuilder("feature0") + .on(true) + .prerequisites(prerequisite("feature1", 1)) + .fallthrough(fallthroughVariation(0)) + .offVariation(1) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .version(1) + .build(); + FlagModel.FeatureFlag f1 = flagBuilder("feature1") + .on(true) + .fallthrough(fallthroughVariation(0)) + .variations(LDValue.of("nogo"), LDValue.of("go")) + .version(2) + .build(); + Evaluator e = evaluatorBuilder().withStoredFlags(f1).build(); + Evaluator.EvalResult result = e.evaluate(f0, BASE_USER, EventFactory.DEFAULT); + + EvaluationReason expectedReason = EvaluationReason.prerequisiteFailed("feature1"); + assertEquals(fromValue(LDValue.of("off"), 1, expectedReason), result.getDetails()); + + assertEquals(1, Iterables.size(result.getPrerequisiteEvents())); + Event.FeatureRequest event = Iterables.get(result.getPrerequisiteEvents(), 0); + assertEquals(f1.getKey(), event.key); + assertEquals(LDValue.of("nogo"), event.value); + assertEquals(f1.getVersion(), event.version.intValue()); + assertEquals(f0.getKey(), event.prereqOf); + } + + @Test + public void flagReturnsFallthroughVariationAndEventIfPrerequisiteIsMetAndThereAreNoRules() throws Exception { + FlagModel.FeatureFlag f0 = flagBuilder("feature0") + .on(true) + .prerequisites(prerequisite("feature1", 1)) + .fallthrough(fallthroughVariation(0)) + .offVariation(1) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .version(1) + .build(); + FlagModel.FeatureFlag f1 = flagBuilder("feature1") + .on(true) + .fallthrough(fallthroughVariation(1)) + .variations(LDValue.of("nogo"), LDValue.of("go")) + .version(2) + .build(); + Evaluator e = evaluatorBuilder().withStoredFlags(f1).build(); + Evaluator.EvalResult result = e.evaluate(f0, BASE_USER, EventFactory.DEFAULT); + + assertEquals(fromValue(LDValue.of("fall"), 0, EvaluationReason.fallthrough()), result.getDetails()); + + assertEquals(1, Iterables.size(result.getPrerequisiteEvents())); + Event.FeatureRequest event = Iterables.get(result.getPrerequisiteEvents(), 0); + assertEquals(f1.getKey(), event.key); + assertEquals(LDValue.of("go"), event.value); + assertEquals(f1.getVersion(), event.version.intValue()); + assertEquals(f0.getKey(), event.prereqOf); + } + + @Test + public void multipleLevelsOfPrerequisitesProduceMultipleEvents() throws Exception { + FlagModel.FeatureFlag f0 = flagBuilder("feature0") + .on(true) + .prerequisites(prerequisite("feature1", 1)) + .fallthrough(fallthroughVariation(0)) + .offVariation(1) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .version(1) + .build(); + FlagModel.FeatureFlag f1 = flagBuilder("feature1") + .on(true) + .prerequisites(prerequisite("feature2", 1)) + .fallthrough(fallthroughVariation(1)) + .variations(LDValue.of("nogo"), LDValue.of("go")) + .version(2) + .build(); + FlagModel.FeatureFlag f2 = flagBuilder("feature2") + .on(true) + .fallthrough(fallthroughVariation(1)) + .variations(LDValue.of("nogo"), LDValue.of("go")) + .version(3) + .build(); + Evaluator e = evaluatorBuilder().withStoredFlags(f1, f2).build(); + Evaluator.EvalResult result = e.evaluate(f0, BASE_USER, EventFactory.DEFAULT); + + assertEquals(fromValue(LDValue.of("fall"), 0, EvaluationReason.fallthrough()), result.getDetails()); + assertEquals(2, Iterables.size(result.getPrerequisiteEvents())); + + Event.FeatureRequest event0 = Iterables.get(result.getPrerequisiteEvents(), 0); + assertEquals(f2.getKey(), event0.key); + assertEquals(LDValue.of("go"), event0.value); + assertEquals(f2.getVersion(), event0.version.intValue()); + assertEquals(f1.getKey(), event0.prereqOf); + + Event.FeatureRequest event1 = Iterables.get(result.getPrerequisiteEvents(), 1); + assertEquals(f1.getKey(), event1.key); + assertEquals(LDValue.of("go"), event1.value); + assertEquals(f1.getVersion(), event1.version.intValue()); + assertEquals(f0.getKey(), event1.prereqOf); + } + + @Test + public void flagMatchesUserFromTargets() throws Exception { + FlagModel.FeatureFlag f = flagBuilder("feature") + .on(true) + .targets(target(2, "whoever", "userkey")) + .fallthrough(fallthroughVariation(0)) + .offVariation(1) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .build(); + LDUser user = new LDUser.Builder("userkey").build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); + + assertEquals(fromValue(LDValue.of("on"), 2, EvaluationReason.targetMatch()), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + + @Test + public void flagMatchesUserFromRules() { + FlagModel.Clause clause0 = new FlagModel.Clause("key", Operator.in, Arrays.asList(LDValue.of("wrongkey")), false); + FlagModel.Clause clause1 = new FlagModel.Clause("key", Operator.in, Arrays.asList(LDValue.of("userkey")), false); + FlagModel.Rule rule0 = new FlagModel.Rule("ruleid0", Arrays.asList(clause0), 2, null); + FlagModel.Rule rule1 = new FlagModel.Rule("ruleid1", Arrays.asList(clause1), 2, null); + FlagModel.FeatureFlag f = featureFlagWithRules("feature", rule0, rule1); + LDUser user = new LDUser.Builder("userkey").build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); + + assertEquals(fromValue(LDValue.of("on"), 2, EvaluationReason.ruleMatch(1, "ruleid1")), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + + private FlagModel.FeatureFlag featureFlagWithRules(String flagKey, FlagModel.Rule... rules) { + return flagBuilder(flagKey) + .on(true) + .rules(rules) + .fallthrough(fallthroughVariation(0)) + .offVariation(1) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .build(); + } +} diff --git a/src/test/java/com/launchdarkly/client/EvaluatorTestUtil.java b/src/test/java/com/launchdarkly/client/EvaluatorTestUtil.java new file mode 100644 index 000000000..993a76c35 --- /dev/null +++ b/src/test/java/com/launchdarkly/client/EvaluatorTestUtil.java @@ -0,0 +1,102 @@ +package com.launchdarkly.client; + +@SuppressWarnings("javadoc") +public abstract class EvaluatorTestUtil { + public static Evaluator BASE_EVALUATOR = evaluatorBuilder().build(); + + public static EvaluatorBuilder evaluatorBuilder() { + return new EvaluatorBuilder(); + } + + public static class EvaluatorBuilder { + private Evaluator.Getters getters; + + EvaluatorBuilder() { + getters = new Evaluator.Getters() { + public FlagModel.FeatureFlag getFlag(String key) { + throw new IllegalStateException("Evaluator unexpectedly tried to query flag: " + key); + } + + public FlagModel.Segment getSegment(String key) { + throw new IllegalStateException("Evaluator unexpectedly tried to query segment: " + key); + } + }; + } + + public Evaluator build() { + return new Evaluator(getters); + } + + public EvaluatorBuilder withStoredFlags(final FlagModel.FeatureFlag... flags) { + final Evaluator.Getters baseGetters = getters; + getters = new Evaluator.Getters() { + public FlagModel.FeatureFlag getFlag(String key) { + for (FlagModel.FeatureFlag f: flags) { + if (f.getKey().equals(key)) { + return f; + } + } + return baseGetters.getFlag(key); + } + + public FlagModel.Segment getSegment(String key) { + return baseGetters.getSegment(key); + } + }; + return this; + } + + public EvaluatorBuilder withNonexistentFlag(final String nonexistentFlagKey) { + final Evaluator.Getters baseGetters = getters; + getters = new Evaluator.Getters() { + public FlagModel.FeatureFlag getFlag(String key) { + if (key.equals(nonexistentFlagKey)) { + return null; + } + return baseGetters.getFlag(key); + } + + public FlagModel.Segment getSegment(String key) { + return baseGetters.getSegment(key); + } + }; + return this; + } + + public EvaluatorBuilder withStoredSegments(final FlagModel.Segment... segments) { + final Evaluator.Getters baseGetters = getters; + getters = new Evaluator.Getters() { + public FlagModel.FeatureFlag getFlag(String key) { + return baseGetters.getFlag(key); + } + + public FlagModel.Segment getSegment(String key) { + for (FlagModel.Segment s: segments) { + if (s.getKey().equals(key)) { + return s; + } + } + return baseGetters.getSegment(key); + } + }; + return this; + } + + public EvaluatorBuilder withNonexistentSegment(final String nonexistentSegmentKey) { + final Evaluator.Getters baseGetters = getters; + getters = new Evaluator.Getters() { + public FlagModel.FeatureFlag getFlag(String key) { + return baseGetters.getFlag(key); + } + + public FlagModel.Segment getSegment(String key) { + if (key.equals(nonexistentSegmentKey)) { + return null; + } + return baseGetters.getSegment(key); + } + }; + return this; + } + } +} diff --git a/src/test/java/com/launchdarkly/client/EventOutputTest.java b/src/test/java/com/launchdarkly/client/EventOutputTest.java index a1f698270..e9ea3d585 100644 --- a/src/test/java/com/launchdarkly/client/EventOutputTest.java +++ b/src/test/java/com/launchdarkly/client/EventOutputTest.java @@ -13,6 +13,7 @@ import java.io.StringWriter; import java.util.Set; +import static com.launchdarkly.client.ModelBuilders.flagBuilder; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.containsInAnyOrder; @@ -71,9 +72,9 @@ public void userKeyIsSetInsteadOfUserWhenNotInlined() throws Exception { EventOutputFormatter f = new EventOutputFormatter(new LDConfig.Builder().build()); Event.FeatureRequest featureEvent = EventFactory.DEFAULT.newFeatureRequestEvent( - new FeatureFlagBuilder("flag").build(), + flagBuilder("flag").build(), user, - new EvaluationDetail(EvaluationReason.off(), null, LDValue.ofNull()), + new Evaluator.EvalResult(LDValue.ofNull(), null, EvaluationReason.off()), LDValue.ofNull()); LDValue outputEvent = getSingleOutputEvent(f, featureEvent); assertEquals(LDValue.ofNull(), outputEvent.get("user")); @@ -162,12 +163,12 @@ private void testPrivateAttributes(LDConfig config, LDUser user, String... priva public void featureEventIsSerialized() throws Exception { EventFactory factory = eventFactoryWithTimestamp(100000, false); EventFactory factoryWithReason = eventFactoryWithTimestamp(100000, true); - FeatureFlag flag = new FeatureFlagBuilder("flag").version(11).build(); + FlagModel.FeatureFlag flag = flagBuilder("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")), + new Evaluator.EvalResult(LDValue.of("flagvalue"), 1, EvaluationReason.off()), LDValue.of("defaultvalue")); LDValue feJson1 = parseValue("{" + "\"kind\":\"feature\"," + @@ -182,7 +183,7 @@ public void featureEventIsSerialized() throws Exception { assertEquals(feJson1, getSingleOutputEvent(f, feWithVariation)); FeatureRequest feWithoutVariationOrDefault = factory.newFeatureRequestEvent(flag, user, - new EvaluationDetail(EvaluationReason.off(), null, LDValue.of("flagvalue")), + new Evaluator.EvalResult(LDValue.of("flagvalue"), null, EvaluationReason.off()), LDValue.ofNull()); LDValue feJson2 = parseValue("{" + "\"kind\":\"feature\"," + @@ -195,7 +196,7 @@ public void featureEventIsSerialized() throws Exception { assertEquals(feJson2, getSingleOutputEvent(f, feWithoutVariationOrDefault)); FeatureRequest feWithReason = factoryWithReason.newFeatureRequestEvent(flag, user, - new EvaluationDetail(EvaluationReason.ruleMatch(1, "id"), 1, LDValue.of("flagvalue")), + new Evaluator.EvalResult(LDValue.of("flagvalue"), 1, EvaluationReason.ruleMatch(1, "id")), LDValue.of("defaultvalue")); LDValue feJson3 = parseValue("{" + "\"kind\":\"feature\"," + @@ -386,9 +387,9 @@ private void testInlineUserSerialization(LDUser user, LDValue expectedJsonValue, EventOutputFormatter f = new EventOutputFormatter(baseConfig.build()); Event.FeatureRequest featureEvent = EventFactory.DEFAULT.newFeatureRequestEvent( - new FeatureFlagBuilder("flag").build(), + flagBuilder("flag").build(), user, - new EvaluationDetail(EvaluationReason.off(), null, LDValue.ofNull()), + new Evaluator.EvalResult(LDValue.ofNull(), null, EvaluationReason.off()), LDValue.ofNull()); LDValue outputEvent = getSingleOutputEvent(f, featureEvent); assertEquals(LDValue.ofNull(), outputEvent.get("userKey")); diff --git a/src/test/java/com/launchdarkly/client/EventSummarizerTest.java b/src/test/java/com/launchdarkly/client/EventSummarizerTest.java index c0e6f0aed..c67e53743 100644 --- a/src/test/java/com/launchdarkly/client/EventSummarizerTest.java +++ b/src/test/java/com/launchdarkly/client/EventSummarizerTest.java @@ -7,12 +7,13 @@ import java.util.HashMap; import java.util.Map; -import static com.launchdarkly.client.TestUtil.js; +import static com.launchdarkly.client.ModelBuilders.flagBuilder; 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; +@SuppressWarnings("javadoc") public class EventSummarizerTest { private static final LDUser user = new LDUser.Builder("key").build(); @@ -50,7 +51,7 @@ public void summarizeEventDoesNothingForCustomEvent() { @Test public void summarizeEventSetsStartAndEndDates() { EventSummarizer es = new EventSummarizer(); - FeatureFlag flag = new FeatureFlagBuilder("key").build(); + FlagModel.FeatureFlag flag = flagBuilder("key").build(); eventTimestamp = 2000; Event event1 = eventFactory.newFeatureRequestEvent(flag, user, null, null); eventTimestamp = 1000; @@ -69,8 +70,8 @@ public void summarizeEventSetsStartAndEndDates() { @Test public void summarizeEventIncrementsCounters() { EventSummarizer es = new EventSummarizer(); - FeatureFlag flag1 = new FeatureFlagBuilder("key1").version(11).build(); - FeatureFlag flag2 = new FeatureFlagBuilder("key2").version(22).build(); + FlagModel.FeatureFlag flag1 = flagBuilder("key1").version(11).build(); + FlagModel.FeatureFlag flag2 = flagBuilder("key2").version(22).build(); String unknownFlagKey = "badkey"; Event event1 = eventFactory.newFeatureRequestEvent(flag1, user, simpleEvaluation(1, LDValue.of("value1")), LDValue.of("default1")); diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java deleted file mode 100644 index 17328ed83..000000000 --- a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java +++ /dev/null @@ -1,505 +0,0 @@ -package com.launchdarkly.client; - -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.fromValue; -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.assertNotNull; - -@SuppressWarnings("javadoc") -public class FeatureFlagTest { - - private static LDUser BASE_USER = new LDUser.Builder("x").build(); - - private FeatureStore featureStore; - - @Before - public void before() { - featureStore = new InMemoryFeatureStore(); - } - - @Test - public void flagReturnsOffVariationIfFlagIsOff() throws Exception { - FeatureFlag f = new FeatureFlagBuilder("feature") - .on(false) - .offVariation(1) - .fallthrough(fallthroughVariation(0)) - .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) - .build(); - FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - - assertEquals(fromValue(LDValue.of("off"), 1, EvaluationReason.off()), result.getDetails()); - assertEquals(0, result.getPrerequisiteEvents().size()); - } - - @Test - public void flagReturnsNullIfFlagIsOffAndOffVariationIsUnspecified() throws Exception { - FeatureFlag f = new FeatureFlagBuilder("feature") - .on(false) - .fallthrough(fallthroughVariation(0)) - .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) - .build(); - FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - - assertEquals(fromValue(LDValue.ofNull(), null, EvaluationReason.off()), result.getDetails()); - assertEquals(0, result.getPrerequisiteEvents().size()); - } - - @Test - public void flagReturnsErrorIfFlagIsOffAndOffVariationIsTooHigh() throws Exception { - FeatureFlag f = new FeatureFlagBuilder("feature") - .on(false) - .offVariation(999) - .fallthrough(fallthroughVariation(0)) - .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("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(LDValue.of("fall"), LDValue.of("off"), LDValue.of("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 flagReturnsFallthroughIfFlagIsOnAndThereAreNoRules() throws Exception { - FeatureFlag f = new FeatureFlagBuilder("feature") - .on(true) - .offVariation(1) - .fallthrough(fallthroughVariation(0)) - .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) - .build(); - FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - - assertEquals(fromValue(LDValue.of("fall"), 0, EvaluationReason.fallthrough()), 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(LDValue.of("fall"), LDValue.of("off"), LDValue.of("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(LDValue.of("fall"), LDValue.of("off"), LDValue.of("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(LDValue.of("fall"), LDValue.of("off"), LDValue.of("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(LDValue.of("fall"), LDValue.of("off"), LDValue.of("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") - .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 result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - - EvaluationReason expectedReason = EvaluationReason.prerequisiteFailed("feature1"); - assertEquals(fromValue(LDValue.of("off"), 1, expectedReason), 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(LDValue.of("fall"), LDValue.of("off"), LDValue.of("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(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(fromValue(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(LDValue.of("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") - .on(true) - .prerequisites(Arrays.asList(new Prerequisite("feature1", 1))) - .fallthrough(fallthroughVariation(0)) - .offVariation(1) - .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) - .version(1) - .build(); - FeatureFlag f1 = new FeatureFlagBuilder("feature1") - .on(true) - .fallthrough(fallthroughVariation(0)) - .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(fromValue(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(LDValue.of("nogo"), event.value); - assertEquals(f1.getVersion(), event.version.intValue()); - assertEquals(f0.getKey(), event.prereqOf); - } - - @Test - public void flagReturnsFallthroughVariationAndEventIfPrerequisiteIsMetAndThereAreNoRules() throws Exception { - FeatureFlag f0 = new FeatureFlagBuilder("feature0") - .on(true) - .prerequisites(Arrays.asList(new Prerequisite("feature1", 1))) - .fallthrough(fallthroughVariation(0)) - .offVariation(1) - .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) - .version(1) - .build(); - FeatureFlag f1 = new FeatureFlagBuilder("feature1") - .on(true) - .fallthrough(fallthroughVariation(1)) - .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(fromValue(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(LDValue.of("go"), event.value); - assertEquals(f1.getVersion(), event.version.intValue()); - assertEquals(f0.getKey(), event.prereqOf); - } - - @Test - public void multipleLevelsOfPrerequisitesProduceMultipleEvents() throws Exception { - FeatureFlag f0 = new FeatureFlagBuilder("feature0") - .on(true) - .prerequisites(Arrays.asList(new Prerequisite("feature1", 1))) - .fallthrough(fallthroughVariation(0)) - .offVariation(1) - .variations(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(LDValue.of("nogo"), LDValue.of("go")) - .version(2) - .build(); - FeatureFlag f2 = new FeatureFlagBuilder("feature2") - .on(true) - .fallthrough(fallthroughVariation(1)) - .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(fromValue(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(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(LDValue.of("go"), event1.value); - assertEquals(f1.getVersion(), event1.version.intValue()); - assertEquals(f0.getKey(), event1.prereqOf); - } - - @Test - public void flagMatchesUserFromTargets() throws Exception { - FeatureFlag f = new FeatureFlagBuilder("feature") - .on(true) - .targets(Arrays.asList(new Target(Arrays.asList("whoever", "userkey"), 2))) - .fallthrough(fallthroughVariation(0)) - .offVariation(1) - .variations(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(fromValue(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(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(fromValue(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(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(); - 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(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(); - 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(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(); - 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(LDValue.of("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(LDValue.of("Bob")), false); - FeatureFlag f = booleanFlagWithClauses("flag", clause); - LDUser user = new LDUser.Builder("key").name("Bob").build(); - - 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(LDValue.of(4)), false); - FeatureFlag f = booleanFlagWithClauses("flag", clause); - LDUser user = new LDUser.Builder("key").custom("legs", 4).build(); - - 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(LDValue.of(4)), false); - FeatureFlag f = booleanFlagWithClauses("flag", clause); - LDUser user = new LDUser.Builder("key").name("Bob").build(); - - 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(LDValue.of("Bob")), true); - FeatureFlag f = booleanFlagWithClauses("flag", clause); - LDUser user = new LDUser.Builder("key").name("Bob").build(); - - assertEquals(LDValue.of(false), f.evaluate(user, featureStore, EventFactory.DEFAULT).getDetails().getValue()); - } - - @Test - public void clauseWithUnsupportedOperatorStringIsUnmarshalledWithNullOperator() throws Exception { - // This just verifies that GSON will give us a null in this case instead of throwing an exception, - // so we fail as gracefully as possible if a new operator type has been added in the application - // and the SDK hasn't been upgraded yet. - String badClauseJson = "{\"attribute\":\"name\",\"operator\":\"doesSomethingUnsupported\",\"values\":[\"x\"]}"; - Gson gson = new Gson(); - Clause clause = gson.fromJson(badClauseJson, Clause.class); - assertNotNull(clause); - - JsonElement json = gson.toJsonTree(clause); - String expectedJson = "{\"attribute\":\"name\",\"values\":[\"x\"],\"negate\":false}"; - assertEquals(gson.fromJson(expectedJson, JsonElement.class), json); - } - - @Test - public void clauseWithNullOperatorDoesNotMatch() throws Exception { - 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(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(LDValue.of("Bob")), false); - Rule badRule = new Rule("rule1", Arrays.asList(badClause), 1, null); - 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(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(fromValue(LDValue.of(true), 1, EvaluationReason.ruleMatch(1, "rule2")), details); - } - - @Test - public void testSegmentMatchClauseRetrievesSegmentFromStore() throws Exception { - Segment segment = new Segment.Builder("segkey") - .included(Arrays.asList("foo")) - .version(1) - .build(); - featureStore.upsert(SEGMENTS, segment); - - FeatureFlag flag = segmentMatchBooleanFlag("segkey"); - LDUser user = new LDUser.Builder("foo").build(); - - FeatureFlag.EvalResult result = flag.evaluate(user, featureStore, EventFactory.DEFAULT); - assertEquals(LDValue.of(true), result.getDetails().getValue()); - } - - @Test - public void testSegmentMatchClauseFallsThroughIfSegmentNotFound() throws Exception { - FeatureFlag flag = segmentMatchBooleanFlag("segkey"); - LDUser user = new LDUser.Builder("foo").build(); - - FeatureFlag.EvalResult result = flag.evaluate(user, featureStore, EventFactory.DEFAULT); - assertEquals(LDValue.of(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(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) - .build(); - } - - private FeatureFlag segmentMatchBooleanFlag(String segmentKey) { - 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 92e4cfc0d..4250bccaf 100644 --- a/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java @@ -7,7 +7,7 @@ import org.junit.Test; -import static com.launchdarkly.client.EvaluationDetail.fromValue; +import static com.launchdarkly.client.ModelBuilders.flagBuilder; import static com.launchdarkly.client.TestUtil.js; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; @@ -18,8 +18,8 @@ public class FeatureFlagsStateTest { @Test public void canGetFlagValue() { - EvaluationDetail eval = fromValue(LDValue.of("value"), 1, EvaluationReason.off()); - FeatureFlag flag = new FeatureFlagBuilder("key").build(); + Evaluator.EvalResult eval = new Evaluator.EvalResult(LDValue.of("value"), 1, EvaluationReason.off()); + FlagModel.FeatureFlag flag = flagBuilder("key").build(); FeatureFlagsState state = new FeatureFlagsState.Builder().addFlag(flag, eval).build(); assertEquals(js("value"), state.getFlagValue("key")); @@ -34,8 +34,8 @@ public void unknownFlagReturnsNullValue() { @Test public void canGetFlagReason() { - EvaluationDetail eval = fromValue(LDValue.of("value1"), 1, EvaluationReason.off()); - FeatureFlag flag = new FeatureFlagBuilder("key").build(); + Evaluator.EvalResult eval = new Evaluator.EvalResult(LDValue.of("value1"), 1, EvaluationReason.off()); + FlagModel.FeatureFlag flag = flagBuilder("key").build(); FeatureFlagsState state = new FeatureFlagsState.Builder(FlagsStateOption.WITH_REASONS) .addFlag(flag, eval).build(); @@ -51,8 +51,8 @@ public void unknownFlagReturnsNullReason() { @Test public void reasonIsNullIfReasonsWereNotRecorded() { - EvaluationDetail eval = fromValue(LDValue.of("value1"), 1, EvaluationReason.off()); - FeatureFlag flag = new FeatureFlagBuilder("key").build(); + Evaluator.EvalResult eval = new Evaluator.EvalResult(LDValue.of("value1"), 1, EvaluationReason.off()); + FlagModel.FeatureFlag flag = flagBuilder("key").build(); FeatureFlagsState state = new FeatureFlagsState.Builder().addFlag(flag, eval).build(); assertNull(state.getFlagReason("key")); @@ -60,8 +60,8 @@ public void reasonIsNullIfReasonsWereNotRecorded() { @Test public void flagCanHaveNullValue() { - EvaluationDetail eval = fromValue(LDValue.ofNull(), 1, null); - FeatureFlag flag = new FeatureFlagBuilder("key").build(); + Evaluator.EvalResult eval = new Evaluator.EvalResult(LDValue.ofNull(), 1, null); + FlagModel.FeatureFlag flag = flagBuilder("key").build(); FeatureFlagsState state = new FeatureFlagsState.Builder().addFlag(flag, eval).build(); assertNull(state.getFlagValue("key")); @@ -69,10 +69,10 @@ public void flagCanHaveNullValue() { @Test public void canConvertToValuesMap() { - EvaluationDetail eval1 = fromValue(LDValue.of("value1"), 0, EvaluationReason.off()); - FeatureFlag flag1 = new FeatureFlagBuilder("key1").build(); - EvaluationDetail eval2 = fromValue(LDValue.of("value2"), 1, EvaluationReason.fallthrough()); - FeatureFlag flag2 = new FeatureFlagBuilder("key2").build(); + Evaluator.EvalResult eval1 = new Evaluator.EvalResult(LDValue.of("value1"), 0, EvaluationReason.off()); + FlagModel.FeatureFlag flag1 = flagBuilder("key1").build(); + Evaluator.EvalResult eval2 = new Evaluator.EvalResult(LDValue.of("value2"), 1, EvaluationReason.fallthrough()); + FlagModel.FeatureFlag flag2 = flagBuilder("key2").build(); FeatureFlagsState state = new FeatureFlagsState.Builder() .addFlag(flag1, eval1).addFlag(flag2, eval2).build(); @@ -82,10 +82,10 @@ public void canConvertToValuesMap() { @Test public void canConvertToJson() { - EvaluationDetail eval1 = fromValue(LDValue.of("value1"), 0, EvaluationReason.off()); - FeatureFlag flag1 = new FeatureFlagBuilder("key1").version(100).trackEvents(false).build(); - EvaluationDetail eval2 = fromValue(LDValue.of("value2"), 1, EvaluationReason.fallthrough()); - FeatureFlag flag2 = new FeatureFlagBuilder("key2").version(200).trackEvents(true).debugEventsUntilDate(1000L).build(); + Evaluator.EvalResult eval1 = new Evaluator.EvalResult(LDValue.of("value1"), 0, EvaluationReason.off()); + FlagModel.FeatureFlag flag1 = flagBuilder("key1").version(100).trackEvents(false).build(); + Evaluator.EvalResult eval2 = new Evaluator.EvalResult(LDValue.of("value2"), 1, EvaluationReason.fallthrough()); + FlagModel.FeatureFlag flag2 = flagBuilder("key2").version(200).trackEvents(true).debugEventsUntilDate(1000L).build(); FeatureFlagsState state = new FeatureFlagsState.Builder(FlagsStateOption.WITH_REASONS) .addFlag(flag1, eval1).addFlag(flag2, eval2).build(); @@ -105,10 +105,10 @@ public void canConvertToJson() { @Test public void canConvertFromJson() { - EvaluationDetail eval1 = fromValue(LDValue.of("value1"), 0, EvaluationReason.off()); - FeatureFlag flag1 = new FeatureFlagBuilder("key1").version(100).trackEvents(false).build(); - EvaluationDetail eval2 = fromValue(LDValue.of("value2"), 1, EvaluationReason.off()); - FeatureFlag flag2 = new FeatureFlagBuilder("key2").version(200).trackEvents(true).debugEventsUntilDate(1000L).build(); + Evaluator.EvalResult eval1 = new Evaluator.EvalResult(LDValue.of("value1"), 0, EvaluationReason.off()); + FlagModel.FeatureFlag flag1 = flagBuilder("key1").version(100).trackEvents(false).build(); + Evaluator.EvalResult eval2 = new Evaluator.EvalResult(LDValue.of("value2"), 1, EvaluationReason.off()); + FlagModel.FeatureFlag flag2 = flagBuilder("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/FeatureRequestorTest.java b/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java index b80386f10..2f1a00212 100644 --- a/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java @@ -62,7 +62,7 @@ public void requestFlag() throws Exception { try (MockWebServer server = makeStartedServer(resp)) { try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(sdkKey, basePollingConfig(server).build())) { - FeatureFlag flag = r.getFlag(flag1Key); + FlagModel.FeatureFlag flag = r.getFlag(flag1Key); RecordedRequest req = server.takeRequest(); assertEquals("/sdk/latest-flags/" + flag1Key, req.getPath()); @@ -79,7 +79,7 @@ public void requestSegment() throws Exception { try (MockWebServer server = makeStartedServer(resp)) { try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(sdkKey, basePollingConfig(server).build())) { - Segment segment = r.getSegment(segment1Key); + FlagModel.Segment segment = r.getSegment(segment1Key); RecordedRequest req = server.takeRequest(); assertEquals("/sdk/latest-segments/" + segment1Key, req.getPath()); @@ -130,7 +130,7 @@ public void requestsAreCached() throws Exception { try (MockWebServer server = makeStartedServer(cacheableResp)) { try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(sdkKey, basePollingConfig(server).build())) { - FeatureFlag flag1a = r.getFlag(flag1Key); + FlagModel.FeatureFlag flag1a = r.getFlag(flag1Key); RecordedRequest req1 = server.takeRequest(); assertEquals("/sdk/latest-flags/" + flag1Key, req1.getPath()); @@ -138,7 +138,7 @@ public void requestsAreCached() throws Exception { verifyFlag(flag1a, flag1Key); - FeatureFlag flag1b = r.getFlag(flag1Key); + FlagModel.FeatureFlag flag1b = r.getFlag(flag1Key); verifyFlag(flag1b, flag1Key); assertNull(server.takeRequest(0, TimeUnit.SECONDS)); // there was no second request, due to the cache hit } @@ -172,7 +172,7 @@ public void httpClientCanUseCustomTlsConfig() throws Exception { .build(); try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(sdkKey, config)) { - FeatureFlag flag = r.getFlag(flag1Key); + FlagModel.FeatureFlag flag = r.getFlag(flag1Key); verifyFlag(flag, flag1Key); } } @@ -190,7 +190,7 @@ public void httpClientCanUseProxyConfig() throws Exception { .build(); try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(sdkKey, config)) { - FeatureFlag flag = r.getFlag(flag1Key); + FlagModel.FeatureFlag flag = r.getFlag(flag1Key); verifyFlag(flag, flag1Key); assertEquals(1, server.getRequestCount()); @@ -208,12 +208,12 @@ private void verifyHeaders(RecordedRequest req) { assertEquals("JavaClient/" + LDClient.CLIENT_VERSION, req.getHeader("User-Agent")); } - private void verifyFlag(FeatureFlag flag, String key) { + private void verifyFlag(FlagModel.FeatureFlag flag, String key) { assertNotNull(flag); assertEquals(key, flag.getKey()); } - private void verifySegment(Segment segment, String key) { + private void verifySegment(FlagModel.Segment segment, String key) { assertNotNull(segment); assertEquals(key, segment.getKey()); } diff --git a/src/test/java/com/launchdarkly/client/FeatureStoreDatabaseTestBase.java b/src/test/java/com/launchdarkly/client/FeatureStoreDatabaseTestBase.java index e96278e11..0ca41606e 100644 --- a/src/test/java/com/launchdarkly/client/FeatureStoreDatabaseTestBase.java +++ b/src/test/java/com/launchdarkly/client/FeatureStoreDatabaseTestBase.java @@ -13,6 +13,7 @@ import java.util.Arrays; import java.util.Map; +import static com.launchdarkly.client.ModelBuilders.flagBuilder; import static com.launchdarkly.client.VersionedDataKind.FEATURES; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -133,13 +134,13 @@ public void handlesUpsertRaceConditionAgainstExternalClientWithLowerVersion() th final int store2VersionEnd = 4; int store1VersionEnd = 10; - final FeatureFlag flag1 = new FeatureFlagBuilder("foo").version(startVersion).build(); + final FlagModel.FeatureFlag flag1 = flagBuilder("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(); + FlagModel.FeatureFlag f = flagBuilder(flag1).version(versionCounter).build(); store2.upsert(FEATURES, f); versionCounter++; } @@ -151,10 +152,10 @@ public void run() { store.init(new DataBuilder().add(FEATURES, flag1).build()); - FeatureFlag store1End = new FeatureFlagBuilder(flag1).version(store1VersionEnd).build(); + FlagModel.FeatureFlag store1End = flagBuilder(flag1).version(store1VersionEnd).build(); store.upsert(FEATURES, store1End); - FeatureFlag result = store.get(FEATURES, flag1.getKey()); + FlagModel.FeatureFlag result = store.get(FEATURES, flag1.getKey()); assertEquals(store1VersionEnd, result.getVersion()); } finally { store2.close(); @@ -169,11 +170,11 @@ public void handlesUpsertRaceConditionAgainstExternalClientWithHigherVersion() t final int store2Version = 3; int store1VersionEnd = 2; - final FeatureFlag flag1 = new FeatureFlagBuilder("foo").version(startVersion).build(); + final FlagModel.FeatureFlag flag1 = flagBuilder("foo").version(startVersion).build(); Runnable concurrentModifier = new Runnable() { public void run() { - FeatureFlag f = new FeatureFlagBuilder(flag1).version(store2Version).build(); + FlagModel.FeatureFlag f = flagBuilder(flag1).version(store2Version).build(); store2.upsert(FEATURES, f); } }; @@ -183,10 +184,10 @@ public void run() { store.init(new DataBuilder().add(FEATURES, flag1).build()); - FeatureFlag store1End = new FeatureFlagBuilder(flag1).version(store1VersionEnd).build(); + FlagModel.FeatureFlag store1End = flagBuilder(flag1).version(store1VersionEnd).build(); store.upsert(FEATURES, store1End); - FeatureFlag result = store.get(FEATURES, flag1.getKey()); + FlagModel.FeatureFlag result = store.get(FEATURES, flag1.getKey()); assertEquals(store2Version, result.getVersion()); } finally { store2.close(); @@ -206,10 +207,10 @@ 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(); + FlagModel.FeatureFlag flag1a = flagBuilder("flag-a").version(1).build(); + FlagModel.FeatureFlag flag1b = flagBuilder("flag-b").version(1).build(); + FlagModel.FeatureFlag flag2a = flagBuilder("flag-a").version(2).build(); + FlagModel.FeatureFlag flag2c = flagBuilder("flag-c").version(2).build(); store1.init(new DataBuilder().add(FEATURES, flag1a, flag1b).build()); assertTrue(store1.initialized()); @@ -219,8 +220,8 @@ public void storesWithDifferentPrefixAreIndependent() throws Exception { assertTrue(store1.initialized()); assertTrue(store2.initialized()); - Map items1 = store1.all(FEATURES); - Map items2 = store2.all(FEATURES); + 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()); diff --git a/src/test/java/com/launchdarkly/client/FeatureStoreTestBase.java b/src/test/java/com/launchdarkly/client/FeatureStoreTestBase.java index 5b3bdf2f4..6cc46e98f 100644 --- a/src/test/java/com/launchdarkly/client/FeatureStoreTestBase.java +++ b/src/test/java/com/launchdarkly/client/FeatureStoreTestBase.java @@ -8,6 +8,8 @@ import java.util.Map; +import static com.launchdarkly.client.ModelBuilders.flagBuilder; +import static com.launchdarkly.client.ModelBuilders.segmentBuilder; import static com.launchdarkly.client.VersionedDataKind.FEATURES; import static com.launchdarkly.client.VersionedDataKind.SEGMENTS; import static org.junit.Assert.assertEquals; @@ -20,22 +22,23 @@ * 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 FeatureFlag feature1 = new FeatureFlagBuilder("foo") + protected FlagModel.FeatureFlag feature1 = flagBuilder("foo") .version(10) .salt("abc") .build(); - protected FeatureFlag feature2 = new FeatureFlagBuilder("bar") + protected FlagModel.FeatureFlag feature2 = flagBuilder("bar") .version(10) .salt("abc") .build(); - protected Segment segment1 = new Segment.Builder("foo") + protected FlagModel.Segment segment1 = segmentBuilder("foo") .version(11) .build(); @@ -92,12 +95,12 @@ public void initCompletelyReplacesPreviousData() { new DataBuilder().add(FEATURES, feature1, feature2).add(SEGMENTS, segment1).build(); store.init(allData); - FeatureFlag feature2v2 = new FeatureFlagBuilder(feature2).version(feature2.getVersion() + 1).build(); + FlagModel.FeatureFlag feature2v2 = flagBuilder(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()); + FlagModel.FeatureFlag item2 = store.get(FEATURES, feature2.getKey()); assertNotNull(item2); assertEquals(feature2v2.getVersion(), item2.getVersion()); assertNull(store.get(SEGMENTS, segment1.getKey())); @@ -106,7 +109,7 @@ public void initCompletelyReplacesPreviousData() { @Test public void getExistingFeature() { store.init(new DataBuilder().add(FEATURES, feature1, feature2).build()); - FeatureFlag result = store.get(FEATURES, feature1.getKey()); + FlagModel.FeatureFlag result = store.get(FEATURES, feature1.getKey()); assertEquals(feature1.getKey(), result.getKey()); } @@ -119,12 +122,12 @@ public void getNonexistingFeature() { @Test public void getAll() { store.init(new DataBuilder().add(FEATURES, feature1, feature2).add(SEGMENTS, segment1).build()); - Map items = store.all(FEATURES); + Map items = store.all(FEATURES); assertEquals(2, items.size()); - FeatureFlag item1 = items.get(feature1.getKey()); + FlagModel.FeatureFlag item1 = items.get(feature1.getKey()); assertNotNull(item1); assertEquals(feature1.getVersion(), item1.getVersion()); - FeatureFlag item2 = items.get(feature2.getKey()); + FlagModel.FeatureFlag item2 = items.get(feature2.getKey()); assertNotNull(item2); assertEquals(feature2.getVersion(), item2.getVersion()); } @@ -133,9 +136,9 @@ public void getAll() { 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); + Map items = store.all(FEATURES); assertEquals(1, items.size()); - FeatureFlag item2 = items.get(feature2.getKey()); + FlagModel.FeatureFlag item2 = items.get(feature2.getKey()); assertNotNull(item2); assertEquals(feature2.getVersion(), item2.getVersion()); } @@ -143,33 +146,33 @@ public void getAllWithDeletedItem() { @Test public void upsertWithNewerVersion() { store.init(new DataBuilder().add(FEATURES, feature1, feature2).build()); - FeatureFlag newVer = new FeatureFlagBuilder(feature1) + FlagModel.FeatureFlag newVer = flagBuilder(feature1) .version(feature1.getVersion() + 1) .build(); store.upsert(FEATURES, newVer); - FeatureFlag result = store.get(FEATURES, newVer.getKey()); + FlagModel.FeatureFlag result = store.get(FEATURES, newVer.getKey()); assertEquals(newVer.getVersion(), result.getVersion()); } @Test public void upsertWithOlderVersion() { store.init(new DataBuilder().add(FEATURES, feature1, feature2).build()); - FeatureFlag oldVer = new FeatureFlagBuilder(feature1) + FlagModel.FeatureFlag oldVer = flagBuilder(feature1) .version(feature1.getVersion() - 1) .build(); store.upsert(FEATURES, oldVer); - FeatureFlag result = store.get(FEATURES, oldVer.getKey()); + FlagModel.FeatureFlag result = store.get(FEATURES, oldVer.getKey()); assertEquals(feature1.getVersion(), result.getVersion()); } @Test public void upsertNewFeature() { store.init(new DataBuilder().add(FEATURES, feature1, feature2).build()); - FeatureFlag newFeature = new FeatureFlagBuilder("biz") + FlagModel.FeatureFlag newFeature = flagBuilder("biz") .version(99) .build(); store.upsert(FEATURES, newFeature); - FeatureFlag result = store.get(FEATURES, newFeature.getKey()); + FlagModel.FeatureFlag result = store.get(FEATURES, newFeature.getKey()); assertEquals(newFeature.getKey(), result.getKey()); } diff --git a/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java b/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java index a513e9d0d..cfdede733 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.ModelBuilders.flagBuilder; import static com.launchdarkly.client.TestHttpUtil.baseConfig; import static com.launchdarkly.client.TestHttpUtil.httpsServerWithSelfSignedCert; import static com.launchdarkly.client.TestHttpUtil.jsonResponse; @@ -21,7 +22,7 @@ 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) + private static final FlagModel.FeatureFlag flag = flagBuilder(flagKey) .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 501e7c11f..dad3d34e4 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java @@ -11,11 +11,13 @@ import java.util.Arrays; import java.util.Map; -import static com.launchdarkly.client.TestUtil.booleanFlagWithClauses; +import static com.launchdarkly.client.ModelBuilders.booleanFlagWithClauses; +import static com.launchdarkly.client.ModelBuilders.fallthroughVariation; +import static com.launchdarkly.client.ModelBuilders.flagBuilder; +import static com.launchdarkly.client.ModelBuilders.flagWithValue; +import static com.launchdarkly.client.ModelBuilders.segmentBuilder; import static com.launchdarkly.client.TestUtil.failedUpdateProcessor; -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.specificFeatureStore; import static com.launchdarkly.client.TestUtil.specificUpdateProcessor; import static com.launchdarkly.client.VersionedDataKind.FEATURES; @@ -176,14 +178,14 @@ public void jsonValueVariationReturnsDefaultValueForUnknownFlag() throws Excepti @Test public void canMatchUserBySegment() throws Exception { // This is similar to one of the tests in FeatureFlagTest, but more end-to-end - Segment segment = new Segment.Builder("segment1") + FlagModel.Segment segment = segmentBuilder("segment1") .version(1) - .included(Arrays.asList(user.getKeyAsString())) + .included(user.getKeyAsString()) .build(); featureStore.upsert(SEGMENTS, segment); - Clause clause = new Clause("", Operator.segmentMatch, Arrays.asList(LDValue.of("segment1")), false); - FeatureFlag feature = booleanFlagWithClauses("feature", clause); + FlagModel.Clause clause = new FlagModel.Clause("", Operator.segmentMatch, Arrays.asList(LDValue.of("segment1")), false); + FlagModel.FeatureFlag feature = booleanFlagWithClauses("feature", clause); featureStore.upsert(FEATURES, feature); assertTrue(client.boolVariation("feature", user, false)); @@ -200,7 +202,7 @@ public void canGetDetailsForSuccessfulEvaluation() throws Exception { @Test public void variationReturnsDefaultIfFlagEvaluatesToNull() { - FeatureFlag flag = new FeatureFlagBuilder("key").on(false).offVariation(null).build(); + FlagModel.FeatureFlag flag = flagBuilder("key").on(false).offVariation(null).build(); featureStore.upsert(FEATURES, flag); assertEquals("default", client.stringVariation("key", user, "default")); @@ -208,7 +210,7 @@ public void variationReturnsDefaultIfFlagEvaluatesToNull() { @Test public void variationDetailReturnsDefaultIfFlagEvaluatesToNull() { - FeatureFlag flag = new FeatureFlagBuilder("key").on(false).offVariation(null).build(); + FlagModel.FeatureFlag flag = flagBuilder("key").on(false).offVariation(null).build(); featureStore.upsert(FEATURES, flag); EvaluationDetail expected = EvaluationDetail.fromValue("default", @@ -302,14 +304,14 @@ public void allFlagsReturnsNullForNullUserKey() throws Exception { @Test public void allFlagsStateReturnsState() throws Exception { - FeatureFlag flag1 = new FeatureFlagBuilder("key1") + FlagModel.FeatureFlag flag1 = flagBuilder("key1") .version(100) .trackEvents(false) .on(false) .offVariation(0) .variations(LDValue.of("value1")) .build(); - FeatureFlag flag2 = new FeatureFlagBuilder("key2") + FlagModel.FeatureFlag flag2 = flagBuilder("key2") .version(200) .trackEvents(true) .debugEventsUntilDate(1000L) @@ -339,11 +341,11 @@ public void allFlagsStateReturnsState() throws Exception { @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) + FlagModel.FeatureFlag flag1 = flagBuilder("server-side-1").build(); + FlagModel.FeatureFlag flag2 = flagBuilder("server-side-2").build(); + FlagModel.FeatureFlag flag3 = flagBuilder("client-side-1").clientSide(true) .variations(LDValue.of("value1")).offVariation(0).build(); - FeatureFlag flag4 = new FeatureFlagBuilder("client-side-2").clientSide(true) + FlagModel.FeatureFlag flag4 = flagBuilder("client-side-2").clientSide(true) .variations(LDValue.of("value2")).offVariation(0).build(); featureStore.upsert(FEATURES, flag1); featureStore.upsert(FEATURES, flag2); @@ -359,14 +361,14 @@ public void allFlagsStateCanFilterForOnlyClientSideFlags() { @Test public void allFlagsStateReturnsStateWithReasons() { - FeatureFlag flag1 = new FeatureFlagBuilder("key1") + FlagModel.FeatureFlag flag1 = flagBuilder("key1") .version(100) .trackEvents(false) .on(false) .offVariation(0) .variations(LDValue.of("value1")) .build(); - FeatureFlag flag2 = new FeatureFlagBuilder("key2") + FlagModel.FeatureFlag flag2 = flagBuilder("key2") .version(200) .trackEvents(true) .debugEventsUntilDate(1000L) @@ -397,21 +399,21 @@ public void allFlagsStateReturnsStateWithReasons() { @Test public void allFlagsStateCanOmitDetailsForUntrackedFlags() { long futureTime = System.currentTimeMillis() + 1000000; - FeatureFlag flag1 = new FeatureFlagBuilder("key1") + FlagModel.FeatureFlag flag1 = flagBuilder("key1") .version(100) .trackEvents(false) .on(false) .offVariation(0) .variations(LDValue.of("value1")) .build(); - FeatureFlag flag2 = new FeatureFlagBuilder("key2") + FlagModel.FeatureFlag flag2 = flagBuilder("key2") .version(200) .trackEvents(true) .on(true) .fallthrough(fallthroughVariation(1)) .variations(LDValue.of("off"), LDValue.of("value2")) .build(); - FeatureFlag flag3 = new FeatureFlagBuilder("key3") + FlagModel.FeatureFlag flag3 = flagBuilder("key3") .version(300) .trackEvents(false) .debugEventsUntilDate(futureTime) // event tracking is turned on temporarily even though trackEvents is false diff --git a/src/test/java/com/launchdarkly/client/LDClientEventTest.java b/src/test/java/com/launchdarkly/client/LDClientEventTest.java index f71a56bf3..02d6c61a8 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEventTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEventTest.java @@ -7,12 +7,13 @@ import org.junit.Test; -import java.util.Arrays; - -import static com.launchdarkly.client.TestUtil.fallthroughVariation; -import static com.launchdarkly.client.TestUtil.flagWithValue; -import static com.launchdarkly.client.TestUtil.makeClauseToMatchUser; -import static com.launchdarkly.client.TestUtil.makeClauseToNotMatchUser; +import static com.launchdarkly.client.ModelBuilders.clauseMatchingUser; +import static com.launchdarkly.client.ModelBuilders.clauseNotMatchingUser; +import static com.launchdarkly.client.ModelBuilders.fallthroughVariation; +import static com.launchdarkly.client.ModelBuilders.flagBuilder; +import static com.launchdarkly.client.ModelBuilders.flagWithValue; +import static com.launchdarkly.client.ModelBuilders.prerequisite; +import static com.launchdarkly.client.ModelBuilders.ruleBuilder; import static com.launchdarkly.client.TestUtil.specificEventProcessor; import static com.launchdarkly.client.TestUtil.specificFeatureStore; import static com.launchdarkly.client.VersionedDataKind.FEATURES; @@ -147,7 +148,7 @@ public void trackWithUserWithNoKeyDoesNotSendEvent() { @Test public void boolVariationSendsEvent() throws Exception { - FeatureFlag flag = flagWithValue("key", LDValue.of(true)); + FlagModel.FeatureFlag flag = flagWithValue("key", LDValue.of(true)); featureStore.upsert(FEATURES, flag); client.boolVariation("key", user, false); @@ -164,7 +165,7 @@ public void boolVariationSendsEventForUnknownFlag() throws Exception { @Test public void boolVariationDetailSendsEvent() throws Exception { - FeatureFlag flag = flagWithValue("key", LDValue.of(true)); + FlagModel.FeatureFlag flag = flagWithValue("key", LDValue.of(true)); featureStore.upsert(FEATURES, flag); client.boolVariationDetail("key", user, false); @@ -182,7 +183,7 @@ public void boolVariationDetailSendsEventForUnknownFlag() throws Exception { @Test public void intVariationSendsEvent() throws Exception { - FeatureFlag flag = flagWithValue("key", LDValue.of(2)); + FlagModel.FeatureFlag flag = flagWithValue("key", LDValue.of(2)); featureStore.upsert(FEATURES, flag); client.intVariation("key", user, 1); @@ -199,7 +200,7 @@ public void intVariationSendsEventForUnknownFlag() throws Exception { @Test public void intVariationDetailSendsEvent() throws Exception { - FeatureFlag flag = flagWithValue("key", LDValue.of(2)); + FlagModel.FeatureFlag flag = flagWithValue("key", LDValue.of(2)); featureStore.upsert(FEATURES, flag); client.intVariationDetail("key", user, 1); @@ -217,7 +218,7 @@ public void intVariationDetailSendsEventForUnknownFlag() throws Exception { @Test public void doubleVariationSendsEvent() throws Exception { - FeatureFlag flag = flagWithValue("key", LDValue.of(2.5d)); + FlagModel.FeatureFlag flag = flagWithValue("key", LDValue.of(2.5d)); featureStore.upsert(FEATURES, flag); client.doubleVariation("key", user, 1.0d); @@ -234,7 +235,7 @@ public void doubleVariationSendsEventForUnknownFlag() throws Exception { @Test public void doubleVariationDetailSendsEvent() throws Exception { - FeatureFlag flag = flagWithValue("key", LDValue.of(2.5d)); + FlagModel.FeatureFlag flag = flagWithValue("key", LDValue.of(2.5d)); featureStore.upsert(FEATURES, flag); client.doubleVariationDetail("key", user, 1.0d); @@ -252,7 +253,7 @@ public void doubleVariationDetailSendsEventForUnknownFlag() throws Exception { @Test public void stringVariationSendsEvent() throws Exception { - FeatureFlag flag = flagWithValue("key", LDValue.of("b")); + FlagModel.FeatureFlag flag = flagWithValue("key", LDValue.of("b")); featureStore.upsert(FEATURES, flag); client.stringVariation("key", user, "a"); @@ -269,7 +270,7 @@ public void stringVariationSendsEventForUnknownFlag() throws Exception { @Test public void stringVariationDetailSendsEvent() throws Exception { - FeatureFlag flag = flagWithValue("key", LDValue.of("b")); + FlagModel.FeatureFlag flag = flagWithValue("key", LDValue.of("b")); featureStore.upsert(FEATURES, flag); client.stringVariationDetail("key", user, "a"); @@ -289,7 +290,7 @@ public void stringVariationDetailSendsEventForUnknownFlag() throws Exception { @Test public void jsonVariationSendsEvent() throws Exception { LDValue data = LDValue.buildObject().put("thing", LDValue.of("stuff")).build(); - FeatureFlag flag = flagWithValue("key", data); + FlagModel.FeatureFlag flag = flagWithValue("key", data); featureStore.upsert(FEATURES, flag); LDValue defaultVal = LDValue.of(42); @@ -312,7 +313,7 @@ public void jsonVariationSendsEventForUnknownFlag() throws Exception { @Test public void jsonVariationDetailSendsEvent() throws Exception { LDValue data = LDValue.buildObject().put("thing", LDValue.of("stuff")).build(); - FeatureFlag flag = flagWithValue("key", data); + FlagModel.FeatureFlag flag = flagWithValue("key", data); featureStore.upsert(FEATURES, flag); LDValue defaultVal = LDValue.of(42); @@ -335,7 +336,7 @@ public void jsonVariationDetailSendsEventForUnknownFlag() throws Exception { @Test public void jsonValueVariationDetailSendsEvent() throws Exception { LDValue data = LDValue.buildObject().put("thing", LDValue.of("stuff")).build(); - FeatureFlag flag = flagWithValue("key", data); + FlagModel.FeatureFlag flag = flagWithValue("key", data); featureStore.upsert(FEATURES, flag); LDValue defaultVal = LDValue.of(42); @@ -356,11 +357,11 @@ public void jsonValueVariationDetailSendsEventForUnknownFlag() throws Exception @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") + FlagModel.Clause clause = clauseMatchingUser(user); + FlagModel.Rule rule = ruleBuilder().id("id").clauses(clause).variation(1).trackEvents(true).build(); + FlagModel.FeatureFlag flag = flagBuilder("flag") .on(true) - .rules(Arrays.asList(rule)) + .rules(rule) .offVariation(0) .variations(LDValue.of("off"), LDValue.of("on")) .build(); @@ -379,13 +380,13 @@ public void eventTrackingAndReasonCanBeForcedForRule() throws Exception { @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") + FlagModel.Clause clause0 = clauseNotMatchingUser(user); + FlagModel.Clause clause1 = clauseMatchingUser(user); + FlagModel.Rule rule0 = ruleBuilder().id("id0").clauses(clause0).variation(1).trackEvents(true).build(); + FlagModel.Rule rule1 = ruleBuilder().id("id1").clauses(clause1).variation(1).trackEvents(false).build(); + FlagModel.FeatureFlag flag = flagBuilder("flag") .on(true) - .rules(Arrays.asList(rule0, rule1)) + .rules(rule0, rule1) .offVariation(0) .variations(LDValue.of("off"), LDValue.of("on")) .build(); @@ -403,9 +404,9 @@ public void eventTrackingAndReasonAreNotForcedIfFlagIsNotSetForMatchingRule() th @Test public void eventTrackingAndReasonCanBeForcedForFallthrough() throws Exception { - FeatureFlag flag = new FeatureFlagBuilder("flag") + FlagModel.FeatureFlag flag = flagBuilder("flag") .on(true) - .fallthrough(new VariationOrRollout(0, null)) + .fallthrough(new FlagModel.VariationOrRollout(0, null)) .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .trackEventsFallthrough(true) .build(); @@ -424,9 +425,9 @@ public void eventTrackingAndReasonCanBeForcedForFallthrough() throws Exception { @Test public void eventTrackingAndReasonAreNotForcedForFallthroughIfFlagIsNotSet() throws Exception { - FeatureFlag flag = new FeatureFlagBuilder("flag") + FlagModel.FeatureFlag flag = flagBuilder("flag") .on(true) - .fallthrough(new VariationOrRollout(0, null)) + .fallthrough(new FlagModel.VariationOrRollout(0, null)) .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .trackEventsFallthrough(false) .build(); @@ -442,10 +443,10 @@ public void eventTrackingAndReasonAreNotForcedForFallthroughIfFlagIsNotSet() thr @Test public void eventTrackingAndReasonAreNotForcedForFallthroughIfReasonIsNotFallthrough() throws Exception { - FeatureFlag flag = new FeatureFlagBuilder("flag") + FlagModel.FeatureFlag flag = flagBuilder("flag") .on(false) // so the evaluation reason will be OFF, not FALLTHROUGH .offVariation(1) - .fallthrough(new VariationOrRollout(0, null)) + .fallthrough(new FlagModel.VariationOrRollout(0, null)) .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .trackEventsFallthrough(true) .build(); @@ -461,15 +462,15 @@ public void eventTrackingAndReasonAreNotForcedForFallthroughIfReasonIsNotFallthr @Test public void eventIsSentForExistingPrererequisiteFlag() throws Exception { - FeatureFlag f0 = new FeatureFlagBuilder("feature0") + FlagModel.FeatureFlag f0 = flagBuilder("feature0") .on(true) - .prerequisites(Arrays.asList(new Prerequisite("feature1", 1))) + .prerequisites(prerequisite("feature1", 1)) .fallthrough(fallthroughVariation(0)) .offVariation(1) .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .version(1) .build(); - FeatureFlag f1 = new FeatureFlagBuilder("feature1") + FlagModel.FeatureFlag f1 = flagBuilder("feature1") .on(true) .fallthrough(fallthroughVariation(1)) .variations(LDValue.of("nogo"), LDValue.of("go")) @@ -487,15 +488,15 @@ public void eventIsSentForExistingPrererequisiteFlag() throws Exception { @Test public void eventIsSentWithReasonForExistingPrererequisiteFlag() throws Exception { - FeatureFlag f0 = new FeatureFlagBuilder("feature0") + FlagModel.FeatureFlag f0 = flagBuilder("feature0") .on(true) - .prerequisites(Arrays.asList(new Prerequisite("feature1", 1))) + .prerequisites(prerequisite("feature1", 1)) .fallthrough(fallthroughVariation(0)) .offVariation(1) .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .version(1) .build(); - FeatureFlag f1 = new FeatureFlagBuilder("feature1") + FlagModel.FeatureFlag f1 = flagBuilder("feature1") .on(true) .fallthrough(fallthroughVariation(1)) .variations(LDValue.of("nogo"), LDValue.of("go")) @@ -513,9 +514,9 @@ public void eventIsSentWithReasonForExistingPrererequisiteFlag() throws Exceptio @Test public void eventIsNotSentForUnknownPrererequisiteFlag() throws Exception { - FeatureFlag f0 = new FeatureFlagBuilder("feature0") + FlagModel.FeatureFlag f0 = flagBuilder("feature0") .on(true) - .prerequisites(Arrays.asList(new Prerequisite("feature1", 1))) + .prerequisites(prerequisite("feature1", 1)) .fallthrough(fallthroughVariation(0)) .offVariation(1) .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) @@ -531,9 +532,9 @@ public void eventIsNotSentForUnknownPrererequisiteFlag() throws Exception { @Test public void failureReasonIsGivenForUnknownPrererequisiteFlagIfDetailsWereRequested() throws Exception { - FeatureFlag f0 = new FeatureFlagBuilder("feature0") + FlagModel.FeatureFlag f0 = flagBuilder("feature0") .on(true) - .prerequisites(Arrays.asList(new Prerequisite("feature1", 1))) + .prerequisites(prerequisite("feature1", 1)) .fallthrough(fallthroughVariation(0)) .offVariation(1) .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) @@ -548,7 +549,7 @@ public void failureReasonIsGivenForUnknownPrererequisiteFlagIfDetailsWereRequest EvaluationReason.prerequisiteFailed("feature1")); } - private void checkFeatureEvent(Event e, FeatureFlag flag, LDValue value, LDValue defaultVal, + private void checkFeatureEvent(Event e, FlagModel.FeatureFlag flag, LDValue value, 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 76ee3611a..79c263aae 100644 --- a/src/test/java/com/launchdarkly/client/LDClientLddModeTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientLddModeTest.java @@ -6,7 +6,7 @@ import java.io.IOException; -import static com.launchdarkly.client.TestUtil.flagWithValue; +import static com.launchdarkly.client.ModelBuilders.flagWithValue; import static com.launchdarkly.client.TestUtil.initedFeatureStore; import static com.launchdarkly.client.TestUtil.specificFeatureStore; import static com.launchdarkly.client.VersionedDataKind.FEATURES; @@ -52,7 +52,7 @@ public void lddModeClientGetsFlagFromFeatureStore() throws IOException { .useLdd(true) .featureStoreFactory(specificFeatureStore(testFeatureStore)) .build(); - FeatureFlag flag = flagWithValue("key", LDValue.of(true)); + FlagModel.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 cff7ed994..fb34730d8 100644 --- a/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java @@ -9,7 +9,7 @@ import java.io.IOException; import java.util.Map; -import static com.launchdarkly.client.TestUtil.flagWithValue; +import static com.launchdarkly.client.ModelBuilders.flagWithValue; import static com.launchdarkly.client.TestUtil.initedFeatureStore; import static com.launchdarkly.client.TestUtil.jbool; import static com.launchdarkly.client.TestUtil.specificFeatureStore; diff --git a/src/test/java/com/launchdarkly/client/LDClientTest.java b/src/test/java/com/launchdarkly/client/LDClientTest.java index b585536c9..9b67cf085 100644 --- a/src/test/java/com/launchdarkly/client/LDClientTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientTest.java @@ -21,7 +21,10 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; -import static com.launchdarkly.client.TestUtil.flagWithValue; +import static com.launchdarkly.client.ModelBuilders.flagBuilder; +import static com.launchdarkly.client.ModelBuilders.flagWithValue; +import static com.launchdarkly.client.ModelBuilders.prerequisite; +import static com.launchdarkly.client.ModelBuilders.segmentBuilder; import static com.launchdarkly.client.TestUtil.initedFeatureStore; import static com.launchdarkly.client.TestUtil.specificFeatureStore; import static com.launchdarkly.client.TestUtil.updateProcessorWithData; @@ -312,9 +315,9 @@ public void dataSetIsPassedToFeatureStoreInCorrectOrder() throws Exception { 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()); + FlagModel.FeatureFlag item = (FlagModel.FeatureFlag)list1.get(itemIndex); + for (FlagModel.Prerequisite prereq: item.getPrerequisites()) { + FlagModel.FeatureFlag depFlag = (FlagModel.FeatureFlag)map1.get(prereq.getKey()); int depIndex = list1.indexOf(depFlag); if (depIndex > itemIndex) { Iterable allKeys = Iterables.transform(list1, new Function() { @@ -349,18 +352,18 @@ private LDClientInterface createMockClient(LDConfig.Builder config) { 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()) + .put("a", flagBuilder("a") + .prerequisites(prerequisite("b", 0), prerequisite("c", 0)).build()) + .put("b", flagBuilder("b") + .prerequisites(prerequisite("c", 0), prerequisite("e", 0)).build()) + .put("c", flagBuilder("c").build()) + .put("d", flagBuilder("d").build()) + .put("e", flagBuilder("e").build()) + .put("f", flagBuilder("f").build()) .build(), SEGMENTS, ImmutableMap.of( - "o", new Segment.Builder("o").build() + "o", segmentBuilder("o").build() ) ); } \ No newline at end of file diff --git a/src/test/java/com/launchdarkly/client/ModelBuilders.java b/src/test/java/com/launchdarkly/client/ModelBuilders.java new file mode 100644 index 000000000..62e07f472 --- /dev/null +++ b/src/test/java/com/launchdarkly/client/ModelBuilders.java @@ -0,0 +1,282 @@ +package com.launchdarkly.client; + +import com.google.common.collect.ImmutableList; +import com.launchdarkly.client.value.LDValue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; + +@SuppressWarnings("javadoc") +public abstract class ModelBuilders { + public static FlagBuilder flagBuilder(String key) { + return new FlagBuilder(key); + } + + public static FlagBuilder flagBuilder(FlagModel.FeatureFlag fromFlag) { + return new FlagBuilder(fromFlag); + } + + public static FlagModel.FeatureFlag booleanFlagWithClauses(String key, FlagModel.Clause... clauses) { + FlagModel.Rule rule = ruleBuilder().variation(1).clauses(clauses).build(); + return flagBuilder(key) + .on(true) + .rules(rule) + .fallthrough(fallthroughVariation(0)) + .offVariation(0) + .variations(LDValue.of(false), LDValue.of(true)) + .build(); + } + + public static FlagModel.FeatureFlag flagWithValue(String key, LDValue value) { + return flagBuilder(key) + .on(false) + .offVariation(0) + .variations(value) + .build(); + } + + public static FlagModel.VariationOrRollout fallthroughVariation(int variation) { + return new FlagModel.VariationOrRollout(variation, null); + } + + public static RuleBuilder ruleBuilder() { + return new RuleBuilder(); + } + + public static FlagModel.Clause clauseMatchingUser(LDUser user) { + return new FlagModel.Clause("key", Operator.in, Arrays.asList(user.getKey()), false); + } + + public static FlagModel.Clause clauseNotMatchingUser(LDUser user) { + return new FlagModel.Clause("key", Operator.in, Arrays.asList(LDValue.of("not-" + user.getKeyAsString())), false); + } + + public static FlagModel.Target target(int variation, String... userKeys) { + return new FlagModel.Target(Arrays.asList(userKeys), variation); + } + + public static FlagModel.Prerequisite prerequisite(String key, int variation) { + return new FlagModel.Prerequisite(key, variation); + } + + public static SegmentBuilder segmentBuilder(String key) { + return new SegmentBuilder(key); + } + + public static class FlagBuilder { + private String key; + private int version; + private boolean on; + private List prerequisites = new ArrayList<>(); + private String salt; + private List targets = new ArrayList<>(); + private List rules = new ArrayList<>(); + private FlagModel.VariationOrRollout fallthrough; + private Integer offVariation; + private List variations = new ArrayList<>(); + private boolean clientSide; + private boolean trackEvents; + private boolean trackEventsFallthrough; + private Long debugEventsUntilDate; + private boolean deleted; + + private FlagBuilder(String key) { + this.key = key; + } + + private FlagBuilder(FlagModel.FeatureFlag f) { + if (f != null) { + this.key = f.getKey(); + this.version = f.getVersion(); + this.on = f.isOn(); + this.prerequisites = f.getPrerequisites(); + this.salt = f.getSalt(); + this.targets = f.getTargets(); + this.rules = f.getRules(); + this.fallthrough = f.getFallthrough(); + this.offVariation = f.getOffVariation(); + this.variations = f.getVariations(); + this.clientSide = f.isClientSide(); + this.trackEvents = f.isTrackEvents(); + this.trackEventsFallthrough = f.isTrackEventsFallthrough(); + this.debugEventsUntilDate = f.getDebugEventsUntilDate(); + this.deleted = f.isDeleted(); + } + } + + FlagBuilder version(int version) { + this.version = version; + return this; + } + + FlagBuilder on(boolean on) { + this.on = on; + return this; + } + + FlagBuilder prerequisites(FlagModel.Prerequisite... prerequisites) { + this.prerequisites = Arrays.asList(prerequisites); + return this; + } + + FlagBuilder salt(String salt) { + this.salt = salt; + return this; + } + + FlagBuilder targets(FlagModel.Target... targets) { + this.targets = Arrays.asList(targets); + return this; + } + + FlagBuilder rules(FlagModel.Rule... rules) { + this.rules = Arrays.asList(rules); + return this; + } + + FlagBuilder fallthrough(FlagModel.VariationOrRollout fallthrough) { + this.fallthrough = fallthrough; + return this; + } + + FlagBuilder offVariation(Integer offVariation) { + this.offVariation = offVariation; + return this; + } + + FlagBuilder variations(LDValue... variations) { + this.variations = Arrays.asList(variations); + return this; + } + + FlagBuilder clientSide(boolean clientSide) { + this.clientSide = clientSide; + return this; + } + + FlagBuilder trackEvents(boolean trackEvents) { + this.trackEvents = trackEvents; + return this; + } + + FlagBuilder trackEventsFallthrough(boolean trackEventsFallthrough) { + this.trackEventsFallthrough = trackEventsFallthrough; + return this; + } + + FlagBuilder debugEventsUntilDate(Long debugEventsUntilDate) { + this.debugEventsUntilDate = debugEventsUntilDate; + return this; + } + + FlagBuilder deleted(boolean deleted) { + this.deleted = deleted; + return this; + } + + FlagModel.FeatureFlag build() { + return new FlagModel.FeatureFlag(key, version, on, prerequisites, salt, targets, rules, fallthrough, offVariation, variations, + clientSide, trackEvents, trackEventsFallthrough, debugEventsUntilDate, deleted); + } + } + + public static class RuleBuilder { + private String id; + private List clauses = new ArrayList<>(); + private Integer variation; + private FlagModel.Rollout rollout; + private boolean trackEvents; + + private RuleBuilder() { + } + + public FlagModel.Rule build() { + return new FlagModel.Rule(id, clauses, variation, rollout, trackEvents); + } + + public RuleBuilder id(String id) { + this.id = id; + return this; + } + + public RuleBuilder clauses(FlagModel.Clause... clauses) { + this.clauses = ImmutableList.copyOf(clauses); + return this; + } + + public RuleBuilder variation(Integer variation) { + this.variation = variation; + return this; + } + + public RuleBuilder rollout(FlagModel.Rollout rollout) { + this.rollout = rollout; + return this; + } + + public RuleBuilder trackEvents(boolean trackEvents) { + this.trackEvents = trackEvents; + return this; + } + } + + public static class SegmentBuilder { + private String key; + private List included = new ArrayList<>(); + private List excluded = new ArrayList<>(); + private String salt = ""; + private List rules = new ArrayList<>(); + private int version = 0; + private boolean deleted; + + private SegmentBuilder(String key) { + this.key = key; + } + + private SegmentBuilder(FlagModel.Segment from) { + this.key = from.getKey(); + this.included = ImmutableList.copyOf(from.getIncluded()); + this.excluded = ImmutableList.copyOf(from.getExcluded()); + this.salt = from.getSalt(); + this.rules = ImmutableList.copyOf(from.getRules()); + this.version = from.getVersion(); + this.deleted = from.isDeleted(); + } + + public FlagModel.Segment build() { + return new FlagModel.Segment(key, included, excluded, salt, rules, version, deleted); + } + + public SegmentBuilder included(String... included) { + this.included = Arrays.asList(included); + return this; + } + + public SegmentBuilder excluded(String... excluded) { + this.excluded = Arrays.asList(excluded); + return this; + } + + public SegmentBuilder salt(String salt) { + this.salt = salt; + return this; + } + + public SegmentBuilder rules(FlagModel.SegmentRule... rules) { + this.rules = Arrays.asList(rules); + return this; + } + + public SegmentBuilder version(int version) { + this.version = version; + return this; + } + + public SegmentBuilder deleted(boolean deleted) { + this.deleted = deleted; + return this; + } + } +} diff --git a/src/test/java/com/launchdarkly/client/PollingProcessorTest.java b/src/test/java/com/launchdarkly/client/PollingProcessorTest.java index b143cc35f..3edc58e27 100644 --- a/src/test/java/com/launchdarkly/client/PollingProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/PollingProcessorTest.java @@ -17,7 +17,7 @@ public class PollingProcessorTest { @Test public void testConnectionOk() throws Exception { MockFeatureRequestor requestor = new MockFeatureRequestor(); - requestor.allData = new FeatureRequestor.AllData(new HashMap(), new HashMap()); + requestor.allData = new FeatureRequestor.AllData(new HashMap(), new HashMap()); FeatureStore store = new InMemoryFeatureStore(); try (PollingProcessor pollingProcessor = new PollingProcessor(LDConfig.DEFAULT, requestor, store)) { @@ -116,11 +116,11 @@ private static class MockFeatureRequestor implements FeatureRequestor { public void close() throws IOException {} - public FeatureFlag getFlag(String featureKey) throws IOException, HttpErrorException { + public FlagModel.FeatureFlag getFlag(String featureKey) throws IOException, HttpErrorException { return null; } - public Segment getSegment(String segmentKey) throws IOException, HttpErrorException { + public FlagModel.Segment getSegment(String segmentKey) throws IOException, HttpErrorException { return null; } 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/StreamProcessorTest.java b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java index 92a45136b..e399d6355 100644 --- a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java @@ -21,6 +21,8 @@ import javax.net.ssl.SSLHandshakeException; +import static com.launchdarkly.client.ModelBuilders.flagBuilder; +import static com.launchdarkly.client.ModelBuilders.segmentBuilder; import static com.launchdarkly.client.TestHttpUtil.eventStreamResponse; import static com.launchdarkly.client.TestHttpUtil.makeStartedServer; import static com.launchdarkly.client.TestUtil.specificFeatureStore; @@ -45,10 +47,10 @@ public class StreamProcessorTest extends EasyMockSupport { private static final URI STREAM_URI = URI.create("http://stream.test.com"); private static final String FEATURE1_KEY = "feature1"; private static final int FEATURE1_VERSION = 11; - private static final FeatureFlag FEATURE = new FeatureFlagBuilder(FEATURE1_KEY).version(FEATURE1_VERSION).build(); + private static final FlagModel.FeatureFlag FEATURE = flagBuilder(FEATURE1_KEY).version(FEATURE1_VERSION).build(); 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 FlagModel.Segment SEGMENT = segmentBuilder(SEGMENT1_KEY).version(SEGMENT1_VERSION).build(); private static final String STREAM_RESPONSE_WITH_EMPTY_DATA = "event: put\n" + "data: {\"data\":{\"flags\":{},\"segments\":{}}}\n\n"; @@ -462,17 +464,17 @@ private MessageEvent emptyPutEvent() { return new MessageEvent("{\"data\":{\"flags\":{},\"segments\":{}}}"); } - private void setupRequestorToReturnAllDataWithFlag(FeatureFlag feature) throws Exception { + private void setupRequestorToReturnAllDataWithFlag(FlagModel.FeatureFlag feature) throws Exception { FeatureRequestor.AllData data = new FeatureRequestor.AllData( - Collections.singletonMap(feature.getKey(), feature), Collections.emptyMap()); + Collections.singletonMap(feature.getKey(), feature), Collections.emptyMap()); expect(mockRequestor.getAllData()).andReturn(data); } - private void assertFeatureInStore(FeatureFlag feature) { + private void assertFeatureInStore(FlagModel.FeatureFlag feature) { assertEquals(feature.getVersion(), featureStore.get(FEATURES, feature.getKey()).getVersion()); } - private void assertSegmentInStore(Segment segment) { + private void assertSegmentInStore(FlagModel.Segment segment) { assertEquals(segment.getVersion(), featureStore.get(SEGMENTS, segment.getKey()).getVersion()); } diff --git a/src/test/java/com/launchdarkly/client/TestUtil.java b/src/test/java/com/launchdarkly/client/TestUtil.java index cd963461a..cbac31dd9 100644 --- a/src/test/java/com/launchdarkly/client/TestUtil.java +++ b/src/test/java/com/launchdarkly/client/TestUtil.java @@ -13,7 +13,6 @@ import java.io.IOException; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -155,37 +154,6 @@ public static JsonPrimitive jbool(boolean b) { return new JsonPrimitive(b); } - public static VariationOrRollout fallthroughVariation(int variation) { - return new VariationOrRollout(variation, null); - } - - public static FeatureFlag booleanFlagWithClauses(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)) - .offVariation(0) - .variations(LDValue.of(false), LDValue.of(true)) - .build(); - } - - public static FeatureFlag flagWithValue(String key, LDValue value) { - return new FeatureFlagBuilder(key) - .on(false) - .offVariation(0) - .variations(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(LDValue.of("not-" + user.getKeyAsString())), false); - } - public static class DataBuilder { private Map, Map> data = new HashMap<>(); @@ -207,8 +175,8 @@ public DataBuilder add(VersionedDataKind kind, VersionedData... items) { } } - public static EvaluationDetail simpleEvaluation(int variation, LDValue value) { - return EvaluationDetail.fromValue(value, variation, EvaluationReason.fallthrough()); + public static Evaluator.EvalResult simpleEvaluation(int variation, LDValue value) { + return new Evaluator.EvalResult(value, variation, EvaluationReason.fallthrough()); } public static Matcher hasJsonProperty(final String name, JsonElement value) { From 73eba2092bc7c1665f9a6cdc3bd4e3f01a3fcafe Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 10 Dec 2019 17:14:25 -0800 Subject: [PATCH 213/673] javadoc --- src/main/java/com/launchdarkly/client/EvaluationDetail.java | 1 + src/main/java/com/launchdarkly/client/InMemoryFeatureStore.java | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) 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/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 { From 16aaf85c7e4965e949d36504e46cc49aca0953f5 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 11 Dec 2019 10:23:13 -0800 Subject: [PATCH 214/673] 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 215/673] 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 216/673] [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 217/673] 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 2b1197fb6de2c2695a2cfb5b489e981a3b54556d Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 17 Dec 2019 15:59:52 -0800 Subject: [PATCH 218/673] move component interfaces into subpackage --- .../com/launchdarkly/client/Components.java | 7 + .../client/{FlagModel.java => DataModel.java} | 89 +++++++++- .../client/DefaultEventProcessor.java | 1 + .../client/DefaultFeatureRequestor.java | 14 +- .../com/launchdarkly/client/Evaluator.java | 54 +++--- .../client/EvaluatorBucketing.java | 6 +- .../com/launchdarkly/client/EventFactory.java | 14 +- .../client/FeatureFlagsState.java | 2 +- .../launchdarkly/client/FeatureRequestor.java | 10 +- .../client/FeatureStoreCacheConfig.java | 1 + .../client/FeatureStoreClientWrapper.java | 4 + .../client/FeatureStoreDataSetSorter.java | 4 +- .../client/InMemoryFeatureStore.java | 4 + .../com/launchdarkly/client/LDClient.java | 25 ++- .../com/launchdarkly/client/LDConfig.java | 6 + .../launchdarkly/client/PollingProcessor.java | 2 + .../client/RedisFeatureStore.java | 3 + .../client/RedisFeatureStoreBuilder.java | 2 + .../launchdarkly/client/StreamProcessor.java | 31 ++-- .../launchdarkly/client/TestFeatureStore.java | 26 +-- .../client/VersionedDataKind.java | 168 ------------------ .../client/files/DataBuilder.java | 4 +- .../launchdarkly/client/files/DataLoader.java | 8 +- .../client/files/FileDataSource.java | 4 +- .../client/files/FileDataSourceFactory.java | 6 +- .../client/files/FlagFactory.java | 18 +- .../{ => interfaces}/EventProcessor.java | 4 +- .../EventProcessorFactory.java | 5 +- .../client/{ => interfaces}/FeatureStore.java | 2 +- .../{ => interfaces}/FeatureStoreFactory.java | 4 +- .../{ => interfaces}/UpdateProcessor.java | 4 +- .../UpdateProcessorFactory.java | 5 +- .../{ => interfaces}/VersionedData.java | 2 +- .../client/interfaces/VersionedDataKind.java | 87 +++++++++ .../client/interfaces/package-info.java | 7 + .../client/utils/CachingStoreWrapper.java | 6 +- .../client/utils/FeatureStoreCore.java | 6 +- .../client/utils/FeatureStoreHelpers.java | 6 +- .../client/DefaultEventProcessorTest.java | 34 ++-- .../client/EvaluatorClauseTest.java | 42 ++--- .../client/EvaluatorRuleTest.java | 36 ++-- .../client/EvaluatorSegmentMatchTest.java | 40 ++--- .../launchdarkly/client/EvaluatorTest.java | 58 +++--- .../client/EvaluatorTestUtil.java | 28 +-- .../launchdarkly/client/EventOutputTest.java | 2 +- .../client/EventSummarizerTest.java | 6 +- .../client/FeatureFlagsStateTest.java | 20 +-- .../client/FeatureRequestorTest.java | 16 +- .../client/FeatureStoreDatabaseTestBase.java | 31 ++-- .../client/FeatureStoreTestBase.java | 41 +++-- .../client/FlagModelDeserializationTest.java | 4 +- .../client/LDClientEndToEndTest.java | 2 +- .../client/LDClientEvaluationTest.java | 37 ++-- .../client/LDClientEventTest.java | 67 +++---- .../client/LDClientLddModeTest.java | 6 +- .../client/LDClientOfflineTest.java | 5 +- .../com/launchdarkly/client/LDClientTest.java | 15 +- .../launchdarkly/client/ModelBuilders.java | 88 ++++----- .../client/PollingProcessorTest.java | 8 +- .../client/StreamProcessorTest.java | 16 +- .../com/launchdarkly/client/TestUtil.java | 8 + .../client/files/DataLoaderTest.java | 9 +- .../client/files/FileDataSourceTest.java | 24 +-- .../client/utils/CachingStoreWrapperTest.java | 9 +- 64 files changed, 704 insertions(+), 599 deletions(-) rename src/main/java/com/launchdarkly/client/{FlagModel.java => DataModel.java} (75%) delete mode 100644 src/main/java/com/launchdarkly/client/VersionedDataKind.java rename src/main/java/com/launchdarkly/client/{ => interfaces}/EventProcessor.java (92%) rename src/main/java/com/launchdarkly/client/{ => interfaces}/EventProcessorFactory.java (77%) rename src/main/java/com/launchdarkly/client/{ => interfaces}/FeatureStore.java (98%) rename src/main/java/com/launchdarkly/client/{ => interfaces}/FeatureStoreFactory.java (77%) rename src/main/java/com/launchdarkly/client/{ => interfaces}/UpdateProcessor.java (94%) rename src/main/java/com/launchdarkly/client/{ => interfaces}/UpdateProcessorFactory.java (81%) rename src/main/java/com/launchdarkly/client/{ => interfaces}/VersionedData.java (92%) create mode 100644 src/main/java/com/launchdarkly/client/interfaces/VersionedDataKind.java create mode 100644 src/main/java/com/launchdarkly/client/interfaces/package-info.java diff --git a/src/main/java/com/launchdarkly/client/Components.java b/src/main/java/com/launchdarkly/client/Components.java index 65c993869..f2c861a58 100644 --- a/src/main/java/com/launchdarkly/client/Components.java +++ b/src/main/java/com/launchdarkly/client/Components.java @@ -1,5 +1,12 @@ package com.launchdarkly.client; +import com.launchdarkly.client.interfaces.EventProcessor; +import com.launchdarkly.client.interfaces.EventProcessorFactory; +import com.launchdarkly.client.interfaces.FeatureStore; +import com.launchdarkly.client.interfaces.FeatureStoreFactory; +import com.launchdarkly.client.interfaces.UpdateProcessor; +import com.launchdarkly.client.interfaces.UpdateProcessorFactory; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/src/main/java/com/launchdarkly/client/FlagModel.java b/src/main/java/com/launchdarkly/client/DataModel.java similarity index 75% rename from src/main/java/com/launchdarkly/client/FlagModel.java rename to src/main/java/com/launchdarkly/client/DataModel.java index 42a133fc2..5c9ce02b4 100644 --- a/src/main/java/com/launchdarkly/client/FlagModel.java +++ b/src/main/java/com/launchdarkly/client/DataModel.java @@ -1,10 +1,17 @@ package com.launchdarkly.client; +import com.google.common.base.Function; +import com.google.common.collect.ImmutableList; +import com.google.gson.Gson; import com.google.gson.annotations.JsonAdapter; +import com.launchdarkly.client.interfaces.VersionedData; +import com.launchdarkly.client.interfaces.VersionedDataKind; import com.launchdarkly.client.value.LDValue; import java.util.List; +import static com.google.common.collect.Iterables.transform; + /** * Defines the full data model for feature flags and user segments, in the format provided by the SDK endpoints of * the LaunchDarkly service. All sub-objects contained within flags and segments are also defined here as inner @@ -13,7 +20,87 @@ * These classes should all have package-private scope. They should not provide any logic other than standard * property getters; the evaluation logic is in Evaluator. */ -abstract class FlagModel { +public abstract class DataModel { + public static abstract class DataKinds { + /** + * The {@link VersionedDataKind} instance that describes feature flag data. + */ + public static VersionedDataKind FEATURES = new DataKindImpl("features", DataModel.FeatureFlag.class, "/flags/", 1) { + public DataModel.FeatureFlag makeDeletedItem(String key, int version) { + return new DataModel.FeatureFlag(key, version, false, null, null, null, null, null, null, null, false, false, false, null, true); + } + + public boolean isDependencyOrdered() { + return true; + } + + public Iterable getDependencyKeys(VersionedData item) { + DataModel.FeatureFlag flag = (DataModel.FeatureFlag)item; + if (flag.getPrerequisites() == null || flag.getPrerequisites().isEmpty()) { + return ImmutableList.of(); + } + return transform(flag.getPrerequisites(), new Function() { + public String apply(DataModel.Prerequisite p) { + return p.getKey(); + } + }); + } + }; + + /** + * The {@link VersionedDataKind} instance that describes user segment data. + */ + public static VersionedDataKind SEGMENTS = new DataKindImpl("segments", DataModel.Segment.class, "/segments/", 0) { + + public DataModel.Segment makeDeletedItem(String key, int version) { + return new DataModel.Segment(key, null, null, null, null, version, true); + } + }; + + static abstract class DataKindImpl extends VersionedDataKind { + private static final Gson gson = new Gson(); + + private final String namespace; + private final Class itemClass; + private final String streamApiPath; + private final int priority; + + DataKindImpl(String namespace, Class itemClass, String streamApiPath, int priority) { + this.namespace = namespace; + this.itemClass = itemClass; + this.streamApiPath = streamApiPath; + this.priority = priority; + } + + public String getNamespace() { + return namespace; + } + + public Class getItemClass() { + return itemClass; + } + + public String getStreamApiPath() { + return streamApiPath; + } + + public int getPriority() { + return priority; + } + + public T deserialize(String serializedData) { + return gson.fromJson(serializedData, itemClass); + } + + /** + * Used internally to match data URLs in the streaming API. + * @param path path from an API message + * @return the parsed key if the path refers to an object of this kind, otherwise null + */ + + } + } + @JsonAdapter(JsonHelpers.PostProcessingDeserializableTypeAdapterFactory.class) static final class FeatureFlag implements VersionedData, JsonHelpers.PostProcessingDeserializable { private String key; diff --git a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java index 5aee5efa0..db5010422 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.EventProcessor; 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 f7b05ee5f..b735a2a5d 100644 --- a/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java +++ b/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java @@ -1,6 +1,8 @@ package com.launchdarkly.client; import com.google.common.io.Files; +import com.launchdarkly.client.interfaces.VersionedData; +import com.launchdarkly.client.interfaces.VersionedDataKind; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -10,11 +12,11 @@ import java.util.HashMap; import java.util.Map; +import static com.launchdarkly.client.DataModel.DataKinds.FEATURES; +import static com.launchdarkly.client.DataModel.DataKinds.SEGMENTS; 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; @@ -54,14 +56,14 @@ public void close() { shutdownHttpClient(httpClient); } - public FlagModel.FeatureFlag getFlag(String featureKey) throws IOException, HttpErrorException { + public DataModel.FeatureFlag getFlag(String featureKey) throws IOException, HttpErrorException { String body = get(GET_LATEST_FLAGS_PATH + "/" + featureKey); - return config.gson.fromJson(body, FlagModel.FeatureFlag.class); + return config.gson.fromJson(body, DataModel.FeatureFlag.class); } - public FlagModel.Segment getSegment(String segmentKey) throws IOException, HttpErrorException { + public DataModel.Segment getSegment(String segmentKey) throws IOException, HttpErrorException { String body = get(GET_LATEST_SEGMENTS_PATH + "/" + segmentKey); - return config.gson.fromJson(body, FlagModel.Segment.class); + return config.gson.fromJson(body, DataModel.Segment.class); } public AllData getAllData() throws IOException, HttpErrorException { diff --git a/src/main/java/com/launchdarkly/client/Evaluator.java b/src/main/java/com/launchdarkly/client/Evaluator.java index 214c13bfe..1a585fe70 100644 --- a/src/main/java/com/launchdarkly/client/Evaluator.java +++ b/src/main/java/com/launchdarkly/client/Evaluator.java @@ -27,8 +27,8 @@ class Evaluator { * and simplifies testing. */ static interface Getters { - FlagModel.FeatureFlag getFlag(String key); - FlagModel.Segment getSegment(String key); + DataModel.FeatureFlag getFlag(String key); + DataModel.Segment getSegment(String key); } /** @@ -105,7 +105,7 @@ private void setPrerequisiteEvents(List prerequisiteEvents * @param eventFactory produces feature request events * @return an {@link EvalResult} */ - EvalResult evaluate(FlagModel.FeatureFlag flag, LDUser user, EventFactory eventFactory) { + EvalResult evaluate(DataModel.FeatureFlag flag, LDUser user, EventFactory eventFactory) { 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", flag.getKey()); @@ -123,7 +123,7 @@ EvalResult evaluate(FlagModel.FeatureFlag flag, LDUser user, EventFactory eventF return result; } - private EvalResult evaluateInternal(FlagModel.FeatureFlag flag, LDUser user, EventFactory eventFactory, + private EvalResult evaluateInternal(DataModel.FeatureFlag flag, LDUser user, EventFactory eventFactory, List eventsOut) { if (!flag.isOn()) { return getOffValue(flag, EvaluationReason.off()); @@ -135,9 +135,9 @@ private EvalResult evaluateInternal(FlagModel.FeatureFlag flag, LDUser user, Eve } // Check to see if targets match - List targets = flag.getTargets(); + List targets = flag.getTargets(); if (targets != null) { - for (FlagModel.Target target: targets) { + for (DataModel.Target target: targets) { for (String v : target.getValues()) { if (v.equals(user.getKey().stringValue())) { return getVariation(flag, target.getVariation(), EvaluationReason.targetMatch()); @@ -146,10 +146,10 @@ private EvalResult evaluateInternal(FlagModel.FeatureFlag flag, LDUser user, Eve } } // Now walk through the rules and see if any match - List rules = flag.getRules(); + List rules = flag.getRules(); if (rules != null) { for (int i = 0; i < rules.size(); i++) { - FlagModel.Rule rule = rules.get(i); + DataModel.Rule rule = rules.get(i); if (ruleMatchesUser(flag, rule, user)) { EvaluationReason.RuleMatch precomputedReason = rule.getRuleMatchReason(); EvaluationReason.RuleMatch reason = precomputedReason != null ? precomputedReason : EvaluationReason.ruleMatch(i, rule.getId()); @@ -163,15 +163,15 @@ private EvalResult evaluateInternal(FlagModel.FeatureFlag flag, LDUser user, Eve // 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(FlagModel.FeatureFlag flag, LDUser user, EventFactory eventFactory, + private EvaluationReason checkPrerequisites(DataModel.FeatureFlag flag, LDUser user, EventFactory eventFactory, List eventsOut) { - List prerequisites = flag.getPrerequisites(); + List prerequisites = flag.getPrerequisites(); if (prerequisites == null) { return null; } - for (FlagModel.Prerequisite prereq: prerequisites) { + for (DataModel.Prerequisite prereq: prerequisites) { boolean prereqOk = true; - FlagModel.FeatureFlag prereqFeatureFlag = getters.getFlag(prereq.getKey()); + DataModel.FeatureFlag prereqFeatureFlag = getters.getFlag(prereq.getKey()); if (prereqFeatureFlag == null) { logger.error("Could not retrieve prerequisite flag \"{}\" when evaluating \"{}\"", prereq.getKey(), flag.getKey()); prereqOk = false; @@ -194,7 +194,7 @@ private EvaluationReason checkPrerequisites(FlagModel.FeatureFlag flag, LDUser u return null; } - private EvalResult getVariation(FlagModel.FeatureFlag flag, int variation, EvaluationReason reason) { + private EvalResult getVariation(DataModel.FeatureFlag flag, int variation, EvaluationReason reason) { List variations = flag.getVariations(); if (variation < 0 || variation >= variations.size()) { logger.error("Data inconsistency in feature flag \"{}\": invalid variation index", flag.getKey()); @@ -204,7 +204,7 @@ private EvalResult getVariation(FlagModel.FeatureFlag flag, int variation, Evalu } } - private EvalResult getOffValue(FlagModel.FeatureFlag flag, EvaluationReason reason) { + private EvalResult getOffValue(DataModel.FeatureFlag flag, EvaluationReason reason) { Integer offVariation = flag.getOffVariation(); if (offVariation == null) { // off variation unspecified - return default value return new EvalResult(null, null, reason); @@ -213,7 +213,7 @@ private EvalResult getOffValue(FlagModel.FeatureFlag flag, EvaluationReason reas } } - private EvalResult getValueForVariationOrRollout(FlagModel.FeatureFlag flag, FlagModel.VariationOrRollout vr, LDUser user, EvaluationReason reason) { + private EvalResult getValueForVariationOrRollout(DataModel.FeatureFlag flag, DataModel.VariationOrRollout vr, LDUser user, EvaluationReason reason) { Integer index = EvaluatorBucketing.variationIndexForUser(vr, user, flag.getKey(), flag.getSalt()); if (index == null) { logger.error("Data inconsistency in feature flag \"{}\": variation/rollout object with no variation or rollout", flag.getKey()); @@ -223,10 +223,10 @@ private EvalResult getValueForVariationOrRollout(FlagModel.FeatureFlag flag, Fla } } - private boolean ruleMatchesUser(FlagModel.FeatureFlag flag, FlagModel.Rule rule, LDUser user) { - Iterable clauses = rule.getClauses(); + private boolean ruleMatchesUser(DataModel.FeatureFlag flag, DataModel.Rule rule, LDUser user) { + Iterable clauses = rule.getClauses(); if (clauses != null) { - for (FlagModel.Clause clause: clauses) { + for (DataModel.Clause clause: clauses) { if (!clauseMatchesUser(clause, user)) { return false; } @@ -235,13 +235,13 @@ private boolean ruleMatchesUser(FlagModel.FeatureFlag flag, FlagModel.Rule rule, return true; } - private boolean clauseMatchesUser(FlagModel.Clause clause, LDUser user) { + private boolean clauseMatchesUser(DataModel.Clause clause, 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 (clause.getOp() == Operator.segmentMatch) { for (LDValue j: clause.getValues()) { if (j.isString()) { - FlagModel.Segment segment = getters.getSegment(j.stringValue()); + DataModel.Segment segment = getters.getSegment(j.stringValue()); if (segment != null) { if (segmentMatchesUser(segment, user)) { return maybeNegate(clause, true); @@ -255,7 +255,7 @@ private boolean clauseMatchesUser(FlagModel.Clause clause, LDUser user) { return clauseMatchesUserNoSegments(clause, user); } - private boolean clauseMatchesUserNoSegments(FlagModel.Clause clause, LDUser user) { + private boolean clauseMatchesUserNoSegments(DataModel.Clause clause, LDUser user) { LDValue userValue = user.getValueForEvaluation(clause.getAttribute()); if (userValue.isNull()) { return false; @@ -280,7 +280,7 @@ private boolean clauseMatchesUserNoSegments(FlagModel.Clause clause, LDUser user return false; } - private boolean clauseMatchAny(FlagModel.Clause clause, LDValue userValue) { + private boolean clauseMatchAny(DataModel.Clause clause, LDValue userValue) { Operator op = clause.getOp(); if (op != null) { for (LDValue v : clause.getValues()) { @@ -292,11 +292,11 @@ private boolean clauseMatchAny(FlagModel.Clause clause, LDValue userValue) { return false; } - private boolean maybeNegate(FlagModel.Clause clause, boolean b) { + private boolean maybeNegate(DataModel.Clause clause, boolean b) { return clause.isNegate() ? !b : b; } - private boolean segmentMatchesUser(FlagModel.Segment segment, LDUser user) { + private boolean segmentMatchesUser(DataModel.Segment segment, LDUser user) { String userKey = user.getKeyAsString(); if (userKey == null) { return false; @@ -307,7 +307,7 @@ private boolean segmentMatchesUser(FlagModel.Segment segment, LDUser user) { if (Iterables.contains(segment.getExcluded(), userKey)) { return false; } - for (FlagModel.SegmentRule rule: segment.getRules()) { + for (DataModel.SegmentRule rule: segment.getRules()) { if (segmentRuleMatchesUser(rule, user, segment.getKey(), segment.getSalt())) { return true; } @@ -315,8 +315,8 @@ private boolean segmentMatchesUser(FlagModel.Segment segment, LDUser user) { return false; } - private boolean segmentRuleMatchesUser(FlagModel.SegmentRule segmentRule, LDUser user, String segmentKey, String salt) { - for (FlagModel.Clause c: segmentRule.getClauses()) { + private boolean segmentRuleMatchesUser(DataModel.SegmentRule segmentRule, LDUser user, String segmentKey, String salt) { + for (DataModel.Clause c: segmentRule.getClauses()) { if (!clauseMatchesUserNoSegments(c, user)) { return false; } diff --git a/src/main/java/com/launchdarkly/client/EvaluatorBucketing.java b/src/main/java/com/launchdarkly/client/EvaluatorBucketing.java index d747c62fe..d6856c82d 100644 --- a/src/main/java/com/launchdarkly/client/EvaluatorBucketing.java +++ b/src/main/java/com/launchdarkly/client/EvaluatorBucketing.java @@ -12,16 +12,16 @@ abstract class EvaluatorBucketing { // 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). - static Integer variationIndexForUser(FlagModel.VariationOrRollout vr, LDUser user, String key, String salt) { + static Integer variationIndexForUser(DataModel.VariationOrRollout vr, LDUser user, String key, String salt) { Integer variation = vr.getVariation(); if (variation != null) { return variation; } else { - FlagModel.Rollout rollout = vr.getRollout(); + DataModel.Rollout rollout = vr.getRollout(); if (rollout != null && rollout.getVariations() != null && !rollout.getVariations().isEmpty()) { float bucket = bucketUser(user, key, rollout.getBucketBy(), salt); float sum = 0F; - for (FlagModel.WeightedVariation wv : rollout.getVariations()) { + for (DataModel.WeightedVariation wv : rollout.getVariations()) { sum += (float) wv.getWeight() / 100000F; if (bucket < sum) { return wv.getVariation(); diff --git a/src/main/java/com/launchdarkly/client/EventFactory.java b/src/main/java/com/launchdarkly/client/EventFactory.java index 4fe75a681..142fc0a8b 100644 --- a/src/main/java/com/launchdarkly/client/EventFactory.java +++ b/src/main/java/com/launchdarkly/client/EventFactory.java @@ -9,7 +9,7 @@ abstract class EventFactory { protected abstract long getTimestamp(); protected abstract boolean isIncludeReasons(); - public Event.FeatureRequest newFeatureRequestEvent(FlagModel.FeatureFlag flag, LDUser user, LDValue value, + public Event.FeatureRequest newFeatureRequestEvent(DataModel.FeatureFlag flag, LDUser user, LDValue value, Integer variationIndex, EvaluationReason reason, LDValue defaultValue, String prereqOf) { boolean requireExperimentData = isExperiment(flag, reason); return new Event.FeatureRequest( @@ -28,13 +28,13 @@ public Event.FeatureRequest newFeatureRequestEvent(FlagModel.FeatureFlag flag, L ); } - public Event.FeatureRequest newFeatureRequestEvent(FlagModel.FeatureFlag flag, LDUser user, Evaluator.EvalResult result, LDValue defaultVal) { + public Event.FeatureRequest newFeatureRequestEvent(DataModel.FeatureFlag flag, LDUser user, Evaluator.EvalResult 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(FlagModel.FeatureFlag flag, LDUser user, LDValue defaultValue, + public Event.FeatureRequest newDefaultFeatureRequestEvent(DataModel.FeatureFlag flag, LDUser user, LDValue defaultValue, EvaluationReason.ErrorKind errorKind) { return new Event.FeatureRequest(getTimestamp(), flag.getKey(), user, flag.getVersion(), null, defaultValue, defaultValue, isIncludeReasons() ? EvaluationReason.error(errorKind) : null, @@ -47,8 +47,8 @@ public Event.FeatureRequest newUnknownFeatureRequestEvent(String key, LDUser use isIncludeReasons() ? EvaluationReason.error(errorKind) : null, null, false, null, false); } - public Event.FeatureRequest newPrerequisiteFeatureRequestEvent(FlagModel.FeatureFlag prereqFlag, LDUser user, - Evaluator.EvalResult details, FlagModel.FeatureFlag prereqOf) { + public Event.FeatureRequest newPrerequisiteFeatureRequestEvent(DataModel.FeatureFlag prereqFlag, LDUser user, + Evaluator.EvalResult details, DataModel.FeatureFlag prereqOf) { return newFeatureRequestEvent(prereqFlag, user, details == null ? null : details.getValue(), details == null ? null : details.getVariationIndex(), details == null ? null : details.getReason(), LDValue.ofNull(), prereqOf.getKey()); @@ -67,7 +67,7 @@ public Event.Identify newIdentifyEvent(LDUser user) { return new Event.Identify(getTimestamp(), user); } - private boolean isExperiment(FlagModel.FeatureFlag flag, EvaluationReason reason) { + private boolean isExperiment(DataModel.FeatureFlag flag, EvaluationReason reason) { if (reason == null) { // doesn't happen in real life, but possible in testing return false; @@ -86,7 +86,7 @@ private boolean isExperiment(FlagModel.FeatureFlag flag, EvaluationReason reason // 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()) { - FlagModel.Rule rule = flag.getRules().get(ruleIndex); + DataModel.Rule rule = flag.getRules().get(ruleIndex); return rule.isTrackEvents(); } return false; diff --git a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java b/src/main/java/com/launchdarkly/client/FeatureFlagsState.java index 80f915d1e..0334f4f66 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlagsState.java @@ -146,7 +146,7 @@ Builder valid(boolean valid) { } @SuppressWarnings("deprecation") - Builder addFlag(FlagModel.FeatureFlag flag, Evaluator.EvalResult eval) { + Builder addFlag(DataModel.FeatureFlag flag, Evaluator.EvalResult eval) { flagValues.put(flag.getKey(), eval.getValue().asUnsafeJsonElement()); final boolean flagIsTracked = flag.isTrackEvents() || (flag.getDebugEventsUntilDate() != null && flag.getDebugEventsUntilDate() > System.currentTimeMillis()); diff --git a/src/main/java/com/launchdarkly/client/FeatureRequestor.java b/src/main/java/com/launchdarkly/client/FeatureRequestor.java index 5fb63d8e1..38581a401 100644 --- a/src/main/java/com/launchdarkly/client/FeatureRequestor.java +++ b/src/main/java/com/launchdarkly/client/FeatureRequestor.java @@ -5,17 +5,17 @@ import java.util.Map; interface FeatureRequestor extends Closeable { - FlagModel.FeatureFlag getFlag(String featureKey) throws IOException, HttpErrorException; + DataModel.FeatureFlag getFlag(String featureKey) throws IOException, HttpErrorException; - FlagModel.Segment getSegment(String segmentKey) throws IOException, HttpErrorException; + DataModel.Segment getSegment(String segmentKey) throws IOException, HttpErrorException; AllData getAllData() throws IOException, HttpErrorException; static class AllData { - final Map flags; - final Map segments; + final Map flags; + final Map segments; - AllData(Map flags, Map segments) { + AllData(Map flags, Map segments) { this.flags = flags; this.segments = segments; } diff --git a/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java b/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java index 9c615e4b1..4c8363709 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.interfaces.FeatureStore; import java.util.Objects; import java.util.concurrent.TimeUnit; diff --git a/src/main/java/com/launchdarkly/client/FeatureStoreClientWrapper.java b/src/main/java/com/launchdarkly/client/FeatureStoreClientWrapper.java index 5c2bba097..583f4dc4f 100644 --- a/src/main/java/com/launchdarkly/client/FeatureStoreClientWrapper.java +++ b/src/main/java/com/launchdarkly/client/FeatureStoreClientWrapper.java @@ -1,5 +1,9 @@ package com.launchdarkly.client; +import com.launchdarkly.client.interfaces.FeatureStore; +import com.launchdarkly.client.interfaces.VersionedData; +import com.launchdarkly.client.interfaces.VersionedDataKind; + import java.io.IOException; import java.util.Map; diff --git a/src/main/java/com/launchdarkly/client/FeatureStoreDataSetSorter.java b/src/main/java/com/launchdarkly/client/FeatureStoreDataSetSorter.java index d2ae25fc3..92eb49919 100644 --- a/src/main/java/com/launchdarkly/client/FeatureStoreDataSetSorter.java +++ b/src/main/java/com/launchdarkly/client/FeatureStoreDataSetSorter.java @@ -2,6 +2,8 @@ import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSortedMap; +import com.launchdarkly.client.interfaces.VersionedData; +import com.launchdarkly.client.interfaces.VersionedDataKind; import java.util.Comparator; import java.util.HashMap; @@ -9,7 +11,7 @@ /** * 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)}. + * on every data set that will be passed to {@link com.launchdarkly.client.interfaces.FeatureStore#init(Map)}. * * @since 4.6.1 */ diff --git a/src/main/java/com/launchdarkly/client/InMemoryFeatureStore.java b/src/main/java/com/launchdarkly/client/InMemoryFeatureStore.java index 05ad4bbb2..2405f7f7d 100644 --- a/src/main/java/com/launchdarkly/client/InMemoryFeatureStore.java +++ b/src/main/java/com/launchdarkly/client/InMemoryFeatureStore.java @@ -1,5 +1,9 @@ package com.launchdarkly.client; +import com.launchdarkly.client.interfaces.FeatureStore; +import com.launchdarkly.client.interfaces.VersionedData; +import com.launchdarkly.client.interfaces.VersionedDataKind; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index edb8eefe3..d4d67f1b7 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -1,6 +1,12 @@ package com.launchdarkly.client; import com.google.gson.JsonElement; +import com.launchdarkly.client.interfaces.EventProcessor; +import com.launchdarkly.client.interfaces.EventProcessorFactory; +import com.launchdarkly.client.interfaces.FeatureStore; +import com.launchdarkly.client.interfaces.FeatureStoreFactory; +import com.launchdarkly.client.interfaces.UpdateProcessor; +import com.launchdarkly.client.interfaces.UpdateProcessorFactory; import com.launchdarkly.client.value.LDValue; import org.apache.commons.codec.binary.Hex; @@ -23,7 +29,8 @@ import javax.crypto.spec.SecretKeySpec; import static com.google.common.base.Preconditions.checkNotNull; -import static com.launchdarkly.client.VersionedDataKind.FEATURES; +import static com.launchdarkly.client.DataModel.DataKinds.FEATURES; +import static com.launchdarkly.client.DataModel.DataKinds.SEGMENTS; /** * A client for the LaunchDarkly API. Client instances are thread-safe. Applications should instantiate @@ -81,12 +88,12 @@ public LDClient(String sdkKey, LDConfig config) { this.featureStore = new FeatureStoreClientWrapper(store); this.evaluator = new Evaluator(new Evaluator.Getters() { - public FlagModel.FeatureFlag getFlag(String key) { - return LDClient.this.featureStore.get(VersionedDataKind.FEATURES, key); + public DataModel.FeatureFlag getFlag(String key) { + return LDClient.this.featureStore.get(FEATURES, key); } - public FlagModel.Segment getSegment(String key) { - return LDClient.this.featureStore.get(VersionedDataKind.SEGMENTS, key); + public DataModel.Segment getSegment(String key) { + return LDClient.this.featureStore.get(SEGMENTS, key); } }); @@ -202,9 +209,9 @@ public FeatureFlagsState allFlagsState(LDUser user, FlagsStateOption... options) } boolean clientSideOnly = FlagsStateOption.hasOption(options, FlagsStateOption.CLIENT_SIDE_ONLY); - Map flags = featureStore.all(FEATURES); - for (Map.Entry entry : flags.entrySet()) { - FlagModel.FeatureFlag flag = entry.getValue(); + Map flags = featureStore.all(FEATURES); + for (Map.Entry entry : flags.entrySet()) { + DataModel.FeatureFlag flag = entry.getValue(); if (clientSideOnly && !flag.isClientSide()) { continue; } @@ -342,7 +349,7 @@ private Evaluator.EvalResult evaluateInternal(String featureKey, LDUser user, LD } } - FlagModel.FeatureFlag featureFlag = null; + DataModel.FeatureFlag featureFlag = null; try { featureFlag = featureStore.get(FEATURES, featureKey); if (featureFlag == null) { diff --git a/src/main/java/com/launchdarkly/client/LDConfig.java b/src/main/java/com/launchdarkly/client/LDConfig.java index 38ff7b475..202db1126 100644 --- a/src/main/java/com/launchdarkly/client/LDConfig.java +++ b/src/main/java/com/launchdarkly/client/LDConfig.java @@ -2,6 +2,12 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; +import com.launchdarkly.client.interfaces.EventProcessor; +import com.launchdarkly.client.interfaces.EventProcessorFactory; +import com.launchdarkly.client.interfaces.FeatureStore; +import com.launchdarkly.client.interfaces.FeatureStoreFactory; +import com.launchdarkly.client.interfaces.UpdateProcessor; +import com.launchdarkly.client.interfaces.UpdateProcessorFactory; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/src/main/java/com/launchdarkly/client/PollingProcessor.java b/src/main/java/com/launchdarkly/client/PollingProcessor.java index 436fa4659..cb4e664bc 100644 --- a/src/main/java/com/launchdarkly/client/PollingProcessor.java +++ b/src/main/java/com/launchdarkly/client/PollingProcessor.java @@ -2,6 +2,8 @@ import com.google.common.util.concurrent.SettableFuture; import com.google.common.util.concurrent.ThreadFactoryBuilder; +import com.launchdarkly.client.interfaces.FeatureStore; +import com.launchdarkly.client.interfaces.UpdateProcessor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/src/main/java/com/launchdarkly/client/RedisFeatureStore.java b/src/main/java/com/launchdarkly/client/RedisFeatureStore.java index 55091fa40..07df92641 100644 --- a/src/main/java/com/launchdarkly/client/RedisFeatureStore.java +++ b/src/main/java/com/launchdarkly/client/RedisFeatureStore.java @@ -2,6 +2,9 @@ import com.google.common.annotations.VisibleForTesting; import com.google.common.cache.CacheStats; +import com.launchdarkly.client.interfaces.FeatureStore; +import com.launchdarkly.client.interfaces.VersionedData; +import com.launchdarkly.client.interfaces.VersionedDataKind; import com.launchdarkly.client.utils.CachingStoreWrapper; import com.launchdarkly.client.utils.FeatureStoreCore; diff --git a/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java b/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java index 7c5661e82..b5f5900e2 100644 --- a/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java +++ b/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java @@ -3,6 +3,8 @@ import redis.clients.jedis.JedisPoolConfig; import redis.clients.jedis.Protocol; +import com.launchdarkly.client.interfaces.FeatureStoreFactory; + import java.net.URI; import java.net.URISyntaxException; import java.util.concurrent.TimeUnit; diff --git a/src/main/java/com/launchdarkly/client/StreamProcessor.java b/src/main/java/com/launchdarkly/client/StreamProcessor.java index 9172fef18..990e0d39d 100644 --- a/src/main/java/com/launchdarkly/client/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/client/StreamProcessor.java @@ -3,6 +3,9 @@ import com.google.common.util.concurrent.SettableFuture; import com.google.gson.Gson; import com.google.gson.JsonElement; +import com.launchdarkly.client.interfaces.FeatureStore; +import com.launchdarkly.client.interfaces.UpdateProcessor; +import com.launchdarkly.client.interfaces.VersionedDataKind; import com.launchdarkly.eventsource.ConnectionErrorHandler; import com.launchdarkly.eventsource.EventHandler; import com.launchdarkly.eventsource.EventSource; @@ -17,11 +20,11 @@ import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicBoolean; +import static com.launchdarkly.client.DataModel.DataKinds.FEATURES; +import static com.launchdarkly.client.DataModel.DataKinds.SEGMENTS; 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; -import static com.launchdarkly.client.VersionedDataKind.SEGMENTS; import okhttp3.Headers; import okhttp3.OkHttpClient; @@ -120,20 +123,20 @@ public void onMessage(String name, MessageEvent event) throws Exception { } case PATCH: { PatchData data = gson.fromJson(event.getData(), PatchData.class); - if (FEATURES.getKeyFromStreamApiPath(data.path) != null) { - store.upsert(FEATURES, gson.fromJson(data.data, FlagModel.FeatureFlag.class)); - } else if (SEGMENTS.getKeyFromStreamApiPath(data.path) != null) { - store.upsert(SEGMENTS, gson.fromJson(data.data, FlagModel.Segment.class)); + if (getKeyFromStreamApiPath(FEATURES, data.path) != null) { + store.upsert(FEATURES, gson.fromJson(data.data, DataModel.FeatureFlag.class)); + } else if (getKeyFromStreamApiPath(SEGMENTS, data.path) != null) { + store.upsert(SEGMENTS, gson.fromJson(data.data, DataModel.Segment.class)); } break; } case DELETE: { DeleteData data = gson.fromJson(event.getData(), DeleteData.class); - String featureKey = FEATURES.getKeyFromStreamApiPath(data.path); + String featureKey = getKeyFromStreamApiPath(FEATURES, data.path); if (featureKey != null) { store.delete(FEATURES, featureKey, data.version); } else { - String segmentKey = SEGMENTS.getKeyFromStreamApiPath(data.path); + String segmentKey = getKeyFromStreamApiPath(SEGMENTS, data.path); if (segmentKey != null) { store.delete(SEGMENTS, segmentKey, data.version); } @@ -156,14 +159,14 @@ public void onMessage(String name, MessageEvent event) throws Exception { case INDIRECT_PATCH: String path = event.getData(); try { - String featureKey = FEATURES.getKeyFromStreamApiPath(path); + String featureKey = getKeyFromStreamApiPath(FEATURES, path); if (featureKey != null) { - FlagModel.FeatureFlag feature = requestor.getFlag(featureKey); + DataModel.FeatureFlag feature = requestor.getFlag(featureKey); store.upsert(FEATURES, feature); } else { - String segmentKey = SEGMENTS.getKeyFromStreamApiPath(path); + String segmentKey = getKeyFromStreamApiPath(SEGMENTS, path); if (segmentKey != null) { - FlagModel.Segment segment = requestor.getSegment(segmentKey); + DataModel.Segment segment = requestor.getSegment(segmentKey); store.upsert(SEGMENTS, segment); } } @@ -215,6 +218,10 @@ public boolean initialized() { return initialized.get(); } + private static String getKeyFromStreamApiPath(VersionedDataKind kind, String path) { + return path.startsWith(kind.getStreamApiPath()) ? path.substring(kind.getStreamApiPath().length()) : null; + } + private static final class PutData { FeatureRequestor.AllData data; diff --git a/src/main/java/com/launchdarkly/client/TestFeatureStore.java b/src/main/java/com/launchdarkly/client/TestFeatureStore.java index 1ffae3969..5fd0d55cd 100644 --- a/src/main/java/com/launchdarkly/client/TestFeatureStore.java +++ b/src/main/java/com/launchdarkly/client/TestFeatureStore.java @@ -1,15 +1,17 @@ package com.launchdarkly.client; -import static com.launchdarkly.client.VersionedDataKind.FEATURES; +import com.google.gson.JsonElement; +import com.google.gson.JsonPrimitive; +import com.launchdarkly.client.interfaces.VersionedData; +import com.launchdarkly.client.interfaces.VersionedDataKind; +import com.launchdarkly.client.value.LDValue; import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; -import com.google.gson.JsonElement; -import com.google.gson.JsonPrimitive; -import com.launchdarkly.client.value.LDValue; +import static com.launchdarkly.client.DataModel.DataKinds.FEATURES; /** * A decorated {@link InMemoryFeatureStore} which provides functionality to create (or override) true or false feature flags for all users. @@ -32,7 +34,7 @@ public class TestFeatureStore extends InMemoryFeatureStore { * @param value the new value of the feature flag * @return the feature flag */ - public FlagModel.FeatureFlag setBooleanValue(String key, Boolean value) { + public DataModel.FeatureFlag setBooleanValue(String key, Boolean value) { return setJsonValue(key, value == null ? null : new JsonPrimitive(value.booleanValue())); } @@ -43,7 +45,7 @@ public FlagModel.FeatureFlag setBooleanValue(String key, Boolean value) { * @param key the key of the feature flag to evaluate to true * @return the feature flag */ - public FlagModel.FeatureFlag setFeatureTrue(String key) { + public DataModel.FeatureFlag setFeatureTrue(String key) { return setBooleanValue(key, true); } @@ -54,7 +56,7 @@ public FlagModel.FeatureFlag setFeatureTrue(String key) { * @param key the key of the feature flag to evaluate to false * @return the feature flag */ - public FlagModel.FeatureFlag setFeatureFalse(String key) { + public DataModel.FeatureFlag setFeatureFalse(String key) { return setBooleanValue(key, false); } @@ -64,7 +66,7 @@ public FlagModel.FeatureFlag setFeatureFalse(String key) { * @param value the new value of the flag * @return the feature flag */ - public FlagModel.FeatureFlag setIntegerValue(String key, Integer value) { + public DataModel.FeatureFlag setIntegerValue(String key, Integer value) { return setJsonValue(key, new JsonPrimitive(value)); } @@ -74,7 +76,7 @@ public FlagModel.FeatureFlag setIntegerValue(String key, Integer value) { * @param value the new value of the flag * @return the feature flag */ - public FlagModel.FeatureFlag setDoubleValue(String key, Double value) { + public DataModel.FeatureFlag setDoubleValue(String key, Double value) { return setJsonValue(key, new JsonPrimitive(value)); } @@ -84,7 +86,7 @@ public FlagModel.FeatureFlag setDoubleValue(String key, Double value) { * @param value the new value of the flag * @return the feature flag */ - public FlagModel.FeatureFlag setStringValue(String key, String value) { + public DataModel.FeatureFlag setStringValue(String key, String value) { return setJsonValue(key, new JsonPrimitive(value)); } @@ -94,8 +96,8 @@ public FlagModel.FeatureFlag setStringValue(String key, String value) { * @param value the new value of the flag * @return the feature flag */ - public FlagModel.FeatureFlag setJsonValue(String key, JsonElement value) { - FlagModel.FeatureFlag newFeature = new FlagModel.FeatureFlag(key, + public DataModel.FeatureFlag setJsonValue(String key, JsonElement value) { + DataModel.FeatureFlag newFeature = new DataModel.FeatureFlag(key, version.incrementAndGet(), false, null, diff --git a/src/main/java/com/launchdarkly/client/VersionedDataKind.java b/src/main/java/com/launchdarkly/client/VersionedDataKind.java deleted file mode 100644 index e745818b4..000000000 --- a/src/main/java/com/launchdarkly/client/VersionedDataKind.java +++ /dev/null @@ -1,168 +0,0 @@ -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} - * or writing your own {@code FeatureStore} implementation. If you are implementing a custom store, for - * 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 { - - /** - * A short string that serves as the unique name for the collection of these objects, e.g. "features". - * @return a namespace string - */ - public abstract String getNamespace(); - - /** - * The Java class for objects of this type. - * @return a Java class - */ - public abstract Class getItemClass(); - - /** - * The path prefix for objects of this type in events received from the streaming API. - * @return the URL path - */ - public abstract String getStreamApiPath(); - - /** - * Return an instance of this type with the specified key and version, and deleted=true. - * @param key the unique key - * @param version the version number - * @return a new instance - */ - 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 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. - * - * @param item the item - * @return keys of dependencies of the item - * @since 4.7.0 - */ - public Iterable getDependencyKeys(VersionedData item) { - return ImmutableList.of(); - } - - @Override - public String toString() { - return "{" + getNamespace() + "}"; - } - - /** - * Used internally to match data URLs in the streaming API. - * @param path path from an API message - * @return the parsed key if the path refers to an object of this kind, otherwise null - */ - String getKeyFromStreamApiPath(String path) { - return path.startsWith(getStreamApiPath()) ? path.substring(getStreamApiPath().length()) : null; - } - - 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 namespace; - } - - public Class getItemClass() { - return itemClass; - } - - public String getStreamApiPath() { - return streamApiPath; - } - - public int getPriority() { - return priority; - } - } - - /** - * The {@link VersionedDataKind} instance that describes feature flag data. - */ - public static VersionedDataKind FEATURES = new Impl("features", FlagModel.FeatureFlag.class, "/flags/", 1) { - public FlagModel.FeatureFlag makeDeletedItem(String key, int version) { - return new FlagModel.FeatureFlag(key, version, false, null, null, null, null, null, null, null, false, false, false, null, true); - } - - public boolean isDependencyOrdered() { - return true; - } - - public Iterable getDependencyKeys(VersionedData item) { - FlagModel.FeatureFlag flag = (FlagModel.FeatureFlag)item; - if (flag.getPrerequisites() == null || flag.getPrerequisites().isEmpty()) { - return ImmutableList.of(); - } - return transform(flag.getPrerequisites(), new Function() { - public String apply(FlagModel.Prerequisite p) { - return p.getKey(); - } - }); - } - }; - - /** - * The {@link VersionedDataKind} instance that describes user segment data. - */ - public static VersionedDataKind SEGMENTS = new Impl("segments", FlagModel.Segment.class, "/segments/", 0) { - - public FlagModel.Segment makeDeletedItem(String key, int version) { - return new FlagModel.Segment(key, null, null, null, null, version, true); - } - }; - - /** - * A list of all existing instances of {@link VersionedDataKind}. - * @since 4.1.0 - */ - public static Iterable> ALL = ImmutableList.of(FEATURES, SEGMENTS); -} diff --git a/src/main/java/com/launchdarkly/client/files/DataBuilder.java b/src/main/java/com/launchdarkly/client/files/DataBuilder.java index e9bc580a9..9f483ed95 100644 --- a/src/main/java/com/launchdarkly/client/files/DataBuilder.java +++ b/src/main/java/com/launchdarkly/client/files/DataBuilder.java @@ -1,7 +1,7 @@ package com.launchdarkly.client.files; -import com.launchdarkly.client.VersionedData; -import com.launchdarkly.client.VersionedDataKind; +import com.launchdarkly.client.interfaces.VersionedData; +import com.launchdarkly.client.interfaces.VersionedDataKind; import java.util.HashMap; import java.util.Map; diff --git a/src/main/java/com/launchdarkly/client/files/DataLoader.java b/src/main/java/com/launchdarkly/client/files/DataLoader.java index 0b4ad431c..2c1603e06 100644 --- a/src/main/java/com/launchdarkly/client/files/DataLoader.java +++ b/src/main/java/com/launchdarkly/client/files/DataLoader.java @@ -1,7 +1,7 @@ package com.launchdarkly.client.files; import com.google.gson.JsonElement; -import com.launchdarkly.client.VersionedDataKind; +import com.launchdarkly.client.DataModel; import java.io.ByteArrayInputStream; import java.io.IOException; @@ -35,17 +35,17 @@ public void load(DataBuilder builder) throws DataLoaderException 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())); + builder.add(DataModel.DataKinds.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())); + builder.add(DataModel.DataKinds.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())); + builder.add(DataModel.DataKinds.SEGMENTS, FlagFactory.segmentFromJson(e.getValue())); } } } catch (DataLoaderException e) { diff --git a/src/main/java/com/launchdarkly/client/files/FileDataSource.java b/src/main/java/com/launchdarkly/client/files/FileDataSource.java index e040e7902..280257527 100644 --- a/src/main/java/com/launchdarkly/client/files/FileDataSource.java +++ b/src/main/java/com/launchdarkly/client/files/FileDataSource.java @@ -1,8 +1,8 @@ package com.launchdarkly.client.files; import com.google.common.util.concurrent.Futures; -import com.launchdarkly.client.FeatureStore; -import com.launchdarkly.client.UpdateProcessor; +import com.launchdarkly.client.interfaces.FeatureStore; +import com.launchdarkly.client.interfaces.UpdateProcessor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/src/main/java/com/launchdarkly/client/files/FileDataSourceFactory.java b/src/main/java/com/launchdarkly/client/files/FileDataSourceFactory.java index 216c56213..a8a2a55e7 100644 --- a/src/main/java/com/launchdarkly/client/files/FileDataSourceFactory.java +++ b/src/main/java/com/launchdarkly/client/files/FileDataSourceFactory.java @@ -1,9 +1,9 @@ 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 com.launchdarkly.client.interfaces.FeatureStore; +import com.launchdarkly.client.interfaces.UpdateProcessor; +import com.launchdarkly.client.interfaces.UpdateProcessorFactory; import java.nio.file.InvalidPathException; import java.nio.file.Path; diff --git a/src/main/java/com/launchdarkly/client/files/FlagFactory.java b/src/main/java/com/launchdarkly/client/files/FlagFactory.java index 19af56282..225f29c2c 100644 --- a/src/main/java/com/launchdarkly/client/files/FlagFactory.java +++ b/src/main/java/com/launchdarkly/client/files/FlagFactory.java @@ -1,11 +1,10 @@ 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; +import com.launchdarkly.client.DataModel; +import com.launchdarkly.client.interfaces.VersionedData; /** * Creates flag or segment objects from raw JSON. @@ -16,22 +15,19 @@ * 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)); + return DataModel.DataKinds.FEATURES.deserialize(jsonString); } public static VersionedData flagFromJson(JsonElement jsonTree) { - return gson.fromJson(jsonTree, VersionedDataKind.FEATURES.getItemClass()); + return flagFromJson(jsonTree.toString()); } /** * 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); + public static VersionedData flagWithValue(String key, JsonElement jsonValue) { JsonObject o = new JsonObject(); o.addProperty("key", key); o.addProperty("on", true); @@ -47,10 +43,10 @@ public static VersionedData flagWithValue(String key, JsonElement value) { } public static VersionedData segmentFromJson(String jsonString) { - return segmentFromJson(gson.fromJson(jsonString, JsonElement.class)); + return DataModel.DataKinds.SEGMENTS.deserialize(jsonString); } public static VersionedData segmentFromJson(JsonElement jsonTree) { - return gson.fromJson(jsonTree, VersionedDataKind.SEGMENTS.getItemClass()); + return segmentFromJson(jsonTree.toString()); } } diff --git a/src/main/java/com/launchdarkly/client/EventProcessor.java b/src/main/java/com/launchdarkly/client/interfaces/EventProcessor.java similarity index 92% rename from src/main/java/com/launchdarkly/client/EventProcessor.java rename to src/main/java/com/launchdarkly/client/interfaces/EventProcessor.java index fd75598ef..d4ef04ad6 100644 --- a/src/main/java/com/launchdarkly/client/EventProcessor.java +++ b/src/main/java/com/launchdarkly/client/interfaces/EventProcessor.java @@ -1,4 +1,6 @@ -package com.launchdarkly.client; +package com.launchdarkly.client.interfaces; + +import com.launchdarkly.client.Event; import java.io.Closeable; diff --git a/src/main/java/com/launchdarkly/client/EventProcessorFactory.java b/src/main/java/com/launchdarkly/client/interfaces/EventProcessorFactory.java similarity index 77% rename from src/main/java/com/launchdarkly/client/EventProcessorFactory.java rename to src/main/java/com/launchdarkly/client/interfaces/EventProcessorFactory.java index 3d76b5aad..147460fad 100644 --- a/src/main/java/com/launchdarkly/client/EventProcessorFactory.java +++ b/src/main/java/com/launchdarkly/client/interfaces/EventProcessorFactory.java @@ -1,4 +1,7 @@ -package com.launchdarkly.client; +package com.launchdarkly.client.interfaces; + +import com.launchdarkly.client.Components; +import com.launchdarkly.client.LDConfig; /** * Interface for a factory that creates some implementation of {@link EventProcessor}. diff --git a/src/main/java/com/launchdarkly/client/FeatureStore.java b/src/main/java/com/launchdarkly/client/interfaces/FeatureStore.java similarity index 98% rename from src/main/java/com/launchdarkly/client/FeatureStore.java rename to src/main/java/com/launchdarkly/client/interfaces/FeatureStore.java index 0ea551299..59b776245 100644 --- a/src/main/java/com/launchdarkly/client/FeatureStore.java +++ b/src/main/java/com/launchdarkly/client/interfaces/FeatureStore.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client; +package com.launchdarkly.client.interfaces; import java.io.Closeable; import java.util.Map; diff --git a/src/main/java/com/launchdarkly/client/FeatureStoreFactory.java b/src/main/java/com/launchdarkly/client/interfaces/FeatureStoreFactory.java similarity index 77% rename from src/main/java/com/launchdarkly/client/FeatureStoreFactory.java rename to src/main/java/com/launchdarkly/client/interfaces/FeatureStoreFactory.java index c019de9c9..4e7371fc8 100644 --- a/src/main/java/com/launchdarkly/client/FeatureStoreFactory.java +++ b/src/main/java/com/launchdarkly/client/interfaces/FeatureStoreFactory.java @@ -1,4 +1,6 @@ -package com.launchdarkly.client; +package com.launchdarkly.client.interfaces; + +import com.launchdarkly.client.Components; /** * Interface for a factory that creates some implementation of {@link FeatureStore}. diff --git a/src/main/java/com/launchdarkly/client/UpdateProcessor.java b/src/main/java/com/launchdarkly/client/interfaces/UpdateProcessor.java similarity index 94% rename from src/main/java/com/launchdarkly/client/UpdateProcessor.java rename to src/main/java/com/launchdarkly/client/interfaces/UpdateProcessor.java index 15cc7231e..94cc15576 100644 --- a/src/main/java/com/launchdarkly/client/UpdateProcessor.java +++ b/src/main/java/com/launchdarkly/client/interfaces/UpdateProcessor.java @@ -1,4 +1,6 @@ -package com.launchdarkly.client; +package com.launchdarkly.client.interfaces; + +import com.launchdarkly.client.Components; import java.io.Closeable; import java.io.IOException; diff --git a/src/main/java/com/launchdarkly/client/UpdateProcessorFactory.java b/src/main/java/com/launchdarkly/client/interfaces/UpdateProcessorFactory.java similarity index 81% rename from src/main/java/com/launchdarkly/client/UpdateProcessorFactory.java rename to src/main/java/com/launchdarkly/client/interfaces/UpdateProcessorFactory.java index 1b3a73e8d..1647285af 100644 --- a/src/main/java/com/launchdarkly/client/UpdateProcessorFactory.java +++ b/src/main/java/com/launchdarkly/client/interfaces/UpdateProcessorFactory.java @@ -1,4 +1,7 @@ -package com.launchdarkly.client; +package com.launchdarkly.client.interfaces; + +import com.launchdarkly.client.Components; +import com.launchdarkly.client.LDConfig; /** * Interface for a factory that creates some implementation of {@link UpdateProcessor}. diff --git a/src/main/java/com/launchdarkly/client/VersionedData.java b/src/main/java/com/launchdarkly/client/interfaces/VersionedData.java similarity index 92% rename from src/main/java/com/launchdarkly/client/VersionedData.java rename to src/main/java/com/launchdarkly/client/interfaces/VersionedData.java index 98bd19c34..a27a96429 100644 --- a/src/main/java/com/launchdarkly/client/VersionedData.java +++ b/src/main/java/com/launchdarkly/client/interfaces/VersionedData.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client; +package com.launchdarkly.client.interfaces; /** * Common interface for string-keyed, versioned objects that can be kept in a {@link FeatureStore}. diff --git a/src/main/java/com/launchdarkly/client/interfaces/VersionedDataKind.java b/src/main/java/com/launchdarkly/client/interfaces/VersionedDataKind.java new file mode 100644 index 000000000..19b8220b9 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/interfaces/VersionedDataKind.java @@ -0,0 +1,87 @@ +package com.launchdarkly.client.interfaces; + +import com.google.common.collect.ImmutableList; + +/** + * 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} + * or writing your own {@code FeatureStore} implementation. If you are implementing a custom store, for + * 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 { + + /** + * A short string that serves as the unique name for the collection of these objects, e.g. "features". + * @return a namespace string + */ + public abstract String getNamespace(); + + /** + * The Java class for objects of this type. + * @return a Java class + */ + public abstract Class getItemClass(); + + /** + * The path prefix for objects of this type in events received from the streaming API. + * @return the URL path + */ + public abstract String getStreamApiPath(); + + /** + * Return an instance of this type with the specified key and version, and deleted=true. + * @param key the unique key + * @param version the version number + * @return a new instance + */ + public abstract T makeDeletedItem(String key, int version); + + public abstract T deserialize(String serializedData); + + /** + * 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 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. + * + * @param item the item + * @return keys of dependencies of the item + * @since 4.7.0 + */ + public Iterable getDependencyKeys(VersionedData item) { + return ImmutableList.of(); + } + + @Override + public String toString() { + return "{" + getNamespace() + "}"; + } +} 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; diff --git a/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java b/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java index e2e5fa144..3f4ccbdea 100644 --- a/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java +++ b/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java @@ -8,10 +8,10 @@ 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.FeatureStoreCacheConfig; -import com.launchdarkly.client.VersionedData; -import com.launchdarkly.client.VersionedDataKind; +import com.launchdarkly.client.interfaces.FeatureStore; +import com.launchdarkly.client.interfaces.VersionedData; +import com.launchdarkly.client.interfaces.VersionedDataKind; import java.io.IOException; import java.util.HashMap; diff --git a/src/main/java/com/launchdarkly/client/utils/FeatureStoreCore.java b/src/main/java/com/launchdarkly/client/utils/FeatureStoreCore.java index b4d2e3066..76799790c 100644 --- a/src/main/java/com/launchdarkly/client/utils/FeatureStoreCore.java +++ b/src/main/java/com/launchdarkly/client/utils/FeatureStoreCore.java @@ -1,8 +1,8 @@ package com.launchdarkly.client.utils; -import com.launchdarkly.client.FeatureStore; -import com.launchdarkly.client.VersionedData; -import com.launchdarkly.client.VersionedDataKind; +import com.launchdarkly.client.interfaces.FeatureStore; +import com.launchdarkly.client.interfaces.VersionedData; +import com.launchdarkly.client.interfaces.VersionedDataKind; import java.io.Closeable; import java.util.Map; diff --git a/src/main/java/com/launchdarkly/client/utils/FeatureStoreHelpers.java b/src/main/java/com/launchdarkly/client/utils/FeatureStoreHelpers.java index 6fefb8e62..3a5247254 100644 --- a/src/main/java/com/launchdarkly/client/utils/FeatureStoreHelpers.java +++ b/src/main/java/com/launchdarkly/client/utils/FeatureStoreHelpers.java @@ -2,9 +2,9 @@ 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; +import com.launchdarkly.client.interfaces.FeatureStore; +import com.launchdarkly.client.interfaces.VersionedData; +import com.launchdarkly.client.interfaces.VersionedDataKind; /** * Helper methods that may be useful for implementing a {@link FeatureStore} or {@link FeatureStoreCore}. diff --git a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java index a38850ed4..40ac22bf5 100644 --- a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java @@ -81,7 +81,7 @@ public void userIsFilteredInIdentifyEvent() throws Exception { @SuppressWarnings("unchecked") @Test public void individualFeatureEventIsQueuedWithIndexEvent() throws Exception { - FlagModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); @@ -101,7 +101,7 @@ public void individualFeatureEventIsQueuedWithIndexEvent() throws Exception { @SuppressWarnings("unchecked") @Test public void userIsFilteredInIndexEvent() throws Exception { - FlagModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); @@ -123,7 +123,7 @@ public void userIsFilteredInIndexEvent() throws Exception { @SuppressWarnings("unchecked") @Test public void featureEventCanContainInlineUser() throws Exception { - FlagModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); @@ -144,7 +144,7 @@ public void featureEventCanContainInlineUser() throws Exception { @SuppressWarnings("unchecked") @Test public void userIsFilteredInFeatureEvent() throws Exception { - FlagModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); @@ -165,7 +165,7 @@ public void userIsFilteredInFeatureEvent() throws Exception { @SuppressWarnings("unchecked") @Test public void featureEventCanContainReason() throws Exception { - FlagModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); EvaluationReason reason = EvaluationReason.ruleMatch(1, null); Event.FeatureRequest fe = EventFactory.DEFAULT_WITH_REASONS.newFeatureRequestEvent(flag, user, new Evaluator.EvalResult(LDValue.of("value"), 1, reason), LDValue.ofNull()); @@ -186,7 +186,7 @@ public void featureEventCanContainReason() throws Exception { @SuppressWarnings("unchecked") @Test public void indexEventIsStillGeneratedIfInlineUsersIsTrueButFeatureEventIsNotTracked() throws Exception { - FlagModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(false).build(); + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(false).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), null); @@ -208,7 +208,7 @@ public void indexEventIsStillGeneratedIfInlineUsersIsTrueButFeatureEventIsNotTra @Test public void eventKindIsDebugIfFlagIsTemporarilyInDebugMode() throws Exception { long futureTime = System.currentTimeMillis() + 1000000; - FlagModel.FeatureFlag flag = flagBuilder("flagkey").version(11).debugEventsUntilDate(futureTime).build(); + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).debugEventsUntilDate(futureTime).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); @@ -229,7 +229,7 @@ public void eventKindIsDebugIfFlagIsTemporarilyInDebugMode() throws Exception { @Test public void eventCanBeBothTrackedAndDebugged() throws Exception { long futureTime = System.currentTimeMillis() + 1000000; - FlagModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true) + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true) .debugEventsUntilDate(futureTime).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); @@ -257,7 +257,7 @@ public void debugModeExpiresBasedOnClientTimeIfClientTimeIsLaterThanServerTime() MockResponse resp2 = eventsSuccessResponse(); long debugUntil = serverTime + 1000; - FlagModel.FeatureFlag flag = flagBuilder("flagkey").version(11).debugEventsUntilDate(debugUntil).build(); + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).debugEventsUntilDate(debugUntil).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); @@ -290,7 +290,7 @@ public void debugModeExpiresBasedOnServerTimeIfServerTimeIsLaterThanClientTime() MockResponse resp2 = eventsSuccessResponse(); long debugUntil = serverTime - 1000; - FlagModel.FeatureFlag flag = flagBuilder("flagkey").version(11).debugEventsUntilDate(debugUntil).build(); + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).debugEventsUntilDate(debugUntil).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); @@ -319,8 +319,8 @@ public void debugModeExpiresBasedOnServerTimeIfServerTimeIsLaterThanClientTime() @SuppressWarnings("unchecked") @Test public void twoFeatureEventsForSameUserGenerateOnlyOneIndexEvent() throws Exception { - FlagModel.FeatureFlag flag1 = flagBuilder("flagkey1").version(11).trackEvents(true).build(); - FlagModel.FeatureFlag flag2 = flagBuilder("flagkey2").version(22).trackEvents(true).build(); + DataModel.FeatureFlag flag1 = flagBuilder("flagkey1").version(11).trackEvents(true).build(); + DataModel.FeatureFlag flag2 = flagBuilder("flagkey2").version(22).trackEvents(true).build(); LDValue value = LDValue.of("value"); Event.FeatureRequest fe1 = EventFactory.DEFAULT.newFeatureRequestEvent(flag1, user, simpleEvaluation(1, value), LDValue.ofNull()); @@ -345,8 +345,8 @@ public void twoFeatureEventsForSameUserGenerateOnlyOneIndexEvent() throws Except @SuppressWarnings("unchecked") @Test public void nonTrackedEventsAreSummarized() throws Exception { - FlagModel.FeatureFlag flag1 = flagBuilder("flagkey1").version(11).build(); - FlagModel.FeatureFlag flag2 = flagBuilder("flagkey2").version(22).build(); + DataModel.FeatureFlag flag1 = flagBuilder("flagkey1").version(11).build(); + DataModel.FeatureFlag flag2 = flagBuilder("flagkey2").version(22).build(); LDValue value1 = LDValue.of("value1"); LDValue value2 = LDValue.of("value2"); LDValue default1 = LDValue.of("default1"); @@ -630,12 +630,12 @@ private Matcher isIndexEvent(Event sourceEvent, JsonElement user) { ); } - private Matcher isFeatureEvent(Event.FeatureRequest sourceEvent, FlagModel.FeatureFlag flag, boolean debug, JsonElement inlineUser) { + private Matcher isFeatureEvent(Event.FeatureRequest sourceEvent, DataModel.FeatureFlag flag, boolean debug, JsonElement inlineUser) { return isFeatureEvent(sourceEvent, flag, debug, inlineUser, null); } @SuppressWarnings("unchecked") - private Matcher isFeatureEvent(Event.FeatureRequest sourceEvent, FlagModel.FeatureFlag flag, boolean debug, JsonElement inlineUser, + private Matcher isFeatureEvent(Event.FeatureRequest sourceEvent, DataModel.FeatureFlag flag, boolean debug, JsonElement inlineUser, EvaluationReason reason) { return allOf( hasJsonProperty("kind", debug ? "debug" : "feature"), @@ -689,7 +689,7 @@ private Matcher hasSummaryFlag(String key, LDValue defaultVal, Matc ))); } - private Matcher isSummaryEventCounter(FlagModel.FeatureFlag flag, Integer variation, LDValue value, int count) { + private Matcher isSummaryEventCounter(DataModel.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/EvaluatorClauseTest.java b/src/test/java/com/launchdarkly/client/EvaluatorClauseTest.java index d8d70eb17..1f8798eb0 100644 --- a/src/test/java/com/launchdarkly/client/EvaluatorClauseTest.java +++ b/src/test/java/com/launchdarkly/client/EvaluatorClauseTest.java @@ -22,8 +22,8 @@ public class EvaluatorClauseTest { @Test public void clauseCanMatchBuiltInAttribute() throws Exception { - FlagModel.Clause clause = clause("name", Operator.in, LDValue.of("Bob")); - FlagModel.FeatureFlag f = booleanFlagWithClauses("flag", clause); + DataModel.Clause clause = clause("name", Operator.in, LDValue.of("Bob")); + DataModel.FeatureFlag f = booleanFlagWithClauses("flag", clause); LDUser user = new LDUser.Builder("key").name("Bob").build(); assertEquals(LDValue.of(true), BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT).getDetails().getValue()); @@ -31,8 +31,8 @@ public void clauseCanMatchBuiltInAttribute() throws Exception { @Test public void clauseCanMatchCustomAttribute() throws Exception { - FlagModel.Clause clause = clause("legs", Operator.in, LDValue.of(4)); - FlagModel.FeatureFlag f = booleanFlagWithClauses("flag", clause); + DataModel.Clause clause = clause("legs", Operator.in, LDValue.of(4)); + DataModel.FeatureFlag f = booleanFlagWithClauses("flag", clause); LDUser user = new LDUser.Builder("key").custom("legs", 4).build(); assertEquals(LDValue.of(true), BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT).getDetails().getValue()); @@ -40,8 +40,8 @@ public void clauseCanMatchCustomAttribute() throws Exception { @Test public void clauseReturnsFalseForMissingAttribute() throws Exception { - FlagModel.Clause clause = clause("legs", Operator.in, LDValue.of(4)); - FlagModel.FeatureFlag f = booleanFlagWithClauses("flag", clause); + DataModel.Clause clause = clause("legs", Operator.in, LDValue.of(4)); + DataModel.FeatureFlag f = booleanFlagWithClauses("flag", clause); LDUser user = new LDUser.Builder("key").name("Bob").build(); assertEquals(LDValue.of(false), BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT).getDetails().getValue()); @@ -49,8 +49,8 @@ public void clauseReturnsFalseForMissingAttribute() throws Exception { @Test public void clauseCanBeNegated() throws Exception { - FlagModel.Clause clause = clause("name", Operator.in, true, LDValue.of("Bob")); - FlagModel.FeatureFlag f = booleanFlagWithClauses("flag", clause); + DataModel.Clause clause = clause("name", Operator.in, true, LDValue.of("Bob")); + DataModel.FeatureFlag f = booleanFlagWithClauses("flag", clause); LDUser user = new LDUser.Builder("key").name("Bob").build(); assertEquals(LDValue.of(false), BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT).getDetails().getValue()); @@ -63,7 +63,7 @@ public void clauseWithUnsupportedOperatorStringIsUnmarshalledWithNullOperator() // and the SDK hasn't been upgraded yet. String badClauseJson = "{\"attribute\":\"name\",\"operator\":\"doesSomethingUnsupported\",\"values\":[\"x\"]}"; Gson gson = new Gson(); - FlagModel.Clause clause = gson.fromJson(badClauseJson, FlagModel.Clause.class); + DataModel.Clause clause = gson.fromJson(badClauseJson, DataModel.Clause.class); assertNotNull(clause); JsonElement json = gson.toJsonTree(clause); @@ -73,8 +73,8 @@ public void clauseWithUnsupportedOperatorStringIsUnmarshalledWithNullOperator() @Test public void clauseWithNullOperatorDoesNotMatch() throws Exception { - FlagModel.Clause badClause = clause("name", null, LDValue.of("Bob")); - FlagModel.FeatureFlag f = booleanFlagWithClauses("flag", badClause); + DataModel.Clause badClause = clause("name", null, LDValue.of("Bob")); + DataModel.FeatureFlag f = booleanFlagWithClauses("flag", badClause); LDUser user = new LDUser.Builder("key").name("Bob").build(); assertEquals(LDValue.of(false), BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT).getDetails().getValue()); @@ -82,11 +82,11 @@ public void clauseWithNullOperatorDoesNotMatch() throws Exception { @Test public void clauseWithNullOperatorDoesNotStopSubsequentRuleFromMatching() throws Exception { - FlagModel.Clause badClause = clause("name", null, LDValue.of("Bob")); - FlagModel.Rule badRule = ruleBuilder().id("rule1").clauses(badClause).variation(1).build(); - FlagModel.Clause goodClause = clause("name", Operator.in, LDValue.of("Bob")); - FlagModel.Rule goodRule = ruleBuilder().id("rule2").clauses(goodClause).variation(1).build(); - FlagModel.FeatureFlag f = flagBuilder("feature") + DataModel.Clause badClause = clause("name", null, LDValue.of("Bob")); + DataModel.Rule badRule = ruleBuilder().id("rule1").clauses(badClause).variation(1).build(); + DataModel.Clause goodClause = clause("name", Operator.in, LDValue.of("Bob")); + DataModel.Rule goodRule = ruleBuilder().id("rule2").clauses(goodClause).variation(1).build(); + DataModel.FeatureFlag f = flagBuilder("feature") .on(true) .rules(badRule, goodRule) .fallthrough(fallthroughVariation(0)) @@ -101,13 +101,13 @@ public void clauseWithNullOperatorDoesNotStopSubsequentRuleFromMatching() throws @Test public void testSegmentMatchClauseRetrievesSegmentFromStore() throws Exception { - FlagModel.Segment segment = segmentBuilder("segkey") + DataModel.Segment segment = segmentBuilder("segkey") .included("foo") .version(1) .build(); Evaluator e = evaluatorBuilder().withStoredSegments(segment).build(); - FlagModel.FeatureFlag flag = segmentMatchBooleanFlag("segkey"); + DataModel.FeatureFlag flag = segmentMatchBooleanFlag("segkey"); LDUser user = new LDUser.Builder("foo").build(); Evaluator.EvalResult result = e.evaluate(flag, user, EventFactory.DEFAULT); @@ -116,7 +116,7 @@ public void testSegmentMatchClauseRetrievesSegmentFromStore() throws Exception { @Test public void testSegmentMatchClauseFallsThroughIfSegmentNotFound() throws Exception { - FlagModel.FeatureFlag flag = segmentMatchBooleanFlag("segkey"); + DataModel.FeatureFlag flag = segmentMatchBooleanFlag("segkey"); LDUser user = new LDUser.Builder("foo").build(); Evaluator e = evaluatorBuilder().withNonexistentSegment("segkey").build(); @@ -124,8 +124,8 @@ public void testSegmentMatchClauseFallsThroughIfSegmentNotFound() throws Excepti assertEquals(LDValue.of(false), result.getDetails().getValue()); } - private FlagModel.FeatureFlag segmentMatchBooleanFlag(String segmentKey) { - FlagModel.Clause clause = clause("", Operator.segmentMatch, LDValue.of(segmentKey)); + private DataModel.FeatureFlag segmentMatchBooleanFlag(String segmentKey) { + DataModel.Clause clause = clause("", Operator.segmentMatch, LDValue.of(segmentKey)); return booleanFlagWithClauses("flag", clause); } } diff --git a/src/test/java/com/launchdarkly/client/EvaluatorRuleTest.java b/src/test/java/com/launchdarkly/client/EvaluatorRuleTest.java index e9372ee45..73b727b90 100644 --- a/src/test/java/com/launchdarkly/client/EvaluatorRuleTest.java +++ b/src/test/java/com/launchdarkly/client/EvaluatorRuleTest.java @@ -19,11 +19,11 @@ public class EvaluatorRuleTest { @Test public void ruleMatchReasonInstanceIsReusedForSameRule() { - FlagModel.Clause clause0 = clause("key", Operator.in, LDValue.of("wrongkey")); - FlagModel.Clause clause1 = clause("key", Operator.in, LDValue.of("userkey")); - FlagModel.Rule rule0 = ruleBuilder().id("ruleid0").clauses(clause0).variation(2).build(); - FlagModel.Rule rule1 = ruleBuilder().id("ruleid1").clauses(clause1).variation(2).build(); - FlagModel.FeatureFlag f = featureFlagWithRules("feature", rule0, rule1); + DataModel.Clause clause0 = clause("key", Operator.in, LDValue.of("wrongkey")); + DataModel.Clause clause1 = clause("key", Operator.in, LDValue.of("userkey")); + DataModel.Rule rule0 = ruleBuilder().id("ruleid0").clauses(clause0).variation(2).build(); + DataModel.Rule rule1 = ruleBuilder().id("ruleid1").clauses(clause1).variation(2).build(); + DataModel.FeatureFlag f = featureFlagWithRules("feature", rule0, rule1); LDUser user = new LDUser.Builder("userkey").build(); LDUser otherUser = new LDUser.Builder("wrongkey").build(); @@ -39,9 +39,9 @@ public void ruleMatchReasonInstanceIsReusedForSameRule() { @Test public void ruleWithTooHighVariationReturnsMalformedFlagError() { - FlagModel.Clause clause = clause("key", Operator.in, LDValue.of("userkey")); - FlagModel.Rule rule = ruleBuilder().id("ruleid").clauses(clause).variation(999).build(); - FlagModel.FeatureFlag f = featureFlagWithRules("feature", rule); + DataModel.Clause clause = clause("key", Operator.in, LDValue.of("userkey")); + DataModel.Rule rule = ruleBuilder().id("ruleid").clauses(clause).variation(999).build(); + DataModel.FeatureFlag f = featureFlagWithRules("feature", rule); LDUser user = new LDUser.Builder("userkey").build(); Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); @@ -51,9 +51,9 @@ public void ruleWithTooHighVariationReturnsMalformedFlagError() { @Test public void ruleWithNegativeVariationReturnsMalformedFlagError() { - FlagModel.Clause clause = clause("key", Operator.in, LDValue.of("userkey")); - FlagModel.Rule rule = ruleBuilder().id("ruleid").clauses(clause).variation(-1).build(); - FlagModel.FeatureFlag f = featureFlagWithRules("feature", rule); + DataModel.Clause clause = clause("key", Operator.in, LDValue.of("userkey")); + DataModel.Rule rule = ruleBuilder().id("ruleid").clauses(clause).variation(-1).build(); + DataModel.FeatureFlag f = featureFlagWithRules("feature", rule); LDUser user = new LDUser.Builder("userkey").build(); Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); @@ -63,9 +63,9 @@ public void ruleWithNegativeVariationReturnsMalformedFlagError() { @Test public void ruleWithNoVariationOrRolloutReturnsMalformedFlagError() { - FlagModel.Clause clause = clause("key", Operator.in, LDValue.of("userkey")); - FlagModel.Rule rule = ruleBuilder().id("ruleid").clauses(clause).build(); - FlagModel.FeatureFlag f = featureFlagWithRules("feature", rule); + DataModel.Clause clause = clause("key", Operator.in, LDValue.of("userkey")); + DataModel.Rule rule = ruleBuilder().id("ruleid").clauses(clause).build(); + DataModel.FeatureFlag f = featureFlagWithRules("feature", rule); LDUser user = new LDUser.Builder("userkey").build(); Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); @@ -75,9 +75,9 @@ public void ruleWithNoVariationOrRolloutReturnsMalformedFlagError() { @Test public void ruleWithRolloutWithEmptyVariationsListReturnsMalformedFlagError() { - FlagModel.Clause clause = clause("key", Operator.in, LDValue.of("userkey")); - FlagModel.Rule rule = ruleBuilder().id("ruleid").clauses(clause).rollout(emptyRollout()).build(); - FlagModel.FeatureFlag f = featureFlagWithRules("feature", rule); + DataModel.Clause clause = clause("key", Operator.in, LDValue.of("userkey")); + DataModel.Rule rule = ruleBuilder().id("ruleid").clauses(clause).rollout(emptyRollout()).build(); + DataModel.FeatureFlag f = featureFlagWithRules("feature", rule); LDUser user = new LDUser.Builder("userkey").build(); Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); @@ -85,7 +85,7 @@ public void ruleWithRolloutWithEmptyVariationsListReturnsMalformedFlagError() { assertThat(result.getPrerequisiteEvents(), emptyIterable()); } - private FlagModel.FeatureFlag featureFlagWithRules(String flagKey, FlagModel.Rule... rules) { + private DataModel.FeatureFlag featureFlagWithRules(String flagKey, DataModel.Rule... rules) { return flagBuilder(flagKey) .on(true) .rules(rules) diff --git a/src/test/java/com/launchdarkly/client/EvaluatorSegmentMatchTest.java b/src/test/java/com/launchdarkly/client/EvaluatorSegmentMatchTest.java index 84f8f7d9c..673d8b715 100644 --- a/src/test/java/com/launchdarkly/client/EvaluatorSegmentMatchTest.java +++ b/src/test/java/com/launchdarkly/client/EvaluatorSegmentMatchTest.java @@ -19,7 +19,7 @@ public class EvaluatorSegmentMatchTest { @Test public void explicitIncludeUser() { - FlagModel.Segment s = segmentBuilder("test") + DataModel.Segment s = segmentBuilder("test") .included("foo") .salt("abcdef") .version(1) @@ -31,7 +31,7 @@ public void explicitIncludeUser() { @Test public void explicitExcludeUser() { - FlagModel.Segment s = segmentBuilder("test") + DataModel.Segment s = segmentBuilder("test") .excluded("foo") .salt("abcdef") .version(1) @@ -43,7 +43,7 @@ public void explicitExcludeUser() { @Test public void explicitIncludeHasPrecedence() { - FlagModel.Segment s = segmentBuilder("test") + DataModel.Segment s = segmentBuilder("test") .included("foo") .excluded("foo") .salt("abcdef") @@ -56,9 +56,9 @@ public void explicitIncludeHasPrecedence() { @Test public void matchingRuleWithFullRollout() { - FlagModel.Clause clause = clause("email", Operator.in, LDValue.of("test@example.com")); - FlagModel.SegmentRule rule = segmentRuleBuilder().clauses(clause).weight(maxWeight).build(); - FlagModel.Segment s = segmentBuilder("test") + DataModel.Clause clause = clause("email", Operator.in, LDValue.of("test@example.com")); + DataModel.SegmentRule rule = segmentRuleBuilder().clauses(clause).weight(maxWeight).build(); + DataModel.Segment s = segmentBuilder("test") .salt("abcdef") .rules(rule) .build(); @@ -69,9 +69,9 @@ public void matchingRuleWithFullRollout() { @Test public void matchingRuleWithZeroRollout() { - FlagModel.Clause clause = clause("email", Operator.in, LDValue.of("test@example.com")); - FlagModel.SegmentRule rule = segmentRuleBuilder().clauses(clause).weight(0).build(); - FlagModel.Segment s = segmentBuilder("test") + DataModel.Clause clause = clause("email", Operator.in, LDValue.of("test@example.com")); + DataModel.SegmentRule rule = segmentRuleBuilder().clauses(clause).weight(0).build(); + DataModel.Segment s = segmentBuilder("test") .salt("abcdef") .rules(rule) .build(); @@ -82,10 +82,10 @@ public void matchingRuleWithZeroRollout() { @Test public void matchingRuleWithMultipleClauses() { - FlagModel.Clause clause1 = clause("email", Operator.in, LDValue.of("test@example.com")); - FlagModel.Clause clause2 = clause("name", Operator.in, LDValue.of("bob")); - FlagModel.SegmentRule rule = segmentRuleBuilder().clauses(clause1, clause2).build(); - FlagModel.Segment s = segmentBuilder("test") + DataModel.Clause clause1 = clause("email", Operator.in, LDValue.of("test@example.com")); + DataModel.Clause clause2 = clause("name", Operator.in, LDValue.of("bob")); + DataModel.SegmentRule rule = segmentRuleBuilder().clauses(clause1, clause2).build(); + DataModel.Segment s = segmentBuilder("test") .salt("abcdef") .rules(rule) .build(); @@ -96,10 +96,10 @@ public void matchingRuleWithMultipleClauses() { @Test public void nonMatchingRuleWithMultipleClauses() { - FlagModel.Clause clause1 = clause("email", Operator.in, LDValue.of("test@example.com")); - FlagModel.Clause clause2 = clause("name", Operator.in, LDValue.of("bill")); - FlagModel.SegmentRule rule = segmentRuleBuilder().clauses(clause1, clause2).build(); - FlagModel.Segment s = segmentBuilder("test") + DataModel.Clause clause1 = clause("email", Operator.in, LDValue.of("test@example.com")); + DataModel.Clause clause2 = clause("name", Operator.in, LDValue.of("bill")); + DataModel.SegmentRule rule = segmentRuleBuilder().clauses(clause1, clause2).build(); + DataModel.Segment s = segmentBuilder("test") .salt("abcdef") .rules(rule) .build(); @@ -108,9 +108,9 @@ public void nonMatchingRuleWithMultipleClauses() { assertFalse(segmentMatchesUser(s, u)); } - private static boolean segmentMatchesUser(FlagModel.Segment segment, LDUser user) { - FlagModel.Clause clause = clause("", Operator.segmentMatch, LDValue.of(segment.getKey())); - FlagModel.FeatureFlag flag = booleanFlagWithClauses("flag", clause); + private static boolean segmentMatchesUser(DataModel.Segment segment, LDUser user) { + DataModel.Clause clause = clause("", Operator.segmentMatch, LDValue.of(segment.getKey())); + DataModel.FeatureFlag flag = booleanFlagWithClauses("flag", clause); Evaluator e = evaluatorBuilder().withStoredSegments(segment).build(); return e.evaluate(flag, user, EventFactory.DEFAULT).getValue().booleanValue(); } diff --git a/src/test/java/com/launchdarkly/client/EvaluatorTest.java b/src/test/java/com/launchdarkly/client/EvaluatorTest.java index 366127e6d..0ef936e89 100644 --- a/src/test/java/com/launchdarkly/client/EvaluatorTest.java +++ b/src/test/java/com/launchdarkly/client/EvaluatorTest.java @@ -26,7 +26,7 @@ public class EvaluatorTest { @Test public void flagReturnsOffVariationIfFlagIsOff() throws Exception { - FlagModel.FeatureFlag f = flagBuilder("feature") + DataModel.FeatureFlag f = flagBuilder("feature") .on(false) .offVariation(1) .fallthrough(fallthroughVariation(0)) @@ -40,7 +40,7 @@ public void flagReturnsOffVariationIfFlagIsOff() throws Exception { @Test public void flagReturnsNullIfFlagIsOffAndOffVariationIsUnspecified() throws Exception { - FlagModel.FeatureFlag f = flagBuilder("feature") + DataModel.FeatureFlag f = flagBuilder("feature") .on(false) .fallthrough(fallthroughVariation(0)) .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) @@ -53,7 +53,7 @@ public void flagReturnsNullIfFlagIsOffAndOffVariationIsUnspecified() throws Exce @Test public void flagReturnsErrorIfFlagIsOffAndOffVariationIsTooHigh() throws Exception { - FlagModel.FeatureFlag f = flagBuilder("feature") + DataModel.FeatureFlag f = flagBuilder("feature") .on(false) .offVariation(999) .fallthrough(fallthroughVariation(0)) @@ -67,7 +67,7 @@ public void flagReturnsErrorIfFlagIsOffAndOffVariationIsTooHigh() throws Excepti @Test public void flagReturnsErrorIfFlagIsOffAndOffVariationIsNegative() throws Exception { - FlagModel.FeatureFlag f = flagBuilder("feature") + DataModel.FeatureFlag f = flagBuilder("feature") .on(false) .offVariation(-1) .fallthrough(fallthroughVariation(0)) @@ -81,7 +81,7 @@ public void flagReturnsErrorIfFlagIsOffAndOffVariationIsNegative() throws Except @Test public void flagReturnsFallthroughIfFlagIsOnAndThereAreNoRules() throws Exception { - FlagModel.FeatureFlag f = flagBuilder("feature") + DataModel.FeatureFlag f = flagBuilder("feature") .on(true) .offVariation(1) .fallthrough(fallthroughVariation(0)) @@ -95,7 +95,7 @@ public void flagReturnsFallthroughIfFlagIsOnAndThereAreNoRules() throws Exceptio @Test public void flagReturnsErrorIfFallthroughHasTooHighVariation() throws Exception { - FlagModel.FeatureFlag f = flagBuilder("feature") + DataModel.FeatureFlag f = flagBuilder("feature") .on(true) .offVariation(1) .fallthrough(fallthroughVariation(999)) @@ -109,7 +109,7 @@ public void flagReturnsErrorIfFallthroughHasTooHighVariation() throws Exception @Test public void flagReturnsErrorIfFallthroughHasNegativeVariation() throws Exception { - FlagModel.FeatureFlag f = flagBuilder("feature") + DataModel.FeatureFlag f = flagBuilder("feature") .on(true) .offVariation(1) .fallthrough(fallthroughVariation(-1)) @@ -123,10 +123,10 @@ public void flagReturnsErrorIfFallthroughHasNegativeVariation() throws Exception @Test public void flagReturnsErrorIfFallthroughHasNeitherVariationNorRollout() throws Exception { - FlagModel.FeatureFlag f = flagBuilder("feature") + DataModel.FeatureFlag f = flagBuilder("feature") .on(true) .offVariation(1) - .fallthrough(new FlagModel.VariationOrRollout(null, null)) + .fallthrough(new DataModel.VariationOrRollout(null, null)) .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .build(); Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); @@ -137,10 +137,10 @@ public void flagReturnsErrorIfFallthroughHasNeitherVariationNorRollout() throws @Test public void flagReturnsErrorIfFallthroughHasEmptyRolloutVariationList() throws Exception { - FlagModel.FeatureFlag f = flagBuilder("feature") + DataModel.FeatureFlag f = flagBuilder("feature") .on(true) .offVariation(1) - .fallthrough(new FlagModel.VariationOrRollout(null, ModelBuilders.emptyRollout())) + .fallthrough(new DataModel.VariationOrRollout(null, ModelBuilders.emptyRollout())) .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .build(); Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); @@ -151,7 +151,7 @@ public void flagReturnsErrorIfFallthroughHasEmptyRolloutVariationList() throws E @Test public void flagReturnsOffVariationIfPrerequisiteIsNotFound() throws Exception { - FlagModel.FeatureFlag f0 = flagBuilder("feature0") + DataModel.FeatureFlag f0 = flagBuilder("feature0") .on(true) .prerequisites(prerequisite("feature1", 1)) .fallthrough(fallthroughVariation(0)) @@ -168,7 +168,7 @@ public void flagReturnsOffVariationIfPrerequisiteIsNotFound() throws Exception { @Test public void flagReturnsOffVariationAndEventIfPrerequisiteIsOff() throws Exception { - FlagModel.FeatureFlag f0 = flagBuilder("feature0") + DataModel.FeatureFlag f0 = flagBuilder("feature0") .on(true) .prerequisites(prerequisite("feature1", 1)) .fallthrough(fallthroughVariation(0)) @@ -176,7 +176,7 @@ public void flagReturnsOffVariationAndEventIfPrerequisiteIsOff() throws Exceptio .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .version(1) .build(); - FlagModel.FeatureFlag f1 = flagBuilder("feature1") + DataModel.FeatureFlag f1 = flagBuilder("feature1") .on(false) .offVariation(1) // note that even though it returns the desired variation, it is still off and therefore not a match @@ -200,7 +200,7 @@ public void flagReturnsOffVariationAndEventIfPrerequisiteIsOff() throws Exceptio @Test public void flagReturnsOffVariationAndEventIfPrerequisiteIsNotMet() throws Exception { - FlagModel.FeatureFlag f0 = flagBuilder("feature0") + DataModel.FeatureFlag f0 = flagBuilder("feature0") .on(true) .prerequisites(prerequisite("feature1", 1)) .fallthrough(fallthroughVariation(0)) @@ -208,7 +208,7 @@ public void flagReturnsOffVariationAndEventIfPrerequisiteIsNotMet() throws Excep .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .version(1) .build(); - FlagModel.FeatureFlag f1 = flagBuilder("feature1") + DataModel.FeatureFlag f1 = flagBuilder("feature1") .on(true) .fallthrough(fallthroughVariation(0)) .variations(LDValue.of("nogo"), LDValue.of("go")) @@ -230,7 +230,7 @@ public void flagReturnsOffVariationAndEventIfPrerequisiteIsNotMet() throws Excep @Test public void prerequisiteFailedReasonInstanceIsReusedForSamePrerequisite() throws Exception { - FlagModel.FeatureFlag f0 = flagBuilder("feature0") + DataModel.FeatureFlag f0 = flagBuilder("feature0") .on(true) .prerequisites(prerequisite("feature1", 1)) .fallthrough(fallthroughVariation(0)) @@ -248,7 +248,7 @@ public void prerequisiteFailedReasonInstanceIsReusedForSamePrerequisite() throws @Test public void flagReturnsFallthroughVariationAndEventIfPrerequisiteIsMetAndThereAreNoRules() throws Exception { - FlagModel.FeatureFlag f0 = flagBuilder("feature0") + DataModel.FeatureFlag f0 = flagBuilder("feature0") .on(true) .prerequisites(prerequisite("feature1", 1)) .fallthrough(fallthroughVariation(0)) @@ -256,7 +256,7 @@ public void flagReturnsFallthroughVariationAndEventIfPrerequisiteIsMetAndThereAr .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .version(1) .build(); - FlagModel.FeatureFlag f1 = flagBuilder("feature1") + DataModel.FeatureFlag f1 = flagBuilder("feature1") .on(true) .fallthrough(fallthroughVariation(1)) .variations(LDValue.of("nogo"), LDValue.of("go")) @@ -277,7 +277,7 @@ public void flagReturnsFallthroughVariationAndEventIfPrerequisiteIsMetAndThereAr @Test public void multipleLevelsOfPrerequisitesProduceMultipleEvents() throws Exception { - FlagModel.FeatureFlag f0 = flagBuilder("feature0") + DataModel.FeatureFlag f0 = flagBuilder("feature0") .on(true) .prerequisites(prerequisite("feature1", 1)) .fallthrough(fallthroughVariation(0)) @@ -285,14 +285,14 @@ public void multipleLevelsOfPrerequisitesProduceMultipleEvents() throws Exceptio .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .version(1) .build(); - FlagModel.FeatureFlag f1 = flagBuilder("feature1") + DataModel.FeatureFlag f1 = flagBuilder("feature1") .on(true) .prerequisites(prerequisite("feature2", 1)) .fallthrough(fallthroughVariation(1)) .variations(LDValue.of("nogo"), LDValue.of("go")) .version(2) .build(); - FlagModel.FeatureFlag f2 = flagBuilder("feature2") + DataModel.FeatureFlag f2 = flagBuilder("feature2") .on(true) .fallthrough(fallthroughVariation(1)) .variations(LDValue.of("nogo"), LDValue.of("go")) @@ -319,7 +319,7 @@ public void multipleLevelsOfPrerequisitesProduceMultipleEvents() throws Exceptio @Test public void flagMatchesUserFromTargets() throws Exception { - FlagModel.FeatureFlag f = flagBuilder("feature") + DataModel.FeatureFlag f = flagBuilder("feature") .on(true) .targets(target(2, "whoever", "userkey")) .fallthrough(fallthroughVariation(0)) @@ -335,11 +335,11 @@ public void flagMatchesUserFromTargets() throws Exception { @Test public void flagMatchesUserFromRules() { - FlagModel.Clause clause0 = clause("key", Operator.in, LDValue.of("wrongkey")); - FlagModel.Clause clause1 = clause("key", Operator.in, LDValue.of("userkey")); - FlagModel.Rule rule0 = ruleBuilder().id("ruleid0").clauses(clause0).variation(2).build(); - FlagModel.Rule rule1 = ruleBuilder().id("ruleid1").clauses(clause1).variation(2).build(); - FlagModel.FeatureFlag f = featureFlagWithRules("feature", rule0, rule1); + DataModel.Clause clause0 = clause("key", Operator.in, LDValue.of("wrongkey")); + DataModel.Clause clause1 = clause("key", Operator.in, LDValue.of("userkey")); + DataModel.Rule rule0 = ruleBuilder().id("ruleid0").clauses(clause0).variation(2).build(); + DataModel.Rule rule1 = ruleBuilder().id("ruleid1").clauses(clause1).variation(2).build(); + DataModel.FeatureFlag f = featureFlagWithRules("feature", rule0, rule1); LDUser user = new LDUser.Builder("userkey").build(); Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); @@ -347,7 +347,7 @@ public void flagMatchesUserFromRules() { assertThat(result.getPrerequisiteEvents(), emptyIterable()); } - private FlagModel.FeatureFlag featureFlagWithRules(String flagKey, FlagModel.Rule... rules) { + private DataModel.FeatureFlag featureFlagWithRules(String flagKey, DataModel.Rule... rules) { return flagBuilder(flagKey) .on(true) .rules(rules) diff --git a/src/test/java/com/launchdarkly/client/EvaluatorTestUtil.java b/src/test/java/com/launchdarkly/client/EvaluatorTestUtil.java index 993a76c35..7806d1f69 100644 --- a/src/test/java/com/launchdarkly/client/EvaluatorTestUtil.java +++ b/src/test/java/com/launchdarkly/client/EvaluatorTestUtil.java @@ -13,11 +13,11 @@ public static class EvaluatorBuilder { EvaluatorBuilder() { getters = new Evaluator.Getters() { - public FlagModel.FeatureFlag getFlag(String key) { + public DataModel.FeatureFlag getFlag(String key) { throw new IllegalStateException("Evaluator unexpectedly tried to query flag: " + key); } - public FlagModel.Segment getSegment(String key) { + public DataModel.Segment getSegment(String key) { throw new IllegalStateException("Evaluator unexpectedly tried to query segment: " + key); } }; @@ -27,11 +27,11 @@ public Evaluator build() { return new Evaluator(getters); } - public EvaluatorBuilder withStoredFlags(final FlagModel.FeatureFlag... flags) { + public EvaluatorBuilder withStoredFlags(final DataModel.FeatureFlag... flags) { final Evaluator.Getters baseGetters = getters; getters = new Evaluator.Getters() { - public FlagModel.FeatureFlag getFlag(String key) { - for (FlagModel.FeatureFlag f: flags) { + public DataModel.FeatureFlag getFlag(String key) { + for (DataModel.FeatureFlag f: flags) { if (f.getKey().equals(key)) { return f; } @@ -39,7 +39,7 @@ public FlagModel.FeatureFlag getFlag(String key) { return baseGetters.getFlag(key); } - public FlagModel.Segment getSegment(String key) { + public DataModel.Segment getSegment(String key) { return baseGetters.getSegment(key); } }; @@ -49,29 +49,29 @@ public FlagModel.Segment getSegment(String key) { public EvaluatorBuilder withNonexistentFlag(final String nonexistentFlagKey) { final Evaluator.Getters baseGetters = getters; getters = new Evaluator.Getters() { - public FlagModel.FeatureFlag getFlag(String key) { + public DataModel.FeatureFlag getFlag(String key) { if (key.equals(nonexistentFlagKey)) { return null; } return baseGetters.getFlag(key); } - public FlagModel.Segment getSegment(String key) { + public DataModel.Segment getSegment(String key) { return baseGetters.getSegment(key); } }; return this; } - public EvaluatorBuilder withStoredSegments(final FlagModel.Segment... segments) { + public EvaluatorBuilder withStoredSegments(final DataModel.Segment... segments) { final Evaluator.Getters baseGetters = getters; getters = new Evaluator.Getters() { - public FlagModel.FeatureFlag getFlag(String key) { + public DataModel.FeatureFlag getFlag(String key) { return baseGetters.getFlag(key); } - public FlagModel.Segment getSegment(String key) { - for (FlagModel.Segment s: segments) { + public DataModel.Segment getSegment(String key) { + for (DataModel.Segment s: segments) { if (s.getKey().equals(key)) { return s; } @@ -85,11 +85,11 @@ public FlagModel.Segment getSegment(String key) { public EvaluatorBuilder withNonexistentSegment(final String nonexistentSegmentKey) { final Evaluator.Getters baseGetters = getters; getters = new Evaluator.Getters() { - public FlagModel.FeatureFlag getFlag(String key) { + public DataModel.FeatureFlag getFlag(String key) { return baseGetters.getFlag(key); } - public FlagModel.Segment getSegment(String key) { + public DataModel.Segment getSegment(String key) { if (key.equals(nonexistentSegmentKey)) { return null; } diff --git a/src/test/java/com/launchdarkly/client/EventOutputTest.java b/src/test/java/com/launchdarkly/client/EventOutputTest.java index e9ea3d585..cebc4eb20 100644 --- a/src/test/java/com/launchdarkly/client/EventOutputTest.java +++ b/src/test/java/com/launchdarkly/client/EventOutputTest.java @@ -163,7 +163,7 @@ private void testPrivateAttributes(LDConfig config, LDUser user, String... priva public void featureEventIsSerialized() throws Exception { EventFactory factory = eventFactoryWithTimestamp(100000, false); EventFactory factoryWithReason = eventFactoryWithTimestamp(100000, true); - FlagModel.FeatureFlag flag = flagBuilder("flag").version(11).build(); + DataModel.FeatureFlag flag = flagBuilder("flag").version(11).build(); LDUser user = new LDUser.Builder("userkey").name("me").build(); EventOutputFormatter f = new EventOutputFormatter(LDConfig.DEFAULT); diff --git a/src/test/java/com/launchdarkly/client/EventSummarizerTest.java b/src/test/java/com/launchdarkly/client/EventSummarizerTest.java index c67e53743..c1919a345 100644 --- a/src/test/java/com/launchdarkly/client/EventSummarizerTest.java +++ b/src/test/java/com/launchdarkly/client/EventSummarizerTest.java @@ -51,7 +51,7 @@ public void summarizeEventDoesNothingForCustomEvent() { @Test public void summarizeEventSetsStartAndEndDates() { EventSummarizer es = new EventSummarizer(); - FlagModel.FeatureFlag flag = flagBuilder("key").build(); + DataModel.FeatureFlag flag = flagBuilder("key").build(); eventTimestamp = 2000; Event event1 = eventFactory.newFeatureRequestEvent(flag, user, null, null); eventTimestamp = 1000; @@ -70,8 +70,8 @@ public void summarizeEventSetsStartAndEndDates() { @Test public void summarizeEventIncrementsCounters() { EventSummarizer es = new EventSummarizer(); - FlagModel.FeatureFlag flag1 = flagBuilder("key1").version(11).build(); - FlagModel.FeatureFlag flag2 = flagBuilder("key2").version(22).build(); + DataModel.FeatureFlag flag1 = flagBuilder("key1").version(11).build(); + DataModel.FeatureFlag flag2 = flagBuilder("key2").version(22).build(); String unknownFlagKey = "badkey"; Event event1 = eventFactory.newFeatureRequestEvent(flag1, user, simpleEvaluation(1, LDValue.of("value1")), LDValue.of("default1")); diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java b/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java index 4250bccaf..3649cf402 100644 --- a/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java @@ -19,7 +19,7 @@ public class FeatureFlagsStateTest { @Test public void canGetFlagValue() { Evaluator.EvalResult eval = new Evaluator.EvalResult(LDValue.of("value"), 1, EvaluationReason.off()); - FlagModel.FeatureFlag flag = flagBuilder("key").build(); + DataModel.FeatureFlag flag = flagBuilder("key").build(); FeatureFlagsState state = new FeatureFlagsState.Builder().addFlag(flag, eval).build(); assertEquals(js("value"), state.getFlagValue("key")); @@ -35,7 +35,7 @@ public void unknownFlagReturnsNullValue() { @Test public void canGetFlagReason() { Evaluator.EvalResult eval = new Evaluator.EvalResult(LDValue.of("value1"), 1, EvaluationReason.off()); - FlagModel.FeatureFlag flag = flagBuilder("key").build(); + DataModel.FeatureFlag flag = flagBuilder("key").build(); FeatureFlagsState state = new FeatureFlagsState.Builder(FlagsStateOption.WITH_REASONS) .addFlag(flag, eval).build(); @@ -52,7 +52,7 @@ public void unknownFlagReturnsNullReason() { @Test public void reasonIsNullIfReasonsWereNotRecorded() { Evaluator.EvalResult eval = new Evaluator.EvalResult(LDValue.of("value1"), 1, EvaluationReason.off()); - FlagModel.FeatureFlag flag = flagBuilder("key").build(); + DataModel.FeatureFlag flag = flagBuilder("key").build(); FeatureFlagsState state = new FeatureFlagsState.Builder().addFlag(flag, eval).build(); assertNull(state.getFlagReason("key")); @@ -61,7 +61,7 @@ public void reasonIsNullIfReasonsWereNotRecorded() { @Test public void flagCanHaveNullValue() { Evaluator.EvalResult eval = new Evaluator.EvalResult(LDValue.ofNull(), 1, null); - FlagModel.FeatureFlag flag = flagBuilder("key").build(); + DataModel.FeatureFlag flag = flagBuilder("key").build(); FeatureFlagsState state = new FeatureFlagsState.Builder().addFlag(flag, eval).build(); assertNull(state.getFlagValue("key")); @@ -70,9 +70,9 @@ public void flagCanHaveNullValue() { @Test public void canConvertToValuesMap() { Evaluator.EvalResult eval1 = new Evaluator.EvalResult(LDValue.of("value1"), 0, EvaluationReason.off()); - FlagModel.FeatureFlag flag1 = flagBuilder("key1").build(); + DataModel.FeatureFlag flag1 = flagBuilder("key1").build(); Evaluator.EvalResult eval2 = new Evaluator.EvalResult(LDValue.of("value2"), 1, EvaluationReason.fallthrough()); - FlagModel.FeatureFlag flag2 = flagBuilder("key2").build(); + DataModel.FeatureFlag flag2 = flagBuilder("key2").build(); FeatureFlagsState state = new FeatureFlagsState.Builder() .addFlag(flag1, eval1).addFlag(flag2, eval2).build(); @@ -83,9 +83,9 @@ public void canConvertToValuesMap() { @Test public void canConvertToJson() { Evaluator.EvalResult eval1 = new Evaluator.EvalResult(LDValue.of("value1"), 0, EvaluationReason.off()); - FlagModel.FeatureFlag flag1 = flagBuilder("key1").version(100).trackEvents(false).build(); + DataModel.FeatureFlag flag1 = flagBuilder("key1").version(100).trackEvents(false).build(); Evaluator.EvalResult eval2 = new Evaluator.EvalResult(LDValue.of("value2"), 1, EvaluationReason.fallthrough()); - FlagModel.FeatureFlag flag2 = flagBuilder("key2").version(200).trackEvents(true).debugEventsUntilDate(1000L).build(); + DataModel.FeatureFlag flag2 = flagBuilder("key2").version(200).trackEvents(true).debugEventsUntilDate(1000L).build(); FeatureFlagsState state = new FeatureFlagsState.Builder(FlagsStateOption.WITH_REASONS) .addFlag(flag1, eval1).addFlag(flag2, eval2).build(); @@ -106,9 +106,9 @@ public void canConvertToJson() { @Test public void canConvertFromJson() { Evaluator.EvalResult eval1 = new Evaluator.EvalResult(LDValue.of("value1"), 0, EvaluationReason.off()); - FlagModel.FeatureFlag flag1 = flagBuilder("key1").version(100).trackEvents(false).build(); + DataModel.FeatureFlag flag1 = flagBuilder("key1").version(100).trackEvents(false).build(); Evaluator.EvalResult eval2 = new Evaluator.EvalResult(LDValue.of("value2"), 1, EvaluationReason.off()); - FlagModel.FeatureFlag flag2 = flagBuilder("key2").version(200).trackEvents(true).debugEventsUntilDate(1000L).build(); + DataModel.FeatureFlag flag2 = flagBuilder("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/FeatureRequestorTest.java b/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java index 2f1a00212..33a1505f5 100644 --- a/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java @@ -62,7 +62,7 @@ public void requestFlag() throws Exception { try (MockWebServer server = makeStartedServer(resp)) { try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(sdkKey, basePollingConfig(server).build())) { - FlagModel.FeatureFlag flag = r.getFlag(flag1Key); + DataModel.FeatureFlag flag = r.getFlag(flag1Key); RecordedRequest req = server.takeRequest(); assertEquals("/sdk/latest-flags/" + flag1Key, req.getPath()); @@ -79,7 +79,7 @@ public void requestSegment() throws Exception { try (MockWebServer server = makeStartedServer(resp)) { try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(sdkKey, basePollingConfig(server).build())) { - FlagModel.Segment segment = r.getSegment(segment1Key); + DataModel.Segment segment = r.getSegment(segment1Key); RecordedRequest req = server.takeRequest(); assertEquals("/sdk/latest-segments/" + segment1Key, req.getPath()); @@ -130,7 +130,7 @@ public void requestsAreCached() throws Exception { try (MockWebServer server = makeStartedServer(cacheableResp)) { try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(sdkKey, basePollingConfig(server).build())) { - FlagModel.FeatureFlag flag1a = r.getFlag(flag1Key); + DataModel.FeatureFlag flag1a = r.getFlag(flag1Key); RecordedRequest req1 = server.takeRequest(); assertEquals("/sdk/latest-flags/" + flag1Key, req1.getPath()); @@ -138,7 +138,7 @@ public void requestsAreCached() throws Exception { verifyFlag(flag1a, flag1Key); - FlagModel.FeatureFlag flag1b = r.getFlag(flag1Key); + DataModel.FeatureFlag flag1b = r.getFlag(flag1Key); verifyFlag(flag1b, flag1Key); assertNull(server.takeRequest(0, TimeUnit.SECONDS)); // there was no second request, due to the cache hit } @@ -172,7 +172,7 @@ public void httpClientCanUseCustomTlsConfig() throws Exception { .build(); try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(sdkKey, config)) { - FlagModel.FeatureFlag flag = r.getFlag(flag1Key); + DataModel.FeatureFlag flag = r.getFlag(flag1Key); verifyFlag(flag, flag1Key); } } @@ -190,7 +190,7 @@ public void httpClientCanUseProxyConfig() throws Exception { .build(); try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(sdkKey, config)) { - FlagModel.FeatureFlag flag = r.getFlag(flag1Key); + DataModel.FeatureFlag flag = r.getFlag(flag1Key); verifyFlag(flag, flag1Key); assertEquals(1, server.getRequestCount()); @@ -208,12 +208,12 @@ private void verifyHeaders(RecordedRequest req) { assertEquals("JavaClient/" + LDClient.CLIENT_VERSION, req.getHeader("User-Agent")); } - private void verifyFlag(FlagModel.FeatureFlag flag, String key) { + private void verifyFlag(DataModel.FeatureFlag flag, String key) { assertNotNull(flag); assertEquals(key, flag.getKey()); } - private void verifySegment(FlagModel.Segment segment, String key) { + private void verifySegment(DataModel.Segment segment, String key) { assertNotNull(segment); assertEquals(key, segment.getKey()); } diff --git a/src/test/java/com/launchdarkly/client/FeatureStoreDatabaseTestBase.java b/src/test/java/com/launchdarkly/client/FeatureStoreDatabaseTestBase.java index 0ca41606e..0ba5637f9 100644 --- a/src/test/java/com/launchdarkly/client/FeatureStoreDatabaseTestBase.java +++ b/src/test/java/com/launchdarkly/client/FeatureStoreDatabaseTestBase.java @@ -1,6 +1,7 @@ package com.launchdarkly.client; import com.launchdarkly.client.TestUtil.DataBuilder; +import com.launchdarkly.client.interfaces.FeatureStore; import org.junit.After; import org.junit.Assume; @@ -13,8 +14,8 @@ import java.util.Arrays; import java.util.Map; +import static com.launchdarkly.client.DataModel.DataKinds.FEATURES; import static com.launchdarkly.client.ModelBuilders.flagBuilder; -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; @@ -134,13 +135,13 @@ public void handlesUpsertRaceConditionAgainstExternalClientWithLowerVersion() th final int store2VersionEnd = 4; int store1VersionEnd = 10; - final FlagModel.FeatureFlag flag1 = flagBuilder("foo").version(startVersion).build(); + final DataModel.FeatureFlag flag1 = flagBuilder("foo").version(startVersion).build(); Runnable concurrentModifier = new Runnable() { int versionCounter = store2VersionStart; public void run() { if (versionCounter <= store2VersionEnd) { - FlagModel.FeatureFlag f = flagBuilder(flag1).version(versionCounter).build(); + DataModel.FeatureFlag f = flagBuilder(flag1).version(versionCounter).build(); store2.upsert(FEATURES, f); versionCounter++; } @@ -152,10 +153,10 @@ public void run() { store.init(new DataBuilder().add(FEATURES, flag1).build()); - FlagModel.FeatureFlag store1End = flagBuilder(flag1).version(store1VersionEnd).build(); + DataModel.FeatureFlag store1End = flagBuilder(flag1).version(store1VersionEnd).build(); store.upsert(FEATURES, store1End); - FlagModel.FeatureFlag result = store.get(FEATURES, flag1.getKey()); + DataModel.FeatureFlag result = store.get(FEATURES, flag1.getKey()); assertEquals(store1VersionEnd, result.getVersion()); } finally { store2.close(); @@ -170,11 +171,11 @@ public void handlesUpsertRaceConditionAgainstExternalClientWithHigherVersion() t final int store2Version = 3; int store1VersionEnd = 2; - final FlagModel.FeatureFlag flag1 = flagBuilder("foo").version(startVersion).build(); + final DataModel.FeatureFlag flag1 = flagBuilder("foo").version(startVersion).build(); Runnable concurrentModifier = new Runnable() { public void run() { - FlagModel.FeatureFlag f = flagBuilder(flag1).version(store2Version).build(); + DataModel.FeatureFlag f = flagBuilder(flag1).version(store2Version).build(); store2.upsert(FEATURES, f); } }; @@ -184,10 +185,10 @@ public void run() { store.init(new DataBuilder().add(FEATURES, flag1).build()); - FlagModel.FeatureFlag store1End = flagBuilder(flag1).version(store1VersionEnd).build(); + DataModel.FeatureFlag store1End = flagBuilder(flag1).version(store1VersionEnd).build(); store.upsert(FEATURES, store1End); - FlagModel.FeatureFlag result = store.get(FEATURES, flag1.getKey()); + DataModel.FeatureFlag result = store.get(FEATURES, flag1.getKey()); assertEquals(store2Version, result.getVersion()); } finally { store2.close(); @@ -207,10 +208,10 @@ public void storesWithDifferentPrefixAreIndependent() throws Exception { assertFalse(store1.initialized()); assertFalse(store2.initialized()); - FlagModel.FeatureFlag flag1a = flagBuilder("flag-a").version(1).build(); - FlagModel.FeatureFlag flag1b = flagBuilder("flag-b").version(1).build(); - FlagModel.FeatureFlag flag2a = flagBuilder("flag-a").version(2).build(); - FlagModel.FeatureFlag flag2c = flagBuilder("flag-c").version(2).build(); + DataModel.FeatureFlag flag1a = flagBuilder("flag-a").version(1).build(); + DataModel.FeatureFlag flag1b = flagBuilder("flag-b").version(1).build(); + DataModel.FeatureFlag flag2a = flagBuilder("flag-a").version(2).build(); + DataModel.FeatureFlag flag2c = flagBuilder("flag-c").version(2).build(); store1.init(new DataBuilder().add(FEATURES, flag1a, flag1b).build()); assertTrue(store1.initialized()); @@ -220,8 +221,8 @@ public void storesWithDifferentPrefixAreIndependent() throws Exception { assertTrue(store1.initialized()); assertTrue(store2.initialized()); - Map items1 = store1.all(FEATURES); - Map items2 = store2.all(FEATURES); + 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()); diff --git a/src/test/java/com/launchdarkly/client/FeatureStoreTestBase.java b/src/test/java/com/launchdarkly/client/FeatureStoreTestBase.java index 6cc46e98f..d85b70ead 100644 --- a/src/test/java/com/launchdarkly/client/FeatureStoreTestBase.java +++ b/src/test/java/com/launchdarkly/client/FeatureStoreTestBase.java @@ -1,6 +1,9 @@ package com.launchdarkly.client; import com.launchdarkly.client.TestUtil.DataBuilder; +import com.launchdarkly.client.interfaces.FeatureStore; +import com.launchdarkly.client.interfaces.VersionedData; +import com.launchdarkly.client.interfaces.VersionedDataKind; import org.junit.After; import org.junit.Before; @@ -8,10 +11,10 @@ import java.util.Map; +import static com.launchdarkly.client.DataModel.DataKinds.FEATURES; +import static com.launchdarkly.client.DataModel.DataKinds.SEGMENTS; import static com.launchdarkly.client.ModelBuilders.flagBuilder; import static com.launchdarkly.client.ModelBuilders.segmentBuilder; -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; @@ -28,17 +31,17 @@ public abstract class FeatureStoreTestBase { protected T store; protected boolean cached; - protected FlagModel.FeatureFlag feature1 = flagBuilder("foo") + protected DataModel.FeatureFlag feature1 = flagBuilder("foo") .version(10) .salt("abc") .build(); - protected FlagModel.FeatureFlag feature2 = flagBuilder("bar") + protected DataModel.FeatureFlag feature2 = flagBuilder("bar") .version(10) .salt("abc") .build(); - protected FlagModel.Segment segment1 = segmentBuilder("foo") + protected DataModel.Segment segment1 = segmentBuilder("foo") .version(11) .build(); @@ -95,12 +98,12 @@ public void initCompletelyReplacesPreviousData() { new DataBuilder().add(FEATURES, feature1, feature2).add(SEGMENTS, segment1).build(); store.init(allData); - FlagModel.FeatureFlag feature2v2 = flagBuilder(feature2).version(feature2.getVersion() + 1).build(); + DataModel.FeatureFlag feature2v2 = flagBuilder(feature2).version(feature2.getVersion() + 1).build(); allData = new DataBuilder().add(FEATURES, feature2v2).add(SEGMENTS).build(); store.init(allData); assertNull(store.get(FEATURES, feature1.getKey())); - FlagModel.FeatureFlag item2 = store.get(FEATURES, feature2.getKey()); + DataModel.FeatureFlag item2 = store.get(FEATURES, feature2.getKey()); assertNotNull(item2); assertEquals(feature2v2.getVersion(), item2.getVersion()); assertNull(store.get(SEGMENTS, segment1.getKey())); @@ -109,7 +112,7 @@ public void initCompletelyReplacesPreviousData() { @Test public void getExistingFeature() { store.init(new DataBuilder().add(FEATURES, feature1, feature2).build()); - FlagModel.FeatureFlag result = store.get(FEATURES, feature1.getKey()); + DataModel.FeatureFlag result = store.get(FEATURES, feature1.getKey()); assertEquals(feature1.getKey(), result.getKey()); } @@ -122,12 +125,12 @@ public void getNonexistingFeature() { @Test public void getAll() { store.init(new DataBuilder().add(FEATURES, feature1, feature2).add(SEGMENTS, segment1).build()); - Map items = store.all(FEATURES); + Map items = store.all(FEATURES); assertEquals(2, items.size()); - FlagModel.FeatureFlag item1 = items.get(feature1.getKey()); + DataModel.FeatureFlag item1 = items.get(feature1.getKey()); assertNotNull(item1); assertEquals(feature1.getVersion(), item1.getVersion()); - FlagModel.FeatureFlag item2 = items.get(feature2.getKey()); + DataModel.FeatureFlag item2 = items.get(feature2.getKey()); assertNotNull(item2); assertEquals(feature2.getVersion(), item2.getVersion()); } @@ -136,9 +139,9 @@ public void getAll() { 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); + Map items = store.all(FEATURES); assertEquals(1, items.size()); - FlagModel.FeatureFlag item2 = items.get(feature2.getKey()); + DataModel.FeatureFlag item2 = items.get(feature2.getKey()); assertNotNull(item2); assertEquals(feature2.getVersion(), item2.getVersion()); } @@ -146,33 +149,33 @@ public void getAllWithDeletedItem() { @Test public void upsertWithNewerVersion() { store.init(new DataBuilder().add(FEATURES, feature1, feature2).build()); - FlagModel.FeatureFlag newVer = flagBuilder(feature1) + DataModel.FeatureFlag newVer = flagBuilder(feature1) .version(feature1.getVersion() + 1) .build(); store.upsert(FEATURES, newVer); - FlagModel.FeatureFlag result = store.get(FEATURES, newVer.getKey()); + DataModel.FeatureFlag result = store.get(FEATURES, newVer.getKey()); assertEquals(newVer.getVersion(), result.getVersion()); } @Test public void upsertWithOlderVersion() { store.init(new DataBuilder().add(FEATURES, feature1, feature2).build()); - FlagModel.FeatureFlag oldVer = flagBuilder(feature1) + DataModel.FeatureFlag oldVer = flagBuilder(feature1) .version(feature1.getVersion() - 1) .build(); store.upsert(FEATURES, oldVer); - FlagModel.FeatureFlag result = store.get(FEATURES, oldVer.getKey()); + DataModel.FeatureFlag result = store.get(FEATURES, oldVer.getKey()); assertEquals(feature1.getVersion(), result.getVersion()); } @Test public void upsertNewFeature() { store.init(new DataBuilder().add(FEATURES, feature1, feature2).build()); - FlagModel.FeatureFlag newFeature = flagBuilder("biz") + DataModel.FeatureFlag newFeature = flagBuilder("biz") .version(99) .build(); store.upsert(FEATURES, newFeature); - FlagModel.FeatureFlag result = store.get(FEATURES, newFeature.getKey()); + DataModel.FeatureFlag result = store.get(FEATURES, newFeature.getKey()); assertEquals(newFeature.getKey(), result.getKey()); } diff --git a/src/test/java/com/launchdarkly/client/FlagModelDeserializationTest.java b/src/test/java/com/launchdarkly/client/FlagModelDeserializationTest.java index 985fa1119..ee6d63956 100644 --- a/src/test/java/com/launchdarkly/client/FlagModelDeserializationTest.java +++ b/src/test/java/com/launchdarkly/client/FlagModelDeserializationTest.java @@ -14,7 +14,7 @@ public class FlagModelDeserializationTest { @Test public void precomputedReasonsAreAddedToPrerequisites() { String flagJson = "{\"key\":\"flagkey\",\"prerequisites\":[{\"key\":\"prereq0\"},{\"key\":\"prereq1\"}]}"; - FlagModel.FeatureFlag flag = gson.fromJson(flagJson, FlagModel.FeatureFlag.class); + DataModel.FeatureFlag flag = gson.fromJson(flagJson, DataModel.FeatureFlag.class); assertNotNull(flag.getPrerequisites()); assertEquals(2, flag.getPrerequisites().size()); assertEquals(EvaluationReason.prerequisiteFailed("prereq0"), flag.getPrerequisites().get(0).getPrerequisiteFailedReason()); @@ -24,7 +24,7 @@ public void precomputedReasonsAreAddedToPrerequisites() { @Test public void precomputedReasonsAreAddedToRules() { String flagJson = "{\"key\":\"flagkey\",\"rules\":[{\"id\":\"ruleid0\"},{\"id\":\"ruleid1\"}]}"; - FlagModel.FeatureFlag flag = gson.fromJson(flagJson, FlagModel.FeatureFlag.class); + DataModel.FeatureFlag flag = gson.fromJson(flagJson, DataModel.FeatureFlag.class); assertNotNull(flag.getRules()); assertEquals(2, flag.getRules().size()); assertEquals(EvaluationReason.ruleMatch(0, "ruleid0"), flag.getRules().get(0).getRuleMatchReason()); diff --git a/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java b/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java index cfdede733..f04f974d9 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java @@ -22,7 +22,7 @@ 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 FlagModel.FeatureFlag flag = flagBuilder(flagKey) + private static final DataModel.FeatureFlag flag = flagBuilder(flagKey) .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 5c09596b1..0fb2182f9 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java @@ -4,12 +4,15 @@ import com.google.gson.Gson; import com.google.gson.JsonElement; import com.google.gson.JsonPrimitive; +import com.launchdarkly.client.interfaces.FeatureStore; import com.launchdarkly.client.value.LDValue; import org.junit.Test; import java.util.Map; +import static com.launchdarkly.client.DataModel.DataKinds.FEATURES; +import static com.launchdarkly.client.DataModel.DataKinds.SEGMENTS; import static com.launchdarkly.client.ModelBuilders.booleanFlagWithClauses; import static com.launchdarkly.client.ModelBuilders.clause; import static com.launchdarkly.client.ModelBuilders.fallthroughVariation; @@ -20,8 +23,6 @@ import static com.launchdarkly.client.TestUtil.featureStoreThatThrowsException; 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; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; @@ -178,14 +179,14 @@ public void jsonValueVariationReturnsDefaultValueForUnknownFlag() throws Excepti @Test public void canMatchUserBySegment() throws Exception { // This is similar to one of the tests in FeatureFlagTest, but more end-to-end - FlagModel.Segment segment = segmentBuilder("segment1") + DataModel.Segment segment = segmentBuilder("segment1") .version(1) .included(user.getKeyAsString()) .build(); featureStore.upsert(SEGMENTS, segment); - FlagModel.Clause clause = clause("", Operator.segmentMatch, LDValue.of("segment1")); - FlagModel.FeatureFlag feature = booleanFlagWithClauses("feature", clause); + DataModel.Clause clause = clause("", Operator.segmentMatch, LDValue.of("segment1")); + DataModel.FeatureFlag feature = booleanFlagWithClauses("feature", clause); featureStore.upsert(FEATURES, feature); assertTrue(client.boolVariation("feature", user, false)); @@ -202,7 +203,7 @@ public void canGetDetailsForSuccessfulEvaluation() throws Exception { @Test public void variationReturnsDefaultIfFlagEvaluatesToNull() { - FlagModel.FeatureFlag flag = flagBuilder("key").on(false).offVariation(null).build(); + DataModel.FeatureFlag flag = flagBuilder("key").on(false).offVariation(null).build(); featureStore.upsert(FEATURES, flag); assertEquals("default", client.stringVariation("key", user, "default")); @@ -210,7 +211,7 @@ public void variationReturnsDefaultIfFlagEvaluatesToNull() { @Test public void variationDetailReturnsDefaultIfFlagEvaluatesToNull() { - FlagModel.FeatureFlag flag = flagBuilder("key").on(false).offVariation(null).build(); + DataModel.FeatureFlag flag = flagBuilder("key").on(false).offVariation(null).build(); featureStore.upsert(FEATURES, flag); EvaluationDetail expected = EvaluationDetail.fromValue("default", @@ -304,14 +305,14 @@ public void allFlagsReturnsNullForNullUserKey() throws Exception { @Test public void allFlagsStateReturnsState() throws Exception { - FlagModel.FeatureFlag flag1 = flagBuilder("key1") + DataModel.FeatureFlag flag1 = flagBuilder("key1") .version(100) .trackEvents(false) .on(false) .offVariation(0) .variations(LDValue.of("value1")) .build(); - FlagModel.FeatureFlag flag2 = flagBuilder("key2") + DataModel.FeatureFlag flag2 = flagBuilder("key2") .version(200) .trackEvents(true) .debugEventsUntilDate(1000L) @@ -341,11 +342,11 @@ public void allFlagsStateReturnsState() throws Exception { @Test public void allFlagsStateCanFilterForOnlyClientSideFlags() { - FlagModel.FeatureFlag flag1 = flagBuilder("server-side-1").build(); - FlagModel.FeatureFlag flag2 = flagBuilder("server-side-2").build(); - FlagModel.FeatureFlag flag3 = flagBuilder("client-side-1").clientSide(true) + DataModel.FeatureFlag flag1 = flagBuilder("server-side-1").build(); + DataModel.FeatureFlag flag2 = flagBuilder("server-side-2").build(); + DataModel.FeatureFlag flag3 = flagBuilder("client-side-1").clientSide(true) .variations(LDValue.of("value1")).offVariation(0).build(); - FlagModel.FeatureFlag flag4 = flagBuilder("client-side-2").clientSide(true) + DataModel.FeatureFlag flag4 = flagBuilder("client-side-2").clientSide(true) .variations(LDValue.of("value2")).offVariation(0).build(); featureStore.upsert(FEATURES, flag1); featureStore.upsert(FEATURES, flag2); @@ -361,14 +362,14 @@ public void allFlagsStateCanFilterForOnlyClientSideFlags() { @Test public void allFlagsStateReturnsStateWithReasons() { - FlagModel.FeatureFlag flag1 = flagBuilder("key1") + DataModel.FeatureFlag flag1 = flagBuilder("key1") .version(100) .trackEvents(false) .on(false) .offVariation(0) .variations(LDValue.of("value1")) .build(); - FlagModel.FeatureFlag flag2 = flagBuilder("key2") + DataModel.FeatureFlag flag2 = flagBuilder("key2") .version(200) .trackEvents(true) .debugEventsUntilDate(1000L) @@ -399,21 +400,21 @@ public void allFlagsStateReturnsStateWithReasons() { @Test public void allFlagsStateCanOmitDetailsForUntrackedFlags() { long futureTime = System.currentTimeMillis() + 1000000; - FlagModel.FeatureFlag flag1 = flagBuilder("key1") + DataModel.FeatureFlag flag1 = flagBuilder("key1") .version(100) .trackEvents(false) .on(false) .offVariation(0) .variations(LDValue.of("value1")) .build(); - FlagModel.FeatureFlag flag2 = flagBuilder("key2") + DataModel.FeatureFlag flag2 = flagBuilder("key2") .version(200) .trackEvents(true) .on(true) .fallthrough(fallthroughVariation(1)) .variations(LDValue.of("off"), LDValue.of("value2")) .build(); - FlagModel.FeatureFlag flag3 = flagBuilder("key3") + DataModel.FeatureFlag flag3 = flagBuilder("key3") .version(300) .trackEvents(false) .debugEventsUntilDate(futureTime) // event tracking is turned on temporarily even though trackEvents is false diff --git a/src/test/java/com/launchdarkly/client/LDClientEventTest.java b/src/test/java/com/launchdarkly/client/LDClientEventTest.java index 02d6c61a8..c11d6ac71 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEventTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEventTest.java @@ -3,10 +3,12 @@ import com.google.gson.JsonElement; import com.google.gson.JsonPrimitive; import com.launchdarkly.client.EvaluationReason.ErrorKind; +import com.launchdarkly.client.interfaces.FeatureStore; import com.launchdarkly.client.value.LDValue; import org.junit.Test; +import static com.launchdarkly.client.DataModel.DataKinds.FEATURES; import static com.launchdarkly.client.ModelBuilders.clauseMatchingUser; import static com.launchdarkly.client.ModelBuilders.clauseNotMatchingUser; import static com.launchdarkly.client.ModelBuilders.fallthroughVariation; @@ -16,7 +18,6 @@ import static com.launchdarkly.client.ModelBuilders.ruleBuilder; 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; @@ -148,7 +149,7 @@ public void trackWithUserWithNoKeyDoesNotSendEvent() { @Test public void boolVariationSendsEvent() throws Exception { - FlagModel.FeatureFlag flag = flagWithValue("key", LDValue.of(true)); + DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of(true)); featureStore.upsert(FEATURES, flag); client.boolVariation("key", user, false); @@ -165,7 +166,7 @@ public void boolVariationSendsEventForUnknownFlag() throws Exception { @Test public void boolVariationDetailSendsEvent() throws Exception { - FlagModel.FeatureFlag flag = flagWithValue("key", LDValue.of(true)); + DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of(true)); featureStore.upsert(FEATURES, flag); client.boolVariationDetail("key", user, false); @@ -183,7 +184,7 @@ public void boolVariationDetailSendsEventForUnknownFlag() throws Exception { @Test public void intVariationSendsEvent() throws Exception { - FlagModel.FeatureFlag flag = flagWithValue("key", LDValue.of(2)); + DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of(2)); featureStore.upsert(FEATURES, flag); client.intVariation("key", user, 1); @@ -200,7 +201,7 @@ public void intVariationSendsEventForUnknownFlag() throws Exception { @Test public void intVariationDetailSendsEvent() throws Exception { - FlagModel.FeatureFlag flag = flagWithValue("key", LDValue.of(2)); + DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of(2)); featureStore.upsert(FEATURES, flag); client.intVariationDetail("key", user, 1); @@ -218,7 +219,7 @@ public void intVariationDetailSendsEventForUnknownFlag() throws Exception { @Test public void doubleVariationSendsEvent() throws Exception { - FlagModel.FeatureFlag flag = flagWithValue("key", LDValue.of(2.5d)); + DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of(2.5d)); featureStore.upsert(FEATURES, flag); client.doubleVariation("key", user, 1.0d); @@ -235,7 +236,7 @@ public void doubleVariationSendsEventForUnknownFlag() throws Exception { @Test public void doubleVariationDetailSendsEvent() throws Exception { - FlagModel.FeatureFlag flag = flagWithValue("key", LDValue.of(2.5d)); + DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of(2.5d)); featureStore.upsert(FEATURES, flag); client.doubleVariationDetail("key", user, 1.0d); @@ -253,7 +254,7 @@ public void doubleVariationDetailSendsEventForUnknownFlag() throws Exception { @Test public void stringVariationSendsEvent() throws Exception { - FlagModel.FeatureFlag flag = flagWithValue("key", LDValue.of("b")); + DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of("b")); featureStore.upsert(FEATURES, flag); client.stringVariation("key", user, "a"); @@ -270,7 +271,7 @@ public void stringVariationSendsEventForUnknownFlag() throws Exception { @Test public void stringVariationDetailSendsEvent() throws Exception { - FlagModel.FeatureFlag flag = flagWithValue("key", LDValue.of("b")); + DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of("b")); featureStore.upsert(FEATURES, flag); client.stringVariationDetail("key", user, "a"); @@ -290,7 +291,7 @@ public void stringVariationDetailSendsEventForUnknownFlag() throws Exception { @Test public void jsonVariationSendsEvent() throws Exception { LDValue data = LDValue.buildObject().put("thing", LDValue.of("stuff")).build(); - FlagModel.FeatureFlag flag = flagWithValue("key", data); + DataModel.FeatureFlag flag = flagWithValue("key", data); featureStore.upsert(FEATURES, flag); LDValue defaultVal = LDValue.of(42); @@ -313,7 +314,7 @@ public void jsonVariationSendsEventForUnknownFlag() throws Exception { @Test public void jsonVariationDetailSendsEvent() throws Exception { LDValue data = LDValue.buildObject().put("thing", LDValue.of("stuff")).build(); - FlagModel.FeatureFlag flag = flagWithValue("key", data); + DataModel.FeatureFlag flag = flagWithValue("key", data); featureStore.upsert(FEATURES, flag); LDValue defaultVal = LDValue.of(42); @@ -336,7 +337,7 @@ public void jsonVariationDetailSendsEventForUnknownFlag() throws Exception { @Test public void jsonValueVariationDetailSendsEvent() throws Exception { LDValue data = LDValue.buildObject().put("thing", LDValue.of("stuff")).build(); - FlagModel.FeatureFlag flag = flagWithValue("key", data); + DataModel.FeatureFlag flag = flagWithValue("key", data); featureStore.upsert(FEATURES, flag); LDValue defaultVal = LDValue.of(42); @@ -357,9 +358,9 @@ public void jsonValueVariationDetailSendsEventForUnknownFlag() throws Exception @Test public void eventTrackingAndReasonCanBeForcedForRule() throws Exception { - FlagModel.Clause clause = clauseMatchingUser(user); - FlagModel.Rule rule = ruleBuilder().id("id").clauses(clause).variation(1).trackEvents(true).build(); - FlagModel.FeatureFlag flag = flagBuilder("flag") + DataModel.Clause clause = clauseMatchingUser(user); + DataModel.Rule rule = ruleBuilder().id("id").clauses(clause).variation(1).trackEvents(true).build(); + DataModel.FeatureFlag flag = flagBuilder("flag") .on(true) .rules(rule) .offVariation(0) @@ -380,11 +381,11 @@ public void eventTrackingAndReasonCanBeForcedForRule() throws Exception { @Test public void eventTrackingAndReasonAreNotForcedIfFlagIsNotSetForMatchingRule() throws Exception { - FlagModel.Clause clause0 = clauseNotMatchingUser(user); - FlagModel.Clause clause1 = clauseMatchingUser(user); - FlagModel.Rule rule0 = ruleBuilder().id("id0").clauses(clause0).variation(1).trackEvents(true).build(); - FlagModel.Rule rule1 = ruleBuilder().id("id1").clauses(clause1).variation(1).trackEvents(false).build(); - FlagModel.FeatureFlag flag = flagBuilder("flag") + DataModel.Clause clause0 = clauseNotMatchingUser(user); + DataModel.Clause clause1 = clauseMatchingUser(user); + DataModel.Rule rule0 = ruleBuilder().id("id0").clauses(clause0).variation(1).trackEvents(true).build(); + DataModel.Rule rule1 = ruleBuilder().id("id1").clauses(clause1).variation(1).trackEvents(false).build(); + DataModel.FeatureFlag flag = flagBuilder("flag") .on(true) .rules(rule0, rule1) .offVariation(0) @@ -404,9 +405,9 @@ public void eventTrackingAndReasonAreNotForcedIfFlagIsNotSetForMatchingRule() th @Test public void eventTrackingAndReasonCanBeForcedForFallthrough() throws Exception { - FlagModel.FeatureFlag flag = flagBuilder("flag") + DataModel.FeatureFlag flag = flagBuilder("flag") .on(true) - .fallthrough(new FlagModel.VariationOrRollout(0, null)) + .fallthrough(new DataModel.VariationOrRollout(0, null)) .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .trackEventsFallthrough(true) .build(); @@ -425,9 +426,9 @@ public void eventTrackingAndReasonCanBeForcedForFallthrough() throws Exception { @Test public void eventTrackingAndReasonAreNotForcedForFallthroughIfFlagIsNotSet() throws Exception { - FlagModel.FeatureFlag flag = flagBuilder("flag") + DataModel.FeatureFlag flag = flagBuilder("flag") .on(true) - .fallthrough(new FlagModel.VariationOrRollout(0, null)) + .fallthrough(new DataModel.VariationOrRollout(0, null)) .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .trackEventsFallthrough(false) .build(); @@ -443,10 +444,10 @@ public void eventTrackingAndReasonAreNotForcedForFallthroughIfFlagIsNotSet() thr @Test public void eventTrackingAndReasonAreNotForcedForFallthroughIfReasonIsNotFallthrough() throws Exception { - FlagModel.FeatureFlag flag = flagBuilder("flag") + DataModel.FeatureFlag flag = flagBuilder("flag") .on(false) // so the evaluation reason will be OFF, not FALLTHROUGH .offVariation(1) - .fallthrough(new FlagModel.VariationOrRollout(0, null)) + .fallthrough(new DataModel.VariationOrRollout(0, null)) .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .trackEventsFallthrough(true) .build(); @@ -462,7 +463,7 @@ public void eventTrackingAndReasonAreNotForcedForFallthroughIfReasonIsNotFallthr @Test public void eventIsSentForExistingPrererequisiteFlag() throws Exception { - FlagModel.FeatureFlag f0 = flagBuilder("feature0") + DataModel.FeatureFlag f0 = flagBuilder("feature0") .on(true) .prerequisites(prerequisite("feature1", 1)) .fallthrough(fallthroughVariation(0)) @@ -470,7 +471,7 @@ public void eventIsSentForExistingPrererequisiteFlag() throws Exception { .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .version(1) .build(); - FlagModel.FeatureFlag f1 = flagBuilder("feature1") + DataModel.FeatureFlag f1 = flagBuilder("feature1") .on(true) .fallthrough(fallthroughVariation(1)) .variations(LDValue.of("nogo"), LDValue.of("go")) @@ -488,7 +489,7 @@ public void eventIsSentForExistingPrererequisiteFlag() throws Exception { @Test public void eventIsSentWithReasonForExistingPrererequisiteFlag() throws Exception { - FlagModel.FeatureFlag f0 = flagBuilder("feature0") + DataModel.FeatureFlag f0 = flagBuilder("feature0") .on(true) .prerequisites(prerequisite("feature1", 1)) .fallthrough(fallthroughVariation(0)) @@ -496,7 +497,7 @@ public void eventIsSentWithReasonForExistingPrererequisiteFlag() throws Exceptio .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .version(1) .build(); - FlagModel.FeatureFlag f1 = flagBuilder("feature1") + DataModel.FeatureFlag f1 = flagBuilder("feature1") .on(true) .fallthrough(fallthroughVariation(1)) .variations(LDValue.of("nogo"), LDValue.of("go")) @@ -514,7 +515,7 @@ public void eventIsSentWithReasonForExistingPrererequisiteFlag() throws Exceptio @Test public void eventIsNotSentForUnknownPrererequisiteFlag() throws Exception { - FlagModel.FeatureFlag f0 = flagBuilder("feature0") + DataModel.FeatureFlag f0 = flagBuilder("feature0") .on(true) .prerequisites(prerequisite("feature1", 1)) .fallthrough(fallthroughVariation(0)) @@ -532,7 +533,7 @@ public void eventIsNotSentForUnknownPrererequisiteFlag() throws Exception { @Test public void failureReasonIsGivenForUnknownPrererequisiteFlagIfDetailsWereRequested() throws Exception { - FlagModel.FeatureFlag f0 = flagBuilder("feature0") + DataModel.FeatureFlag f0 = flagBuilder("feature0") .on(true) .prerequisites(prerequisite("feature1", 1)) .fallthrough(fallthroughVariation(0)) @@ -549,7 +550,7 @@ public void failureReasonIsGivenForUnknownPrererequisiteFlagIfDetailsWereRequest EvaluationReason.prerequisiteFailed("feature1")); } - private void checkFeatureEvent(Event e, FlagModel.FeatureFlag flag, LDValue value, LDValue defaultVal, + private void checkFeatureEvent(Event e, DataModel.FeatureFlag flag, LDValue value, 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 79c263aae..edcfb81dc 100644 --- a/src/test/java/com/launchdarkly/client/LDClientLddModeTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientLddModeTest.java @@ -1,15 +1,17 @@ package com.launchdarkly.client; +import com.launchdarkly.client.interfaces.FeatureStore; +import com.launchdarkly.client.interfaces.UpdateProcessor; import com.launchdarkly.client.value.LDValue; import org.junit.Test; import java.io.IOException; +import static com.launchdarkly.client.DataModel.DataKinds.FEATURES; import static com.launchdarkly.client.ModelBuilders.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; @@ -52,7 +54,7 @@ public void lddModeClientGetsFlagFromFeatureStore() throws IOException { .useLdd(true) .featureStoreFactory(specificFeatureStore(testFeatureStore)) .build(); - FlagModel.FeatureFlag flag = flagWithValue("key", LDValue.of(true)); + DataModel.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 fb34730d8..091666708 100644 --- a/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java @@ -2,6 +2,9 @@ import com.google.common.collect.ImmutableMap; import com.google.gson.JsonElement; +import com.launchdarkly.client.interfaces.EventProcessor; +import com.launchdarkly.client.interfaces.FeatureStore; +import com.launchdarkly.client.interfaces.UpdateProcessor; import com.launchdarkly.client.value.LDValue; import org.junit.Test; @@ -9,11 +12,11 @@ import java.io.IOException; import java.util.Map; +import static com.launchdarkly.client.DataModel.DataKinds.FEATURES; import static com.launchdarkly.client.ModelBuilders.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.assertTrue; diff --git a/src/test/java/com/launchdarkly/client/LDClientTest.java b/src/test/java/com/launchdarkly/client/LDClientTest.java index 9b67cf085..2e200b908 100644 --- a/src/test/java/com/launchdarkly/client/LDClientTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientTest.java @@ -5,6 +5,11 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.Iterables; +import com.launchdarkly.client.interfaces.EventProcessor; +import com.launchdarkly.client.interfaces.FeatureStore; +import com.launchdarkly.client.interfaces.UpdateProcessor; +import com.launchdarkly.client.interfaces.VersionedData; +import com.launchdarkly.client.interfaces.VersionedDataKind; import com.launchdarkly.client.value.LDValue; import org.easymock.Capture; @@ -21,6 +26,8 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import static com.launchdarkly.client.DataModel.DataKinds.FEATURES; +import static com.launchdarkly.client.DataModel.DataKinds.SEGMENTS; import static com.launchdarkly.client.ModelBuilders.flagBuilder; import static com.launchdarkly.client.ModelBuilders.flagWithValue; import static com.launchdarkly.client.ModelBuilders.prerequisite; @@ -28,8 +35,6 @@ import static com.launchdarkly.client.TestUtil.initedFeatureStore; 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; @@ -315,9 +320,9 @@ public void dataSetIsPassedToFeatureStoreInCorrectOrder() throws Exception { List list1 = ImmutableList.copyOf(map1.values()); assertEquals(DEPENDENCY_ORDERING_TEST_DATA.get(FEATURES).size(), map1.size()); for (int itemIndex = 0; itemIndex < list1.size(); itemIndex++) { - FlagModel.FeatureFlag item = (FlagModel.FeatureFlag)list1.get(itemIndex); - for (FlagModel.Prerequisite prereq: item.getPrerequisites()) { - FlagModel.FeatureFlag depFlag = (FlagModel.FeatureFlag)map1.get(prereq.getKey()); + DataModel.FeatureFlag item = (DataModel.FeatureFlag)list1.get(itemIndex); + for (DataModel.Prerequisite prereq: item.getPrerequisites()) { + DataModel.FeatureFlag depFlag = (DataModel.FeatureFlag)map1.get(prereq.getKey()); int depIndex = list1.indexOf(depFlag); if (depIndex > itemIndex) { Iterable allKeys = Iterables.transform(list1, new Function() { diff --git a/src/test/java/com/launchdarkly/client/ModelBuilders.java b/src/test/java/com/launchdarkly/client/ModelBuilders.java index 37a583140..fb5facde7 100644 --- a/src/test/java/com/launchdarkly/client/ModelBuilders.java +++ b/src/test/java/com/launchdarkly/client/ModelBuilders.java @@ -1,7 +1,7 @@ package com.launchdarkly.client; import com.google.common.collect.ImmutableList; -import com.launchdarkly.client.FlagModel.FeatureFlag; +import com.launchdarkly.client.DataModel.FeatureFlag; import com.launchdarkly.client.value.LDValue; import java.util.ArrayList; @@ -14,12 +14,12 @@ public static FlagBuilder flagBuilder(String key) { return new FlagBuilder(key); } - public static FlagBuilder flagBuilder(FlagModel.FeatureFlag fromFlag) { + public static FlagBuilder flagBuilder(DataModel.FeatureFlag fromFlag) { return new FlagBuilder(fromFlag); } - public static FlagModel.FeatureFlag booleanFlagWithClauses(String key, FlagModel.Clause... clauses) { - FlagModel.Rule rule = ruleBuilder().variation(1).clauses(clauses).build(); + public static DataModel.FeatureFlag booleanFlagWithClauses(String key, DataModel.Clause... clauses) { + DataModel.Rule rule = ruleBuilder().variation(1).clauses(clauses).build(); return flagBuilder(key) .on(true) .rules(rule) @@ -29,7 +29,7 @@ public static FlagModel.FeatureFlag booleanFlagWithClauses(String key, FlagModel .build(); } - public static FlagModel.FeatureFlag flagWithValue(String key, LDValue value) { + public static DataModel.FeatureFlag flagWithValue(String key, LDValue value) { return flagBuilder(key) .on(false) .offVariation(0) @@ -37,40 +37,40 @@ public static FlagModel.FeatureFlag flagWithValue(String key, LDValue value) { .build(); } - public static FlagModel.VariationOrRollout fallthroughVariation(int variation) { - return new FlagModel.VariationOrRollout(variation, null); + public static DataModel.VariationOrRollout fallthroughVariation(int variation) { + return new DataModel.VariationOrRollout(variation, null); } public static RuleBuilder ruleBuilder() { return new RuleBuilder(); } - public static FlagModel.Clause clause(String attribute, Operator op, boolean negate, LDValue... values) { - return new FlagModel.Clause(attribute, op, Arrays.asList(values), negate); + public static DataModel.Clause clause(String attribute, Operator op, boolean negate, LDValue... values) { + return new DataModel.Clause(attribute, op, Arrays.asList(values), negate); } - public static FlagModel.Clause clause(String attribute, Operator op, LDValue... values) { + public static DataModel.Clause clause(String attribute, Operator op, LDValue... values) { return clause(attribute, op, false, values); } - public static FlagModel.Clause clauseMatchingUser(LDUser user) { + public static DataModel.Clause clauseMatchingUser(LDUser user) { return clause("key", Operator.in, user.getKey()); } - public static FlagModel.Clause clauseNotMatchingUser(LDUser user) { + public static DataModel.Clause clauseNotMatchingUser(LDUser user) { return clause("key", Operator.in, LDValue.of("not-" + user.getKeyAsString())); } - public static FlagModel.Target target(int variation, String... userKeys) { - return new FlagModel.Target(Arrays.asList(userKeys), variation); + public static DataModel.Target target(int variation, String... userKeys) { + return new DataModel.Target(Arrays.asList(userKeys), variation); } - public static FlagModel.Prerequisite prerequisite(String key, int variation) { - return new FlagModel.Prerequisite(key, variation); + public static DataModel.Prerequisite prerequisite(String key, int variation) { + return new DataModel.Prerequisite(key, variation); } - public static FlagModel.Rollout emptyRollout() { - return new FlagModel.Rollout(ImmutableList.of(), null); + public static DataModel.Rollout emptyRollout() { + return new DataModel.Rollout(ImmutableList.of(), null); } public static SegmentBuilder segmentBuilder(String key) { @@ -85,11 +85,11 @@ public static class FlagBuilder { private String key; private int version; private boolean on; - private List prerequisites = new ArrayList<>(); + private List prerequisites = new ArrayList<>(); private String salt; - private List targets = new ArrayList<>(); - private List rules = new ArrayList<>(); - private FlagModel.VariationOrRollout fallthrough; + private List targets = new ArrayList<>(); + private List rules = new ArrayList<>(); + private DataModel.VariationOrRollout fallthrough; private Integer offVariation; private List variations = new ArrayList<>(); private boolean clientSide; @@ -102,7 +102,7 @@ private FlagBuilder(String key) { this.key = key; } - private FlagBuilder(FlagModel.FeatureFlag f) { + private FlagBuilder(DataModel.FeatureFlag f) { if (f != null) { this.key = f.getKey(); this.version = f.getVersion(); @@ -132,7 +132,7 @@ FlagBuilder on(boolean on) { return this; } - FlagBuilder prerequisites(FlagModel.Prerequisite... prerequisites) { + FlagBuilder prerequisites(DataModel.Prerequisite... prerequisites) { this.prerequisites = Arrays.asList(prerequisites); return this; } @@ -142,17 +142,17 @@ FlagBuilder salt(String salt) { return this; } - FlagBuilder targets(FlagModel.Target... targets) { + FlagBuilder targets(DataModel.Target... targets) { this.targets = Arrays.asList(targets); return this; } - FlagBuilder rules(FlagModel.Rule... rules) { + FlagBuilder rules(DataModel.Rule... rules) { this.rules = Arrays.asList(rules); return this; } - FlagBuilder fallthrough(FlagModel.VariationOrRollout fallthrough) { + FlagBuilder fallthrough(DataModel.VariationOrRollout fallthrough) { this.fallthrough = fallthrough; return this; } @@ -192,8 +192,8 @@ FlagBuilder deleted(boolean deleted) { return this; } - FlagModel.FeatureFlag build() { - FeatureFlag flag = new FlagModel.FeatureFlag(key, version, on, prerequisites, salt, targets, rules, fallthrough, offVariation, variations, + DataModel.FeatureFlag build() { + FeatureFlag flag = new DataModel.FeatureFlag(key, version, on, prerequisites, salt, targets, rules, fallthrough, offVariation, variations, clientSide, trackEvents, trackEventsFallthrough, debugEventsUntilDate, deleted); flag.afterDeserialized(); return flag; @@ -202,16 +202,16 @@ FlagModel.FeatureFlag build() { public static class RuleBuilder { private String id; - private List clauses = new ArrayList<>(); + private List clauses = new ArrayList<>(); private Integer variation; - private FlagModel.Rollout rollout; + private DataModel.Rollout rollout; private boolean trackEvents; private RuleBuilder() { } - public FlagModel.Rule build() { - return new FlagModel.Rule(id, clauses, variation, rollout, trackEvents); + public DataModel.Rule build() { + return new DataModel.Rule(id, clauses, variation, rollout, trackEvents); } public RuleBuilder id(String id) { @@ -219,7 +219,7 @@ public RuleBuilder id(String id) { return this; } - public RuleBuilder clauses(FlagModel.Clause... clauses) { + public RuleBuilder clauses(DataModel.Clause... clauses) { this.clauses = ImmutableList.copyOf(clauses); return this; } @@ -229,7 +229,7 @@ public RuleBuilder variation(Integer variation) { return this; } - public RuleBuilder rollout(FlagModel.Rollout rollout) { + public RuleBuilder rollout(DataModel.Rollout rollout) { this.rollout = rollout; return this; } @@ -245,7 +245,7 @@ public static class SegmentBuilder { private List included = new ArrayList<>(); private List excluded = new ArrayList<>(); private String salt = ""; - private List rules = new ArrayList<>(); + private List rules = new ArrayList<>(); private int version = 0; private boolean deleted; @@ -253,7 +253,7 @@ private SegmentBuilder(String key) { this.key = key; } - private SegmentBuilder(FlagModel.Segment from) { + private SegmentBuilder(DataModel.Segment from) { this.key = from.getKey(); this.included = ImmutableList.copyOf(from.getIncluded()); this.excluded = ImmutableList.copyOf(from.getExcluded()); @@ -263,8 +263,8 @@ private SegmentBuilder(FlagModel.Segment from) { this.deleted = from.isDeleted(); } - public FlagModel.Segment build() { - return new FlagModel.Segment(key, included, excluded, salt, rules, version, deleted); + public DataModel.Segment build() { + return new DataModel.Segment(key, included, excluded, salt, rules, version, deleted); } public SegmentBuilder included(String... included) { @@ -282,7 +282,7 @@ public SegmentBuilder salt(String salt) { return this; } - public SegmentBuilder rules(FlagModel.SegmentRule... rules) { + public SegmentBuilder rules(DataModel.SegmentRule... rules) { this.rules = Arrays.asList(rules); return this; } @@ -299,18 +299,18 @@ public SegmentBuilder deleted(boolean deleted) { } public static class SegmentRuleBuilder { - private List clauses = new ArrayList<>(); + private List clauses = new ArrayList<>(); private Integer weight; private String bucketBy; private SegmentRuleBuilder() { } - public FlagModel.SegmentRule build() { - return new FlagModel.SegmentRule(clauses, weight, bucketBy); + public DataModel.SegmentRule build() { + return new DataModel.SegmentRule(clauses, weight, bucketBy); } - public SegmentRuleBuilder clauses(FlagModel.Clause... clauses) { + public SegmentRuleBuilder clauses(DataModel.Clause... clauses) { this.clauses = ImmutableList.copyOf(clauses); return this; } diff --git a/src/test/java/com/launchdarkly/client/PollingProcessorTest.java b/src/test/java/com/launchdarkly/client/PollingProcessorTest.java index 3edc58e27..e6a174131 100644 --- a/src/test/java/com/launchdarkly/client/PollingProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/PollingProcessorTest.java @@ -1,5 +1,7 @@ package com.launchdarkly.client; +import com.launchdarkly.client.interfaces.FeatureStore; + import org.junit.Test; import java.io.IOException; @@ -17,7 +19,7 @@ public class PollingProcessorTest { @Test public void testConnectionOk() throws Exception { MockFeatureRequestor requestor = new MockFeatureRequestor(); - requestor.allData = new FeatureRequestor.AllData(new HashMap(), new HashMap()); + requestor.allData = new FeatureRequestor.AllData(new HashMap(), new HashMap()); FeatureStore store = new InMemoryFeatureStore(); try (PollingProcessor pollingProcessor = new PollingProcessor(LDConfig.DEFAULT, requestor, store)) { @@ -116,11 +118,11 @@ private static class MockFeatureRequestor implements FeatureRequestor { public void close() throws IOException {} - public FlagModel.FeatureFlag getFlag(String featureKey) throws IOException, HttpErrorException { + public DataModel.FeatureFlag getFlag(String featureKey) throws IOException, HttpErrorException { return null; } - public FlagModel.Segment getSegment(String segmentKey) throws IOException, HttpErrorException { + public DataModel.Segment getSegment(String segmentKey) throws IOException, HttpErrorException { return null; } diff --git a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java index e399d6355..58257747d 100644 --- a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java @@ -21,13 +21,13 @@ import javax.net.ssl.SSLHandshakeException; +import static com.launchdarkly.client.DataModel.DataKinds.FEATURES; +import static com.launchdarkly.client.DataModel.DataKinds.SEGMENTS; import static com.launchdarkly.client.ModelBuilders.flagBuilder; import static com.launchdarkly.client.ModelBuilders.segmentBuilder; 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.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -47,10 +47,10 @@ public class StreamProcessorTest extends EasyMockSupport { private static final URI STREAM_URI = URI.create("http://stream.test.com"); private static final String FEATURE1_KEY = "feature1"; private static final int FEATURE1_VERSION = 11; - private static final FlagModel.FeatureFlag FEATURE = flagBuilder(FEATURE1_KEY).version(FEATURE1_VERSION).build(); + private static final DataModel.FeatureFlag FEATURE = flagBuilder(FEATURE1_KEY).version(FEATURE1_VERSION).build(); private static final String SEGMENT1_KEY = "segment1"; private static final int SEGMENT1_VERSION = 22; - private static final FlagModel.Segment SEGMENT = segmentBuilder(SEGMENT1_KEY).version(SEGMENT1_VERSION).build(); + private static final DataModel.Segment SEGMENT = segmentBuilder(SEGMENT1_KEY).version(SEGMENT1_VERSION).build(); private static final String STREAM_RESPONSE_WITH_EMPTY_DATA = "event: put\n" + "data: {\"data\":{\"flags\":{},\"segments\":{}}}\n\n"; @@ -464,17 +464,17 @@ private MessageEvent emptyPutEvent() { return new MessageEvent("{\"data\":{\"flags\":{},\"segments\":{}}}"); } - private void setupRequestorToReturnAllDataWithFlag(FlagModel.FeatureFlag feature) throws Exception { + private void setupRequestorToReturnAllDataWithFlag(DataModel.FeatureFlag feature) throws Exception { FeatureRequestor.AllData data = new FeatureRequestor.AllData( - Collections.singletonMap(feature.getKey(), feature), Collections.emptyMap()); + Collections.singletonMap(feature.getKey(), feature), Collections.emptyMap()); expect(mockRequestor.getAllData()).andReturn(data); } - private void assertFeatureInStore(FlagModel.FeatureFlag feature) { + private void assertFeatureInStore(DataModel.FeatureFlag feature) { assertEquals(feature.getVersion(), featureStore.get(FEATURES, feature.getKey()).getVersion()); } - private void assertSegmentInStore(FlagModel.Segment segment) { + private void assertSegmentInStore(DataModel.Segment segment) { assertEquals(segment.getVersion(), featureStore.get(SEGMENTS, segment.getKey()).getVersion()); } diff --git a/src/test/java/com/launchdarkly/client/TestUtil.java b/src/test/java/com/launchdarkly/client/TestUtil.java index cbac31dd9..cdbbe7587 100644 --- a/src/test/java/com/launchdarkly/client/TestUtil.java +++ b/src/test/java/com/launchdarkly/client/TestUtil.java @@ -5,6 +5,14 @@ import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonPrimitive; +import com.launchdarkly.client.interfaces.EventProcessor; +import com.launchdarkly.client.interfaces.EventProcessorFactory; +import com.launchdarkly.client.interfaces.FeatureStore; +import com.launchdarkly.client.interfaces.FeatureStoreFactory; +import com.launchdarkly.client.interfaces.UpdateProcessor; +import com.launchdarkly.client.interfaces.UpdateProcessorFactory; +import com.launchdarkly.client.interfaces.VersionedData; +import com.launchdarkly.client.interfaces.VersionedDataKind; import com.launchdarkly.client.value.LDValue; import org.hamcrest.Description; diff --git a/src/test/java/com/launchdarkly/client/files/DataLoaderTest.java b/src/test/java/com/launchdarkly/client/files/DataLoaderTest.java index 9145c7d32..98692fde8 100644 --- a/src/test/java/com/launchdarkly/client/files/DataLoaderTest.java +++ b/src/test/java/com/launchdarkly/client/files/DataLoaderTest.java @@ -4,22 +4,23 @@ 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 com.launchdarkly.client.interfaces.VersionedData; +import com.launchdarkly.client.interfaces.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.DataModel.DataKinds.FEATURES; +import static com.launchdarkly.client.DataModel.DataKinds.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; +@SuppressWarnings("javadoc") public class DataLoaderTest { private static final Gson gson = new Gson(); private DataBuilder builder = new DataBuilder(); diff --git a/src/test/java/com/launchdarkly/client/files/FileDataSourceTest.java b/src/test/java/com/launchdarkly/client/files/FileDataSourceTest.java index 62924d9d5..e6535f1d6 100644 --- a/src/test/java/com/launchdarkly/client/files/FileDataSourceTest.java +++ b/src/test/java/com/launchdarkly/client/files/FileDataSourceTest.java @@ -1,10 +1,9 @@ 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 com.launchdarkly.client.interfaces.FeatureStore; +import com.launchdarkly.client.interfaces.UpdateProcessor; import org.junit.Test; @@ -14,6 +13,8 @@ import java.nio.file.Paths; import java.util.concurrent.Future; +import static com.launchdarkly.client.DataModel.DataKinds.FEATURES; +import static com.launchdarkly.client.DataModel.DataKinds.SEGMENTS; 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; @@ -23,6 +24,7 @@ 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"); @@ -42,8 +44,8 @@ private static FileDataSourceFactory makeFactoryWithFile(Path path) { 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)); + assertThat(store.all(FEATURES).size(), equalTo(0)); + assertThat(store.all(SEGMENTS).size(), equalTo(0)); } } @@ -52,8 +54,8 @@ 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)); + assertThat(store.all(FEATURES).keySet(), equalTo(ALL_FLAG_KEYS)); + assertThat(store.all(SEGMENTS).keySet(), equalTo(ALL_SEGMENT_KEYS)); } } @@ -101,8 +103,8 @@ public void modifiedFileIsNotReloadedIfAutoUpdateIsOff() throws Exception { 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)); + assertThat(store.all(FEATURES).size(), equalTo(1)); + assertThat(store.all(SEGMENTS).size(), equalTo(0)); } } finally { file.delete(); @@ -125,7 +127,7 @@ public void modifiedFileIsReloadedIfAutoUpdateIsOn() throws Exception { 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()) { + if (store.all(FEATURES).size() == ALL_FLAG_KEYS.size()) { // success return; } @@ -151,7 +153,7 @@ public void ifFilesAreBadAtStartTimeAutoUpdateCanStillLoadGoodDataLater() throws 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) { + if (store.all(FEATURES).size() > 0) { // success return; } diff --git a/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java b/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java index ff9a75d6a..f67d16fea 100644 --- a/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java +++ b/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java @@ -2,8 +2,8 @@ import com.google.common.collect.ImmutableMap; import com.launchdarkly.client.FeatureStoreCacheConfig; -import com.launchdarkly.client.VersionedData; -import com.launchdarkly.client.VersionedDataKind; +import com.launchdarkly.client.interfaces.VersionedData; +import com.launchdarkly.client.interfaces.VersionedDataKind; import org.junit.Test; import org.junit.runner.RunWith; @@ -419,5 +419,10 @@ public String getStreamApiPath() { public MockItem makeDeletedItem(String key, int version) { return new MockItem(key, version, true); } + + @Override + public MockItem deserialize(String serializedData) { + return null; + } }; } From ba3aeb18e77f10ae887f70e8a649caef21601c3d Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 17 Dec 2019 16:48:22 -0800 Subject: [PATCH 219/673] move Operator into DataModel, move operator logic into EvaluatorOperators and simplify it --- .../client/{FlagModel.java => DataModel.java} | 33 ++++- .../client/DefaultFeatureRequestor.java | 8 +- .../com/launchdarkly/client/Evaluator.java | 60 ++++---- .../client/EvaluatorBucketing.java | 6 +- .../client/EvaluatorOperators.java | 128 +++++++++++++++++ .../com/launchdarkly/client/EventFactory.java | 14 +- .../client/FeatureFlagsState.java | 2 +- .../launchdarkly/client/FeatureRequestor.java | 10 +- .../com/launchdarkly/client/LDClient.java | 12 +- .../com/launchdarkly/client/OperandType.java | 39 ----- .../com/launchdarkly/client/Operator.java | 133 ------------------ .../launchdarkly/client/StreamProcessor.java | 8 +- .../launchdarkly/client/TestFeatureStore.java | 16 +-- .../client/VersionedDataKind.java | 18 +-- .../client/DefaultEventProcessorTest.java | 34 ++--- .../client/EvaluatorClauseTest.java | 42 +++--- .../EvaluatorOperatorsParameterizedTest.java | 126 +++++++++++++++++ ...rTest.java => EvaluatorOperatorsTest.java} | 6 +- .../client/EvaluatorRuleTest.java | 36 ++--- .../client/EvaluatorSegmentMatchTest.java | 40 +++--- .../launchdarkly/client/EvaluatorTest.java | 58 ++++---- .../client/EvaluatorTestUtil.java | 28 ++-- .../launchdarkly/client/EventOutputTest.java | 2 +- .../client/EventSummarizerTest.java | 6 +- .../client/FeatureFlagsStateTest.java | 20 +-- .../client/FeatureRequestorTest.java | 16 +-- .../client/FeatureStoreDatabaseTestBase.java | 28 ++-- .../client/FeatureStoreTestBase.java | 34 ++--- .../client/FlagModelDeserializationTest.java | 4 +- .../client/LDClientEndToEndTest.java | 2 +- .../client/LDClientEvaluationTest.java | 32 ++--- .../client/LDClientEventTest.java | 64 ++++----- .../client/LDClientLddModeTest.java | 2 +- .../com/launchdarkly/client/LDClientTest.java | 6 +- .../launchdarkly/client/ModelBuilders.java | 92 ++++++------ .../client/OperatorParameterizedTest.java | 126 ----------------- .../client/PollingProcessorTest.java | 6 +- .../client/StreamProcessorTest.java | 12 +- 38 files changed, 643 insertions(+), 666 deletions(-) rename src/main/java/com/launchdarkly/client/{FlagModel.java => DataModel.java} (92%) create mode 100644 src/main/java/com/launchdarkly/client/EvaluatorOperators.java delete mode 100644 src/main/java/com/launchdarkly/client/OperandType.java delete mode 100644 src/main/java/com/launchdarkly/client/Operator.java create mode 100644 src/test/java/com/launchdarkly/client/EvaluatorOperatorsParameterizedTest.java rename src/test/java/com/launchdarkly/client/{OperatorTest.java => EvaluatorOperatorsTest.java} (61%) delete mode 100644 src/test/java/com/launchdarkly/client/OperatorParameterizedTest.java diff --git a/src/main/java/com/launchdarkly/client/FlagModel.java b/src/main/java/com/launchdarkly/client/DataModel.java similarity index 92% rename from src/main/java/com/launchdarkly/client/FlagModel.java rename to src/main/java/com/launchdarkly/client/DataModel.java index 42a133fc2..1498c632f 100644 --- a/src/main/java/com/launchdarkly/client/FlagModel.java +++ b/src/main/java/com/launchdarkly/client/DataModel.java @@ -7,13 +7,12 @@ /** * Defines the full data model for feature flags and user segments, in the format provided by the SDK endpoints of - * the LaunchDarkly service. All sub-objects contained within flags and segments are also defined here as inner - * classes. - * - * These classes should all have package-private scope. They should not provide any logic other than standard - * property getters; the evaluation logic is in Evaluator. + * the LaunchDarkly service. */ -abstract class FlagModel { +abstract class DataModel { + // All of these inner data model classes should have package-private scope. They should have only property + // accessors; the evaluator logic is in Evaluator, EvaluatorBucketing, and EvaluatorOperators. + @JsonAdapter(JsonHelpers.PostProcessingDeserializableTypeAdapterFactory.class) static final class FeatureFlag implements VersionedData, JsonHelpers.PostProcessingDeserializable { private String key; @@ -398,4 +397,26 @@ String getBucketBy() { return bucketBy; } } + + /** + * This enum can be directly deserialized from JSON, avoiding the need for a mapping of strings to + * operators. The implementation of each operator is in EvaluatorOperators. + */ + static enum Operator { + in, + endsWith, + startsWith, + matches, + contains, + lessThan, + lessThanOrEqual, + greaterThan, + greaterThanOrEqual, + before, + after, + semVerEqual, + semVerLessThan, + semVerGreaterThan, + segmentMatch + } } diff --git a/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java b/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java index f7b05ee5f..b7440696f 100644 --- a/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java +++ b/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java @@ -54,14 +54,14 @@ public void close() { shutdownHttpClient(httpClient); } - public FlagModel.FeatureFlag getFlag(String featureKey) throws IOException, HttpErrorException { + public DataModel.FeatureFlag getFlag(String featureKey) throws IOException, HttpErrorException { String body = get(GET_LATEST_FLAGS_PATH + "/" + featureKey); - return config.gson.fromJson(body, FlagModel.FeatureFlag.class); + return config.gson.fromJson(body, DataModel.FeatureFlag.class); } - public FlagModel.Segment getSegment(String segmentKey) throws IOException, HttpErrorException { + public DataModel.Segment getSegment(String segmentKey) throws IOException, HttpErrorException { String body = get(GET_LATEST_SEGMENTS_PATH + "/" + segmentKey); - return config.gson.fromJson(body, FlagModel.Segment.class); + return config.gson.fromJson(body, DataModel.Segment.class); } public AllData getAllData() throws IOException, HttpErrorException { diff --git a/src/main/java/com/launchdarkly/client/Evaluator.java b/src/main/java/com/launchdarkly/client/Evaluator.java index 214c13bfe..2d59218e3 100644 --- a/src/main/java/com/launchdarkly/client/Evaluator.java +++ b/src/main/java/com/launchdarkly/client/Evaluator.java @@ -27,8 +27,8 @@ class Evaluator { * and simplifies testing. */ static interface Getters { - FlagModel.FeatureFlag getFlag(String key); - FlagModel.Segment getSegment(String key); + DataModel.FeatureFlag getFlag(String key); + DataModel.Segment getSegment(String key); } /** @@ -105,7 +105,7 @@ private void setPrerequisiteEvents(List prerequisiteEvents * @param eventFactory produces feature request events * @return an {@link EvalResult} */ - EvalResult evaluate(FlagModel.FeatureFlag flag, LDUser user, EventFactory eventFactory) { + EvalResult evaluate(DataModel.FeatureFlag flag, LDUser user, EventFactory eventFactory) { 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", flag.getKey()); @@ -123,7 +123,7 @@ EvalResult evaluate(FlagModel.FeatureFlag flag, LDUser user, EventFactory eventF return result; } - private EvalResult evaluateInternal(FlagModel.FeatureFlag flag, LDUser user, EventFactory eventFactory, + private EvalResult evaluateInternal(DataModel.FeatureFlag flag, LDUser user, EventFactory eventFactory, List eventsOut) { if (!flag.isOn()) { return getOffValue(flag, EvaluationReason.off()); @@ -135,9 +135,9 @@ private EvalResult evaluateInternal(FlagModel.FeatureFlag flag, LDUser user, Eve } // Check to see if targets match - List targets = flag.getTargets(); + List targets = flag.getTargets(); if (targets != null) { - for (FlagModel.Target target: targets) { + for (DataModel.Target target: targets) { for (String v : target.getValues()) { if (v.equals(user.getKey().stringValue())) { return getVariation(flag, target.getVariation(), EvaluationReason.targetMatch()); @@ -146,10 +146,10 @@ private EvalResult evaluateInternal(FlagModel.FeatureFlag flag, LDUser user, Eve } } // Now walk through the rules and see if any match - List rules = flag.getRules(); + List rules = flag.getRules(); if (rules != null) { for (int i = 0; i < rules.size(); i++) { - FlagModel.Rule rule = rules.get(i); + DataModel.Rule rule = rules.get(i); if (ruleMatchesUser(flag, rule, user)) { EvaluationReason.RuleMatch precomputedReason = rule.getRuleMatchReason(); EvaluationReason.RuleMatch reason = precomputedReason != null ? precomputedReason : EvaluationReason.ruleMatch(i, rule.getId()); @@ -163,15 +163,15 @@ private EvalResult evaluateInternal(FlagModel.FeatureFlag flag, LDUser user, Eve // 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(FlagModel.FeatureFlag flag, LDUser user, EventFactory eventFactory, + private EvaluationReason checkPrerequisites(DataModel.FeatureFlag flag, LDUser user, EventFactory eventFactory, List eventsOut) { - List prerequisites = flag.getPrerequisites(); + List prerequisites = flag.getPrerequisites(); if (prerequisites == null) { return null; } - for (FlagModel.Prerequisite prereq: prerequisites) { + for (DataModel.Prerequisite prereq: prerequisites) { boolean prereqOk = true; - FlagModel.FeatureFlag prereqFeatureFlag = getters.getFlag(prereq.getKey()); + DataModel.FeatureFlag prereqFeatureFlag = getters.getFlag(prereq.getKey()); if (prereqFeatureFlag == null) { logger.error("Could not retrieve prerequisite flag \"{}\" when evaluating \"{}\"", prereq.getKey(), flag.getKey()); prereqOk = false; @@ -194,7 +194,7 @@ private EvaluationReason checkPrerequisites(FlagModel.FeatureFlag flag, LDUser u return null; } - private EvalResult getVariation(FlagModel.FeatureFlag flag, int variation, EvaluationReason reason) { + private EvalResult getVariation(DataModel.FeatureFlag flag, int variation, EvaluationReason reason) { List variations = flag.getVariations(); if (variation < 0 || variation >= variations.size()) { logger.error("Data inconsistency in feature flag \"{}\": invalid variation index", flag.getKey()); @@ -204,7 +204,7 @@ private EvalResult getVariation(FlagModel.FeatureFlag flag, int variation, Evalu } } - private EvalResult getOffValue(FlagModel.FeatureFlag flag, EvaluationReason reason) { + private EvalResult getOffValue(DataModel.FeatureFlag flag, EvaluationReason reason) { Integer offVariation = flag.getOffVariation(); if (offVariation == null) { // off variation unspecified - return default value return new EvalResult(null, null, reason); @@ -213,7 +213,7 @@ private EvalResult getOffValue(FlagModel.FeatureFlag flag, EvaluationReason reas } } - private EvalResult getValueForVariationOrRollout(FlagModel.FeatureFlag flag, FlagModel.VariationOrRollout vr, LDUser user, EvaluationReason reason) { + private EvalResult getValueForVariationOrRollout(DataModel.FeatureFlag flag, DataModel.VariationOrRollout vr, LDUser user, EvaluationReason reason) { Integer index = EvaluatorBucketing.variationIndexForUser(vr, user, flag.getKey(), flag.getSalt()); if (index == null) { logger.error("Data inconsistency in feature flag \"{}\": variation/rollout object with no variation or rollout", flag.getKey()); @@ -223,10 +223,10 @@ private EvalResult getValueForVariationOrRollout(FlagModel.FeatureFlag flag, Fla } } - private boolean ruleMatchesUser(FlagModel.FeatureFlag flag, FlagModel.Rule rule, LDUser user) { - Iterable clauses = rule.getClauses(); + private boolean ruleMatchesUser(DataModel.FeatureFlag flag, DataModel.Rule rule, LDUser user) { + Iterable clauses = rule.getClauses(); if (clauses != null) { - for (FlagModel.Clause clause: clauses) { + for (DataModel.Clause clause: clauses) { if (!clauseMatchesUser(clause, user)) { return false; } @@ -235,13 +235,13 @@ private boolean ruleMatchesUser(FlagModel.FeatureFlag flag, FlagModel.Rule rule, return true; } - private boolean clauseMatchesUser(FlagModel.Clause clause, LDUser user) { + private boolean clauseMatchesUser(DataModel.Clause clause, 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 (clause.getOp() == Operator.segmentMatch) { + if (clause.getOp() == DataModel.Operator.segmentMatch) { for (LDValue j: clause.getValues()) { if (j.isString()) { - FlagModel.Segment segment = getters.getSegment(j.stringValue()); + DataModel.Segment segment = getters.getSegment(j.stringValue()); if (segment != null) { if (segmentMatchesUser(segment, user)) { return maybeNegate(clause, true); @@ -255,7 +255,7 @@ private boolean clauseMatchesUser(FlagModel.Clause clause, LDUser user) { return clauseMatchesUserNoSegments(clause, user); } - private boolean clauseMatchesUserNoSegments(FlagModel.Clause clause, LDUser user) { + private boolean clauseMatchesUserNoSegments(DataModel.Clause clause, LDUser user) { LDValue userValue = user.getValueForEvaluation(clause.getAttribute()); if (userValue.isNull()) { return false; @@ -280,11 +280,11 @@ private boolean clauseMatchesUserNoSegments(FlagModel.Clause clause, LDUser user return false; } - private boolean clauseMatchAny(FlagModel.Clause clause, LDValue userValue) { - Operator op = clause.getOp(); + private boolean clauseMatchAny(DataModel.Clause clause, LDValue userValue) { + DataModel.Operator op = clause.getOp(); if (op != null) { for (LDValue v : clause.getValues()) { - if (op.apply(userValue, v)) { + if (EvaluatorOperators.apply(op, userValue, v)) { return true; } } @@ -292,11 +292,11 @@ private boolean clauseMatchAny(FlagModel.Clause clause, LDValue userValue) { return false; } - private boolean maybeNegate(FlagModel.Clause clause, boolean b) { + private boolean maybeNegate(DataModel.Clause clause, boolean b) { return clause.isNegate() ? !b : b; } - private boolean segmentMatchesUser(FlagModel.Segment segment, LDUser user) { + private boolean segmentMatchesUser(DataModel.Segment segment, LDUser user) { String userKey = user.getKeyAsString(); if (userKey == null) { return false; @@ -307,7 +307,7 @@ private boolean segmentMatchesUser(FlagModel.Segment segment, LDUser user) { if (Iterables.contains(segment.getExcluded(), userKey)) { return false; } - for (FlagModel.SegmentRule rule: segment.getRules()) { + for (DataModel.SegmentRule rule: segment.getRules()) { if (segmentRuleMatchesUser(rule, user, segment.getKey(), segment.getSalt())) { return true; } @@ -315,8 +315,8 @@ private boolean segmentMatchesUser(FlagModel.Segment segment, LDUser user) { return false; } - private boolean segmentRuleMatchesUser(FlagModel.SegmentRule segmentRule, LDUser user, String segmentKey, String salt) { - for (FlagModel.Clause c: segmentRule.getClauses()) { + private boolean segmentRuleMatchesUser(DataModel.SegmentRule segmentRule, LDUser user, String segmentKey, String salt) { + for (DataModel.Clause c: segmentRule.getClauses()) { if (!clauseMatchesUserNoSegments(c, user)) { return false; } diff --git a/src/main/java/com/launchdarkly/client/EvaluatorBucketing.java b/src/main/java/com/launchdarkly/client/EvaluatorBucketing.java index d747c62fe..d6856c82d 100644 --- a/src/main/java/com/launchdarkly/client/EvaluatorBucketing.java +++ b/src/main/java/com/launchdarkly/client/EvaluatorBucketing.java @@ -12,16 +12,16 @@ abstract class EvaluatorBucketing { // 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). - static Integer variationIndexForUser(FlagModel.VariationOrRollout vr, LDUser user, String key, String salt) { + static Integer variationIndexForUser(DataModel.VariationOrRollout vr, LDUser user, String key, String salt) { Integer variation = vr.getVariation(); if (variation != null) { return variation; } else { - FlagModel.Rollout rollout = vr.getRollout(); + DataModel.Rollout rollout = vr.getRollout(); if (rollout != null && rollout.getVariations() != null && !rollout.getVariations().isEmpty()) { float bucket = bucketUser(user, key, rollout.getBucketBy(), salt); float sum = 0F; - for (FlagModel.WeightedVariation wv : rollout.getVariations()) { + for (DataModel.WeightedVariation wv : rollout.getVariations()) { sum += (float) wv.getWeight() / 100000F; if (bucket < sum) { return wv.getVariation(); diff --git a/src/main/java/com/launchdarkly/client/EvaluatorOperators.java b/src/main/java/com/launchdarkly/client/EvaluatorOperators.java new file mode 100644 index 000000000..38f4b8897 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/EvaluatorOperators.java @@ -0,0 +1,128 @@ +package com.launchdarkly.client; + +import com.launchdarkly.client.value.LDValue; + +import org.joda.time.DateTime; + +import java.util.regex.Pattern; + +/** + * Defines the behavior of all operators that can be used in feature flag rules and segment rules. + */ +abstract class EvaluatorOperators { + private static enum ComparisonOp { + EQ, + LT, + LTE, + GT, + GTE; + + boolean test(int delta) { + switch (this) { + case EQ: + return delta == 0; + case LT: + return delta < 0; + case LTE: + return delta <= 0; + case GT: + return delta > 0; + case GTE: + return delta >= 0; + } + return false; + } + } + + static boolean apply(DataModel.Operator op, LDValue userValue, LDValue clauseValue) { + switch (op) { + case in: + return userValue.equals(clauseValue); + + case endsWith: + return userValue.isString() && clauseValue.isString() && userValue.stringValue().endsWith(clauseValue.stringValue()); + + case startsWith: + return userValue.isString() && clauseValue.isString() && userValue.stringValue().startsWith(clauseValue.stringValue()); + + case matches: + return userValue.isString() && clauseValue.isString() && + Pattern.compile(clauseValue.stringValue()).matcher(userValue.stringValue()).find(); + + case contains: + return userValue.isString() && clauseValue.isString() && userValue.stringValue().contains(clauseValue.stringValue()); + + case lessThan: + return compareNumeric(ComparisonOp.LT, userValue, clauseValue); + + case lessThanOrEqual: + return compareNumeric(ComparisonOp.LTE, userValue, clauseValue); + + case greaterThan: + return compareNumeric(ComparisonOp.GT, userValue, clauseValue); + + case greaterThanOrEqual: + return compareNumeric(ComparisonOp.GTE, userValue, clauseValue); + + case before: + return compareDate(ComparisonOp.LT, userValue, clauseValue); + + case after: + return compareDate(ComparisonOp.GT, userValue, clauseValue); + + case semVerEqual: + return compareSemVer(ComparisonOp.EQ, userValue, clauseValue); + + case semVerLessThan: + return compareSemVer(ComparisonOp.LT, userValue, clauseValue); + + case semVerGreaterThan: + return compareSemVer(ComparisonOp.GT, userValue, clauseValue); + + case segmentMatch: + // We shouldn't call apply() for this operator, because it is really implemented in + // Evaluator.clauseMatchesUser(). + return false; + }; + return false; + } + + private static boolean compareNumeric(ComparisonOp op, LDValue userValue, LDValue clauseValue) { + if (!userValue.isNumber() || !clauseValue.isNumber()) { + return false; + } + double n1 = userValue.doubleValue(); + double n2 = clauseValue.doubleValue(); + int compare = n1 == n2 ? 0 : (n1 < n2 ? -1 : 1); + return op.test(compare); + } + + private static boolean compareDate(ComparisonOp op, LDValue userValue, LDValue clauseValue) { + DateTime dt1 = Util.jsonPrimitiveToDateTime(userValue); + DateTime dt2 = Util.jsonPrimitiveToDateTime(clauseValue); + if (dt1 == null || dt2 == null) { + return false; + } + return op.test(dt1.compareTo(dt2)); + } + + private static boolean compareSemVer(ComparisonOp op, LDValue userValue, LDValue clauseValue) { + SemanticVersion sv1 = valueToSemVer(userValue); + SemanticVersion sv2 = valueToSemVer(clauseValue); + if (sv1 == null || sv2 == null) { + return false; + } + return op.test(sv1.compareTo(sv2)); + } + + private static SemanticVersion valueToSemVer(LDValue value) { + if (!value.isString()) { + return null; + } + try { + return SemanticVersion.parse(value.stringValue(), true); + } catch (SemanticVersion.InvalidVersionException e) { + return null; + } + } +} diff --git a/src/main/java/com/launchdarkly/client/EventFactory.java b/src/main/java/com/launchdarkly/client/EventFactory.java index 4fe75a681..142fc0a8b 100644 --- a/src/main/java/com/launchdarkly/client/EventFactory.java +++ b/src/main/java/com/launchdarkly/client/EventFactory.java @@ -9,7 +9,7 @@ abstract class EventFactory { protected abstract long getTimestamp(); protected abstract boolean isIncludeReasons(); - public Event.FeatureRequest newFeatureRequestEvent(FlagModel.FeatureFlag flag, LDUser user, LDValue value, + public Event.FeatureRequest newFeatureRequestEvent(DataModel.FeatureFlag flag, LDUser user, LDValue value, Integer variationIndex, EvaluationReason reason, LDValue defaultValue, String prereqOf) { boolean requireExperimentData = isExperiment(flag, reason); return new Event.FeatureRequest( @@ -28,13 +28,13 @@ public Event.FeatureRequest newFeatureRequestEvent(FlagModel.FeatureFlag flag, L ); } - public Event.FeatureRequest newFeatureRequestEvent(FlagModel.FeatureFlag flag, LDUser user, Evaluator.EvalResult result, LDValue defaultVal) { + public Event.FeatureRequest newFeatureRequestEvent(DataModel.FeatureFlag flag, LDUser user, Evaluator.EvalResult 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(FlagModel.FeatureFlag flag, LDUser user, LDValue defaultValue, + public Event.FeatureRequest newDefaultFeatureRequestEvent(DataModel.FeatureFlag flag, LDUser user, LDValue defaultValue, EvaluationReason.ErrorKind errorKind) { return new Event.FeatureRequest(getTimestamp(), flag.getKey(), user, flag.getVersion(), null, defaultValue, defaultValue, isIncludeReasons() ? EvaluationReason.error(errorKind) : null, @@ -47,8 +47,8 @@ public Event.FeatureRequest newUnknownFeatureRequestEvent(String key, LDUser use isIncludeReasons() ? EvaluationReason.error(errorKind) : null, null, false, null, false); } - public Event.FeatureRequest newPrerequisiteFeatureRequestEvent(FlagModel.FeatureFlag prereqFlag, LDUser user, - Evaluator.EvalResult details, FlagModel.FeatureFlag prereqOf) { + public Event.FeatureRequest newPrerequisiteFeatureRequestEvent(DataModel.FeatureFlag prereqFlag, LDUser user, + Evaluator.EvalResult details, DataModel.FeatureFlag prereqOf) { return newFeatureRequestEvent(prereqFlag, user, details == null ? null : details.getValue(), details == null ? null : details.getVariationIndex(), details == null ? null : details.getReason(), LDValue.ofNull(), prereqOf.getKey()); @@ -67,7 +67,7 @@ public Event.Identify newIdentifyEvent(LDUser user) { return new Event.Identify(getTimestamp(), user); } - private boolean isExperiment(FlagModel.FeatureFlag flag, EvaluationReason reason) { + private boolean isExperiment(DataModel.FeatureFlag flag, EvaluationReason reason) { if (reason == null) { // doesn't happen in real life, but possible in testing return false; @@ -86,7 +86,7 @@ private boolean isExperiment(FlagModel.FeatureFlag flag, EvaluationReason reason // 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()) { - FlagModel.Rule rule = flag.getRules().get(ruleIndex); + DataModel.Rule rule = flag.getRules().get(ruleIndex); return rule.isTrackEvents(); } return false; diff --git a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java b/src/main/java/com/launchdarkly/client/FeatureFlagsState.java index 80f915d1e..0334f4f66 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlagsState.java @@ -146,7 +146,7 @@ Builder valid(boolean valid) { } @SuppressWarnings("deprecation") - Builder addFlag(FlagModel.FeatureFlag flag, Evaluator.EvalResult eval) { + Builder addFlag(DataModel.FeatureFlag flag, Evaluator.EvalResult eval) { flagValues.put(flag.getKey(), eval.getValue().asUnsafeJsonElement()); final boolean flagIsTracked = flag.isTrackEvents() || (flag.getDebugEventsUntilDate() != null && flag.getDebugEventsUntilDate() > System.currentTimeMillis()); diff --git a/src/main/java/com/launchdarkly/client/FeatureRequestor.java b/src/main/java/com/launchdarkly/client/FeatureRequestor.java index 5fb63d8e1..38581a401 100644 --- a/src/main/java/com/launchdarkly/client/FeatureRequestor.java +++ b/src/main/java/com/launchdarkly/client/FeatureRequestor.java @@ -5,17 +5,17 @@ import java.util.Map; interface FeatureRequestor extends Closeable { - FlagModel.FeatureFlag getFlag(String featureKey) throws IOException, HttpErrorException; + DataModel.FeatureFlag getFlag(String featureKey) throws IOException, HttpErrorException; - FlagModel.Segment getSegment(String segmentKey) throws IOException, HttpErrorException; + DataModel.Segment getSegment(String segmentKey) throws IOException, HttpErrorException; AllData getAllData() throws IOException, HttpErrorException; static class AllData { - final Map flags; - final Map segments; + final Map flags; + final Map segments; - AllData(Map flags, Map segments) { + AllData(Map flags, Map segments) { this.flags = flags; this.segments = segments; } diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index edb8eefe3..0062a477b 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -81,11 +81,11 @@ public LDClient(String sdkKey, LDConfig config) { this.featureStore = new FeatureStoreClientWrapper(store); this.evaluator = new Evaluator(new Evaluator.Getters() { - public FlagModel.FeatureFlag getFlag(String key) { + public DataModel.FeatureFlag getFlag(String key) { return LDClient.this.featureStore.get(VersionedDataKind.FEATURES, key); } - public FlagModel.Segment getSegment(String key) { + public DataModel.Segment getSegment(String key) { return LDClient.this.featureStore.get(VersionedDataKind.SEGMENTS, key); } }); @@ -202,9 +202,9 @@ public FeatureFlagsState allFlagsState(LDUser user, FlagsStateOption... options) } boolean clientSideOnly = FlagsStateOption.hasOption(options, FlagsStateOption.CLIENT_SIDE_ONLY); - Map flags = featureStore.all(FEATURES); - for (Map.Entry entry : flags.entrySet()) { - FlagModel.FeatureFlag flag = entry.getValue(); + Map flags = featureStore.all(FEATURES); + for (Map.Entry entry : flags.entrySet()) { + DataModel.FeatureFlag flag = entry.getValue(); if (clientSideOnly && !flag.isClientSide()) { continue; } @@ -342,7 +342,7 @@ private Evaluator.EvalResult evaluateInternal(String featureKey, LDUser user, LD } } - FlagModel.FeatureFlag featureFlag = null; + DataModel.FeatureFlag featureFlag = null; try { featureFlag = featureStore.get(FEATURES, featureKey); if (featureFlag == null) { diff --git a/src/main/java/com/launchdarkly/client/OperandType.java b/src/main/java/com/launchdarkly/client/OperandType.java deleted file mode 100644 index 861d155d4..000000000 --- a/src/main/java/com/launchdarkly/client/OperandType.java +++ /dev/null @@ -1,39 +0,0 @@ -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 - * will always yield false. This enum can be directly deserialized from JSON, avoiding the need for a mapping - * of strings to operators. - */ -enum OperandType { - string, - number, - date, - semVer; - - public static OperandType bestGuess(LDValue value) { - return value.isNumber() ? number : string; - } - - public Object getValueAsType(LDValue value) { - switch (this) { - case string: - return value.stringValue(); - case number: - return value.isNumber() ? Double.valueOf(value.doubleValue()) : null; - case date: - return Util.jsonPrimitiveToDateTime(value); - case semVer: - try { - return SemanticVersion.parse(value.stringValue(), true); - } catch (SemanticVersion.InvalidVersionException e) { - return null; - } - default: - return null; - } - } -} diff --git a/src/main/java/com/launchdarkly/client/Operator.java b/src/main/java/com/launchdarkly/client/Operator.java deleted file mode 100644 index e87c92090..000000000 --- a/src/main/java/com/launchdarkly/client/Operator.java +++ /dev/null @@ -1,133 +0,0 @@ -package com.launchdarkly.client; - -import com.google.gson.JsonPrimitive; -import com.launchdarkly.client.value.LDValue; - -import java.util.regex.Pattern; - -/** - * Operator value that can be applied to {@link JsonPrimitive} objects. Incompatible types or other errors - * will always yield false. This enum can be directly deserialized from JSON, avoiding the need for a mapping - * of strings to operators. - */ -enum Operator { - in { - @Override - public boolean apply(LDValue uValue, LDValue cValue) { - if (uValue.equals(cValue)) { - return true; - } - OperandType type = OperandType.bestGuess(uValue); - if (type == OperandType.bestGuess(cValue)) { - return compareValues(ComparisonOp.EQ, uValue, cValue, type); - } - return false; - } - }, - endsWith { - @Override - public boolean apply(LDValue uValue, LDValue cValue) { - return uValue.isString() && cValue.isString() && uValue.stringValue().endsWith(cValue.stringValue()); - } - }, - startsWith { - @Override - public boolean apply(LDValue uValue, LDValue cValue) { - return uValue.isString() && cValue.isString() && uValue.stringValue().startsWith(cValue.stringValue()); - } - }, - matches { - public boolean apply(LDValue uValue, LDValue cValue) { - return uValue.isString() && cValue.isString() && - Pattern.compile(cValue.stringValue()).matcher(uValue.stringValue()).find(); - } - }, - contains { - public boolean apply(LDValue uValue, LDValue cValue) { - return uValue.isString() && cValue.isString() && uValue.stringValue().contains(cValue.stringValue()); - } - }, - lessThan { - public boolean apply(LDValue uValue, LDValue cValue) { - return compareValues(ComparisonOp.LT, uValue, cValue, OperandType.number); - } - }, - lessThanOrEqual { - public boolean apply(LDValue uValue, LDValue cValue) { - return compareValues(ComparisonOp.LTE, uValue, cValue, OperandType.number); - } - }, - greaterThan { - public boolean apply(LDValue uValue, LDValue cValue) { - return compareValues(ComparisonOp.GT, uValue, cValue, OperandType.number); - } - }, - greaterThanOrEqual { - public boolean apply(LDValue uValue, LDValue cValue) { - return compareValues(ComparisonOp.GTE, uValue, cValue, OperandType.number); - } - }, - before { - public boolean apply(LDValue uValue, LDValue cValue) { - return compareValues(ComparisonOp.LT, uValue, cValue, OperandType.date); - } - }, - after { - public boolean apply(LDValue uValue, LDValue cValue) { - return compareValues(ComparisonOp.GT, uValue, cValue, OperandType.date); - } - }, - semVerEqual { - public boolean apply(LDValue uValue, LDValue cValue) { - return compareValues(ComparisonOp.EQ, uValue, cValue, OperandType.semVer); - } - }, - semVerLessThan { - public boolean apply(LDValue uValue, LDValue cValue) { - return compareValues(ComparisonOp.LT, uValue, cValue, OperandType.semVer); - } - }, - semVerGreaterThan { - public boolean apply(LDValue uValue, LDValue cValue) { - return compareValues(ComparisonOp.GT, uValue, cValue, OperandType.semVer); - } - }, - segmentMatch { - 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(LDValue uValue, LDValue cValue); - - 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); - } - - private static enum ComparisonOp { - EQ, - LT, - LTE, - GT, - GTE; - - @SuppressWarnings("unchecked") - public boolean apply(Object a, Object b) { - if (a instanceof Comparable && a.getClass() == b.getClass()) { - int n = ((Comparable)a).compareTo(b); - switch (this) { - case EQ: return (n == 0); - case LT: return (n < 0); - case LTE: return (n <= 0); - case GT: return (n > 0); - case GTE: return (n >= 0); - } - } - return false; - } - } -} diff --git a/src/main/java/com/launchdarkly/client/StreamProcessor.java b/src/main/java/com/launchdarkly/client/StreamProcessor.java index 9172fef18..5bf8e6b02 100644 --- a/src/main/java/com/launchdarkly/client/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/client/StreamProcessor.java @@ -121,9 +121,9 @@ public void onMessage(String name, MessageEvent event) throws Exception { case PATCH: { PatchData data = gson.fromJson(event.getData(), PatchData.class); if (FEATURES.getKeyFromStreamApiPath(data.path) != null) { - store.upsert(FEATURES, gson.fromJson(data.data, FlagModel.FeatureFlag.class)); + store.upsert(FEATURES, gson.fromJson(data.data, DataModel.FeatureFlag.class)); } else if (SEGMENTS.getKeyFromStreamApiPath(data.path) != null) { - store.upsert(SEGMENTS, gson.fromJson(data.data, FlagModel.Segment.class)); + store.upsert(SEGMENTS, gson.fromJson(data.data, DataModel.Segment.class)); } break; } @@ -158,12 +158,12 @@ public void onMessage(String name, MessageEvent event) throws Exception { try { String featureKey = FEATURES.getKeyFromStreamApiPath(path); if (featureKey != null) { - FlagModel.FeatureFlag feature = requestor.getFlag(featureKey); + DataModel.FeatureFlag feature = requestor.getFlag(featureKey); store.upsert(FEATURES, feature); } else { String segmentKey = SEGMENTS.getKeyFromStreamApiPath(path); if (segmentKey != null) { - FlagModel.Segment segment = requestor.getSegment(segmentKey); + DataModel.Segment segment = requestor.getSegment(segmentKey); store.upsert(SEGMENTS, segment); } } diff --git a/src/main/java/com/launchdarkly/client/TestFeatureStore.java b/src/main/java/com/launchdarkly/client/TestFeatureStore.java index 1ffae3969..116d8296a 100644 --- a/src/main/java/com/launchdarkly/client/TestFeatureStore.java +++ b/src/main/java/com/launchdarkly/client/TestFeatureStore.java @@ -32,7 +32,7 @@ public class TestFeatureStore extends InMemoryFeatureStore { * @param value the new value of the feature flag * @return the feature flag */ - public FlagModel.FeatureFlag setBooleanValue(String key, Boolean value) { + public DataModel.FeatureFlag setBooleanValue(String key, Boolean value) { return setJsonValue(key, value == null ? null : new JsonPrimitive(value.booleanValue())); } @@ -43,7 +43,7 @@ public FlagModel.FeatureFlag setBooleanValue(String key, Boolean value) { * @param key the key of the feature flag to evaluate to true * @return the feature flag */ - public FlagModel.FeatureFlag setFeatureTrue(String key) { + public DataModel.FeatureFlag setFeatureTrue(String key) { return setBooleanValue(key, true); } @@ -54,7 +54,7 @@ public FlagModel.FeatureFlag setFeatureTrue(String key) { * @param key the key of the feature flag to evaluate to false * @return the feature flag */ - public FlagModel.FeatureFlag setFeatureFalse(String key) { + public DataModel.FeatureFlag setFeatureFalse(String key) { return setBooleanValue(key, false); } @@ -64,7 +64,7 @@ public FlagModel.FeatureFlag setFeatureFalse(String key) { * @param value the new value of the flag * @return the feature flag */ - public FlagModel.FeatureFlag setIntegerValue(String key, Integer value) { + public DataModel.FeatureFlag setIntegerValue(String key, Integer value) { return setJsonValue(key, new JsonPrimitive(value)); } @@ -74,7 +74,7 @@ public FlagModel.FeatureFlag setIntegerValue(String key, Integer value) { * @param value the new value of the flag * @return the feature flag */ - public FlagModel.FeatureFlag setDoubleValue(String key, Double value) { + public DataModel.FeatureFlag setDoubleValue(String key, Double value) { return setJsonValue(key, new JsonPrimitive(value)); } @@ -84,7 +84,7 @@ public FlagModel.FeatureFlag setDoubleValue(String key, Double value) { * @param value the new value of the flag * @return the feature flag */ - public FlagModel.FeatureFlag setStringValue(String key, String value) { + public DataModel.FeatureFlag setStringValue(String key, String value) { return setJsonValue(key, new JsonPrimitive(value)); } @@ -94,8 +94,8 @@ public FlagModel.FeatureFlag setStringValue(String key, String value) { * @param value the new value of the flag * @return the feature flag */ - public FlagModel.FeatureFlag setJsonValue(String key, JsonElement value) { - FlagModel.FeatureFlag newFeature = new FlagModel.FeatureFlag(key, + public DataModel.FeatureFlag setJsonValue(String key, JsonElement value) { + DataModel.FeatureFlag newFeature = new DataModel.FeatureFlag(key, version.incrementAndGet(), false, null, diff --git a/src/main/java/com/launchdarkly/client/VersionedDataKind.java b/src/main/java/com/launchdarkly/client/VersionedDataKind.java index e745818b4..28ae6d19e 100644 --- a/src/main/java/com/launchdarkly/client/VersionedDataKind.java +++ b/src/main/java/com/launchdarkly/client/VersionedDataKind.java @@ -128,9 +128,9 @@ public int getPriority() { /** * The {@link VersionedDataKind} instance that describes feature flag data. */ - public static VersionedDataKind FEATURES = new Impl("features", FlagModel.FeatureFlag.class, "/flags/", 1) { - public FlagModel.FeatureFlag makeDeletedItem(String key, int version) { - return new FlagModel.FeatureFlag(key, version, false, null, null, null, null, null, null, null, false, false, false, null, true); + public static VersionedDataKind FEATURES = new Impl("features", DataModel.FeatureFlag.class, "/flags/", 1) { + public DataModel.FeatureFlag makeDeletedItem(String key, int version) { + return new DataModel.FeatureFlag(key, version, false, null, null, null, null, null, null, null, false, false, false, null, true); } public boolean isDependencyOrdered() { @@ -138,12 +138,12 @@ public boolean isDependencyOrdered() { } public Iterable getDependencyKeys(VersionedData item) { - FlagModel.FeatureFlag flag = (FlagModel.FeatureFlag)item; + DataModel.FeatureFlag flag = (DataModel.FeatureFlag)item; if (flag.getPrerequisites() == null || flag.getPrerequisites().isEmpty()) { return ImmutableList.of(); } - return transform(flag.getPrerequisites(), new Function() { - public String apply(FlagModel.Prerequisite p) { + return transform(flag.getPrerequisites(), new Function() { + public String apply(DataModel.Prerequisite p) { return p.getKey(); } }); @@ -153,10 +153,10 @@ public String apply(FlagModel.Prerequisite p) { /** * The {@link VersionedDataKind} instance that describes user segment data. */ - public static VersionedDataKind SEGMENTS = new Impl("segments", FlagModel.Segment.class, "/segments/", 0) { + public static VersionedDataKind SEGMENTS = new Impl("segments", DataModel.Segment.class, "/segments/", 0) { - public FlagModel.Segment makeDeletedItem(String key, int version) { - return new FlagModel.Segment(key, null, null, null, null, version, true); + public DataModel.Segment makeDeletedItem(String key, int version) { + return new DataModel.Segment(key, null, null, null, null, version, true); } }; diff --git a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java index a38850ed4..40ac22bf5 100644 --- a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java @@ -81,7 +81,7 @@ public void userIsFilteredInIdentifyEvent() throws Exception { @SuppressWarnings("unchecked") @Test public void individualFeatureEventIsQueuedWithIndexEvent() throws Exception { - FlagModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); @@ -101,7 +101,7 @@ public void individualFeatureEventIsQueuedWithIndexEvent() throws Exception { @SuppressWarnings("unchecked") @Test public void userIsFilteredInIndexEvent() throws Exception { - FlagModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); @@ -123,7 +123,7 @@ public void userIsFilteredInIndexEvent() throws Exception { @SuppressWarnings("unchecked") @Test public void featureEventCanContainInlineUser() throws Exception { - FlagModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); @@ -144,7 +144,7 @@ public void featureEventCanContainInlineUser() throws Exception { @SuppressWarnings("unchecked") @Test public void userIsFilteredInFeatureEvent() throws Exception { - FlagModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); @@ -165,7 +165,7 @@ public void userIsFilteredInFeatureEvent() throws Exception { @SuppressWarnings("unchecked") @Test public void featureEventCanContainReason() throws Exception { - FlagModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); EvaluationReason reason = EvaluationReason.ruleMatch(1, null); Event.FeatureRequest fe = EventFactory.DEFAULT_WITH_REASONS.newFeatureRequestEvent(flag, user, new Evaluator.EvalResult(LDValue.of("value"), 1, reason), LDValue.ofNull()); @@ -186,7 +186,7 @@ public void featureEventCanContainReason() throws Exception { @SuppressWarnings("unchecked") @Test public void indexEventIsStillGeneratedIfInlineUsersIsTrueButFeatureEventIsNotTracked() throws Exception { - FlagModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(false).build(); + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(false).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), null); @@ -208,7 +208,7 @@ public void indexEventIsStillGeneratedIfInlineUsersIsTrueButFeatureEventIsNotTra @Test public void eventKindIsDebugIfFlagIsTemporarilyInDebugMode() throws Exception { long futureTime = System.currentTimeMillis() + 1000000; - FlagModel.FeatureFlag flag = flagBuilder("flagkey").version(11).debugEventsUntilDate(futureTime).build(); + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).debugEventsUntilDate(futureTime).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); @@ -229,7 +229,7 @@ public void eventKindIsDebugIfFlagIsTemporarilyInDebugMode() throws Exception { @Test public void eventCanBeBothTrackedAndDebugged() throws Exception { long futureTime = System.currentTimeMillis() + 1000000; - FlagModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true) + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true) .debugEventsUntilDate(futureTime).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); @@ -257,7 +257,7 @@ public void debugModeExpiresBasedOnClientTimeIfClientTimeIsLaterThanServerTime() MockResponse resp2 = eventsSuccessResponse(); long debugUntil = serverTime + 1000; - FlagModel.FeatureFlag flag = flagBuilder("flagkey").version(11).debugEventsUntilDate(debugUntil).build(); + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).debugEventsUntilDate(debugUntil).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); @@ -290,7 +290,7 @@ public void debugModeExpiresBasedOnServerTimeIfServerTimeIsLaterThanClientTime() MockResponse resp2 = eventsSuccessResponse(); long debugUntil = serverTime - 1000; - FlagModel.FeatureFlag flag = flagBuilder("flagkey").version(11).debugEventsUntilDate(debugUntil).build(); + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).debugEventsUntilDate(debugUntil).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); @@ -319,8 +319,8 @@ public void debugModeExpiresBasedOnServerTimeIfServerTimeIsLaterThanClientTime() @SuppressWarnings("unchecked") @Test public void twoFeatureEventsForSameUserGenerateOnlyOneIndexEvent() throws Exception { - FlagModel.FeatureFlag flag1 = flagBuilder("flagkey1").version(11).trackEvents(true).build(); - FlagModel.FeatureFlag flag2 = flagBuilder("flagkey2").version(22).trackEvents(true).build(); + DataModel.FeatureFlag flag1 = flagBuilder("flagkey1").version(11).trackEvents(true).build(); + DataModel.FeatureFlag flag2 = flagBuilder("flagkey2").version(22).trackEvents(true).build(); LDValue value = LDValue.of("value"); Event.FeatureRequest fe1 = EventFactory.DEFAULT.newFeatureRequestEvent(flag1, user, simpleEvaluation(1, value), LDValue.ofNull()); @@ -345,8 +345,8 @@ public void twoFeatureEventsForSameUserGenerateOnlyOneIndexEvent() throws Except @SuppressWarnings("unchecked") @Test public void nonTrackedEventsAreSummarized() throws Exception { - FlagModel.FeatureFlag flag1 = flagBuilder("flagkey1").version(11).build(); - FlagModel.FeatureFlag flag2 = flagBuilder("flagkey2").version(22).build(); + DataModel.FeatureFlag flag1 = flagBuilder("flagkey1").version(11).build(); + DataModel.FeatureFlag flag2 = flagBuilder("flagkey2").version(22).build(); LDValue value1 = LDValue.of("value1"); LDValue value2 = LDValue.of("value2"); LDValue default1 = LDValue.of("default1"); @@ -630,12 +630,12 @@ private Matcher isIndexEvent(Event sourceEvent, JsonElement user) { ); } - private Matcher isFeatureEvent(Event.FeatureRequest sourceEvent, FlagModel.FeatureFlag flag, boolean debug, JsonElement inlineUser) { + private Matcher isFeatureEvent(Event.FeatureRequest sourceEvent, DataModel.FeatureFlag flag, boolean debug, JsonElement inlineUser) { return isFeatureEvent(sourceEvent, flag, debug, inlineUser, null); } @SuppressWarnings("unchecked") - private Matcher isFeatureEvent(Event.FeatureRequest sourceEvent, FlagModel.FeatureFlag flag, boolean debug, JsonElement inlineUser, + private Matcher isFeatureEvent(Event.FeatureRequest sourceEvent, DataModel.FeatureFlag flag, boolean debug, JsonElement inlineUser, EvaluationReason reason) { return allOf( hasJsonProperty("kind", debug ? "debug" : "feature"), @@ -689,7 +689,7 @@ private Matcher hasSummaryFlag(String key, LDValue defaultVal, Matc ))); } - private Matcher isSummaryEventCounter(FlagModel.FeatureFlag flag, Integer variation, LDValue value, int count) { + private Matcher isSummaryEventCounter(DataModel.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/EvaluatorClauseTest.java b/src/test/java/com/launchdarkly/client/EvaluatorClauseTest.java index d8d70eb17..ef0ad8a71 100644 --- a/src/test/java/com/launchdarkly/client/EvaluatorClauseTest.java +++ b/src/test/java/com/launchdarkly/client/EvaluatorClauseTest.java @@ -22,8 +22,8 @@ public class EvaluatorClauseTest { @Test public void clauseCanMatchBuiltInAttribute() throws Exception { - FlagModel.Clause clause = clause("name", Operator.in, LDValue.of("Bob")); - FlagModel.FeatureFlag f = booleanFlagWithClauses("flag", clause); + DataModel.Clause clause = clause("name", DataModel.Operator.in, LDValue.of("Bob")); + DataModel.FeatureFlag f = booleanFlagWithClauses("flag", clause); LDUser user = new LDUser.Builder("key").name("Bob").build(); assertEquals(LDValue.of(true), BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT).getDetails().getValue()); @@ -31,8 +31,8 @@ public void clauseCanMatchBuiltInAttribute() throws Exception { @Test public void clauseCanMatchCustomAttribute() throws Exception { - FlagModel.Clause clause = clause("legs", Operator.in, LDValue.of(4)); - FlagModel.FeatureFlag f = booleanFlagWithClauses("flag", clause); + DataModel.Clause clause = clause("legs", DataModel.Operator.in, LDValue.of(4)); + DataModel.FeatureFlag f = booleanFlagWithClauses("flag", clause); LDUser user = new LDUser.Builder("key").custom("legs", 4).build(); assertEquals(LDValue.of(true), BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT).getDetails().getValue()); @@ -40,8 +40,8 @@ public void clauseCanMatchCustomAttribute() throws Exception { @Test public void clauseReturnsFalseForMissingAttribute() throws Exception { - FlagModel.Clause clause = clause("legs", Operator.in, LDValue.of(4)); - FlagModel.FeatureFlag f = booleanFlagWithClauses("flag", clause); + DataModel.Clause clause = clause("legs", DataModel.Operator.in, LDValue.of(4)); + DataModel.FeatureFlag f = booleanFlagWithClauses("flag", clause); LDUser user = new LDUser.Builder("key").name("Bob").build(); assertEquals(LDValue.of(false), BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT).getDetails().getValue()); @@ -49,8 +49,8 @@ public void clauseReturnsFalseForMissingAttribute() throws Exception { @Test public void clauseCanBeNegated() throws Exception { - FlagModel.Clause clause = clause("name", Operator.in, true, LDValue.of("Bob")); - FlagModel.FeatureFlag f = booleanFlagWithClauses("flag", clause); + DataModel.Clause clause = clause("name", DataModel.Operator.in, true, LDValue.of("Bob")); + DataModel.FeatureFlag f = booleanFlagWithClauses("flag", clause); LDUser user = new LDUser.Builder("key").name("Bob").build(); assertEquals(LDValue.of(false), BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT).getDetails().getValue()); @@ -63,7 +63,7 @@ public void clauseWithUnsupportedOperatorStringIsUnmarshalledWithNullOperator() // and the SDK hasn't been upgraded yet. String badClauseJson = "{\"attribute\":\"name\",\"operator\":\"doesSomethingUnsupported\",\"values\":[\"x\"]}"; Gson gson = new Gson(); - FlagModel.Clause clause = gson.fromJson(badClauseJson, FlagModel.Clause.class); + DataModel.Clause clause = gson.fromJson(badClauseJson, DataModel.Clause.class); assertNotNull(clause); JsonElement json = gson.toJsonTree(clause); @@ -73,8 +73,8 @@ public void clauseWithUnsupportedOperatorStringIsUnmarshalledWithNullOperator() @Test public void clauseWithNullOperatorDoesNotMatch() throws Exception { - FlagModel.Clause badClause = clause("name", null, LDValue.of("Bob")); - FlagModel.FeatureFlag f = booleanFlagWithClauses("flag", badClause); + DataModel.Clause badClause = clause("name", null, LDValue.of("Bob")); + DataModel.FeatureFlag f = booleanFlagWithClauses("flag", badClause); LDUser user = new LDUser.Builder("key").name("Bob").build(); assertEquals(LDValue.of(false), BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT).getDetails().getValue()); @@ -82,11 +82,11 @@ public void clauseWithNullOperatorDoesNotMatch() throws Exception { @Test public void clauseWithNullOperatorDoesNotStopSubsequentRuleFromMatching() throws Exception { - FlagModel.Clause badClause = clause("name", null, LDValue.of("Bob")); - FlagModel.Rule badRule = ruleBuilder().id("rule1").clauses(badClause).variation(1).build(); - FlagModel.Clause goodClause = clause("name", Operator.in, LDValue.of("Bob")); - FlagModel.Rule goodRule = ruleBuilder().id("rule2").clauses(goodClause).variation(1).build(); - FlagModel.FeatureFlag f = flagBuilder("feature") + DataModel.Clause badClause = clause("name", null, LDValue.of("Bob")); + DataModel.Rule badRule = ruleBuilder().id("rule1").clauses(badClause).variation(1).build(); + DataModel.Clause goodClause = clause("name", DataModel.Operator.in, LDValue.of("Bob")); + DataModel.Rule goodRule = ruleBuilder().id("rule2").clauses(goodClause).variation(1).build(); + DataModel.FeatureFlag f = flagBuilder("feature") .on(true) .rules(badRule, goodRule) .fallthrough(fallthroughVariation(0)) @@ -101,13 +101,13 @@ public void clauseWithNullOperatorDoesNotStopSubsequentRuleFromMatching() throws @Test public void testSegmentMatchClauseRetrievesSegmentFromStore() throws Exception { - FlagModel.Segment segment = segmentBuilder("segkey") + DataModel.Segment segment = segmentBuilder("segkey") .included("foo") .version(1) .build(); Evaluator e = evaluatorBuilder().withStoredSegments(segment).build(); - FlagModel.FeatureFlag flag = segmentMatchBooleanFlag("segkey"); + DataModel.FeatureFlag flag = segmentMatchBooleanFlag("segkey"); LDUser user = new LDUser.Builder("foo").build(); Evaluator.EvalResult result = e.evaluate(flag, user, EventFactory.DEFAULT); @@ -116,7 +116,7 @@ public void testSegmentMatchClauseRetrievesSegmentFromStore() throws Exception { @Test public void testSegmentMatchClauseFallsThroughIfSegmentNotFound() throws Exception { - FlagModel.FeatureFlag flag = segmentMatchBooleanFlag("segkey"); + DataModel.FeatureFlag flag = segmentMatchBooleanFlag("segkey"); LDUser user = new LDUser.Builder("foo").build(); Evaluator e = evaluatorBuilder().withNonexistentSegment("segkey").build(); @@ -124,8 +124,8 @@ public void testSegmentMatchClauseFallsThroughIfSegmentNotFound() throws Excepti assertEquals(LDValue.of(false), result.getDetails().getValue()); } - private FlagModel.FeatureFlag segmentMatchBooleanFlag(String segmentKey) { - FlagModel.Clause clause = clause("", Operator.segmentMatch, LDValue.of(segmentKey)); + private DataModel.FeatureFlag segmentMatchBooleanFlag(String segmentKey) { + DataModel.Clause clause = clause("", DataModel.Operator.segmentMatch, LDValue.of(segmentKey)); return booleanFlagWithClauses("flag", clause); } } diff --git a/src/test/java/com/launchdarkly/client/EvaluatorOperatorsParameterizedTest.java b/src/test/java/com/launchdarkly/client/EvaluatorOperatorsParameterizedTest.java new file mode 100644 index 000000000..dc417c18e --- /dev/null +++ b/src/test/java/com/launchdarkly/client/EvaluatorOperatorsParameterizedTest.java @@ -0,0 +1,126 @@ +package com.launchdarkly.client; + +import com.launchdarkly.client.value.LDValue; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import java.util.Arrays; + +import static org.junit.Assert.assertEquals; + +@SuppressWarnings("javadoc") +@RunWith(Parameterized.class) +public class EvaluatorOperatorsParameterizedTest { + 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 DataModel.Operator op; + private final LDValue aValue; + private final LDValue bValue; + private final boolean shouldBe; + + public EvaluatorOperatorsParameterizedTest(DataModel.Operator op, LDValue aValue, LDValue bValue, boolean shouldBe) { + this.op = op; + this.aValue = aValue; + this.bValue = bValue; + this.shouldBe = shouldBe; + } + + @Parameterized.Parameters(name = "{1} {0} {2} should be {3}") + public static Iterable data() { + return Arrays.asList(new Object[][] { + // numeric comparisons + { DataModel.Operator.in, LDValue.of(99), LDValue.of(99), true }, + { DataModel.Operator.in, LDValue.of(99.0001), LDValue.of(99.0001), true }, + { DataModel.Operator.in, LDValue.of(99), LDValue.of(99.0001), false }, + { DataModel.Operator.in, LDValue.of(99.0001), LDValue.of(99), false }, + { DataModel.Operator.lessThan, LDValue.of(99), LDValue.of(99.0001), true }, + { DataModel.Operator.lessThan, LDValue.of(99.0001), LDValue.of(99), false }, + { DataModel.Operator.lessThan, LDValue.of(99), LDValue.of(99), false }, + { DataModel.Operator.lessThanOrEqual, LDValue.of(99), LDValue.of(99.0001), true }, + { DataModel.Operator.lessThanOrEqual, LDValue.of(99.0001), LDValue.of(99), false }, + { DataModel.Operator.lessThanOrEqual, LDValue.of(99), LDValue.of(99), true }, + { DataModel.Operator.greaterThan, LDValue.of(99.0001), LDValue.of(99), true }, + { DataModel.Operator.greaterThan, LDValue.of(99), LDValue.of(99.0001), false }, + { DataModel.Operator.greaterThan, LDValue.of(99), LDValue.of(99), false }, + { DataModel.Operator.greaterThanOrEqual, LDValue.of(99.0001), LDValue.of(99), true }, + { DataModel.Operator.greaterThanOrEqual, LDValue.of(99), LDValue.of(99.0001), false }, + { DataModel.Operator.greaterThanOrEqual, LDValue.of(99), LDValue.of(99), true }, + + // string comparisons + { DataModel.Operator.in, LDValue.of("x"), LDValue.of("x"), true }, + { DataModel.Operator.in, LDValue.of("x"), LDValue.of("xyz"), false }, + { DataModel.Operator.startsWith, LDValue.of("xyz"), LDValue.of("x"), true }, + { DataModel.Operator.startsWith, LDValue.of("x"), LDValue.of("xyz"), false }, + { DataModel.Operator.endsWith, LDValue.of("xyz"), LDValue.of("z"), true }, + { DataModel.Operator.endsWith, LDValue.of("z"), LDValue.of("xyz"), false }, + { DataModel.Operator.contains, LDValue.of("xyz"), LDValue.of("y"), true }, + { DataModel.Operator.contains, LDValue.of("y"), LDValue.of("xyz"), false }, + + // mixed strings and numbers + { DataModel.Operator.in, LDValue.of("99"), LDValue.of(99), false }, + { DataModel.Operator.in, LDValue.of(99), LDValue.of("99"), false }, + { DataModel.Operator.contains, LDValue.of("99"), LDValue.of(99), false }, + { DataModel.Operator.startsWith, LDValue.of("99"), LDValue.of(99), false }, + { DataModel.Operator.endsWith, LDValue.of("99"), LDValue.of(99), false }, + { DataModel.Operator.lessThanOrEqual, LDValue.of("99"), LDValue.of(99), false }, + { DataModel.Operator.lessThanOrEqual, LDValue.of(99), LDValue.of("99"), false }, + { DataModel.Operator.greaterThanOrEqual, LDValue.of("99"), LDValue.of(99), false }, + { DataModel.Operator.greaterThanOrEqual, LDValue.of(99), LDValue.of("99"), false }, + + // regex + { DataModel.Operator.matches, LDValue.of("hello world"), LDValue.of("hello.*rld"), true }, + { DataModel.Operator.matches, LDValue.of("hello world"), LDValue.of("hello.*orl"), true }, + { DataModel.Operator.matches, LDValue.of("hello world"), LDValue.of("l+"), true }, + { DataModel.Operator.matches, LDValue.of("hello world"), LDValue.of("(world|planet)"), true }, + { DataModel.Operator.matches, LDValue.of("hello world"), LDValue.of("aloha"), false }, + + // dates + { DataModel.Operator.before, dateStr1, dateStr2, true }, + { DataModel.Operator.before, dateMs1, dateMs2, true }, + { DataModel.Operator.before, dateStr2, dateStr1, false }, + { DataModel.Operator.before, dateMs2, dateMs1, false }, + { DataModel.Operator.before, dateStr1, dateStr1, false }, + { DataModel.Operator.before, dateMs1, dateMs1, false }, + { DataModel.Operator.before, dateStr1, invalidDate, false }, + { DataModel.Operator.after, dateStr1, dateStr2, false }, + { DataModel.Operator.after, dateMs1, dateMs2, false }, + { DataModel.Operator.after, dateStr2, dateStr1, true }, + { DataModel.Operator.after, dateMs2, dateMs1, true }, + { DataModel.Operator.after, dateStr1, dateStr1, false }, + { DataModel.Operator.after, dateMs1, dateMs1, false }, + { DataModel.Operator.after, dateStr1, invalidDate, false }, + + // semver + { DataModel.Operator.semVerEqual, LDValue.of("2.0.1"), LDValue.of("2.0.1"), true }, + { DataModel.Operator.semVerEqual, LDValue.of("2.0"), LDValue.of("2.0.0"), true }, + { DataModel.Operator.semVerEqual, LDValue.of("2"), LDValue.of("2.0.0"), true }, + { DataModel.Operator.semVerEqual, LDValue.of("2-rc1"), LDValue.of("2.0.0-rc1"), true }, + { DataModel.Operator.semVerEqual, LDValue.of("2+build2"), LDValue.of("2.0.0+build2"), true }, + { DataModel.Operator.semVerLessThan, LDValue.of("2.0.0"), LDValue.of("2.0.1"), true }, + { DataModel.Operator.semVerLessThan, LDValue.of("2.0"), LDValue.of("2.0.1"), true }, + { DataModel.Operator.semVerLessThan, LDValue.of("2.0.1"), LDValue.of("2.0.0"), false }, + { DataModel.Operator.semVerLessThan, LDValue.of("2.0.1"), LDValue.of("2.0"), false }, + { DataModel.Operator.semVerLessThan, LDValue.of("2.0.0-rc"), LDValue.of("2.0.0"), true }, + { DataModel.Operator.semVerLessThan, LDValue.of("2.0.0-rc"), LDValue.of("2.0.0-rc.beta"), true }, + { DataModel.Operator.semVerGreaterThan, LDValue.of("2.0.1"), LDValue.of("2.0.0"), true }, + { DataModel.Operator.semVerGreaterThan, LDValue.of("2.0.1"), LDValue.of("2.0"), true }, + { DataModel.Operator.semVerGreaterThan, LDValue.of("2.0.0"), LDValue.of("2.0.1"), false }, + { DataModel.Operator.semVerGreaterThan, LDValue.of("2.0"), LDValue.of("2.0.1"), false }, + { DataModel.Operator.semVerGreaterThan, LDValue.of("2.0.0-rc.1"), LDValue.of("2.0.0-rc.0"), true }, + { DataModel.Operator.semVerLessThan, LDValue.of("2.0.1"), invalidVer, false }, + { DataModel.Operator.semVerGreaterThan, LDValue.of("2.0.1"), invalidVer, false } + }); + } + + @Test + public void parameterizedTestComparison() { + assertEquals(shouldBe, EvaluatorOperators.apply(op, aValue, bValue)); + } +} diff --git a/src/test/java/com/launchdarkly/client/OperatorTest.java b/src/test/java/com/launchdarkly/client/EvaluatorOperatorsTest.java similarity index 61% rename from src/test/java/com/launchdarkly/client/OperatorTest.java rename to src/test/java/com/launchdarkly/client/EvaluatorOperatorsTest.java index 4b55d4c0b..3070b0387 100644 --- a/src/test/java/com/launchdarkly/client/OperatorTest.java +++ b/src/test/java/com/launchdarkly/client/EvaluatorOperatorsTest.java @@ -8,12 +8,12 @@ import static org.junit.Assert.assertFalse; -// Any special-case tests that can't be handled by OperatorParameterizedTest. +// Any special-case tests that can't be handled by EvaluatorOperatorsParameterizedTest. @SuppressWarnings("javadoc") -public class OperatorTest { +public class EvaluatorOperatorsTest { // This is probably not desired behavior, but it is the current behavior @Test(expected = PatternSyntaxException.class) public void testInvalidRegexThrowsException() { - assertFalse(Operator.matches.apply(LDValue.of("hello world"), LDValue.of("***not a regex"))); + assertFalse(EvaluatorOperators.apply(DataModel.Operator.matches, LDValue.of("hello world"), LDValue.of("***not a regex"))); } } diff --git a/src/test/java/com/launchdarkly/client/EvaluatorRuleTest.java b/src/test/java/com/launchdarkly/client/EvaluatorRuleTest.java index e9372ee45..2ae15bcbe 100644 --- a/src/test/java/com/launchdarkly/client/EvaluatorRuleTest.java +++ b/src/test/java/com/launchdarkly/client/EvaluatorRuleTest.java @@ -19,11 +19,11 @@ public class EvaluatorRuleTest { @Test public void ruleMatchReasonInstanceIsReusedForSameRule() { - FlagModel.Clause clause0 = clause("key", Operator.in, LDValue.of("wrongkey")); - FlagModel.Clause clause1 = clause("key", Operator.in, LDValue.of("userkey")); - FlagModel.Rule rule0 = ruleBuilder().id("ruleid0").clauses(clause0).variation(2).build(); - FlagModel.Rule rule1 = ruleBuilder().id("ruleid1").clauses(clause1).variation(2).build(); - FlagModel.FeatureFlag f = featureFlagWithRules("feature", rule0, rule1); + DataModel.Clause clause0 = clause("key", DataModel.Operator.in, LDValue.of("wrongkey")); + DataModel.Clause clause1 = clause("key", DataModel.Operator.in, LDValue.of("userkey")); + DataModel.Rule rule0 = ruleBuilder().id("ruleid0").clauses(clause0).variation(2).build(); + DataModel.Rule rule1 = ruleBuilder().id("ruleid1").clauses(clause1).variation(2).build(); + DataModel.FeatureFlag f = featureFlagWithRules("feature", rule0, rule1); LDUser user = new LDUser.Builder("userkey").build(); LDUser otherUser = new LDUser.Builder("wrongkey").build(); @@ -39,9 +39,9 @@ public void ruleMatchReasonInstanceIsReusedForSameRule() { @Test public void ruleWithTooHighVariationReturnsMalformedFlagError() { - FlagModel.Clause clause = clause("key", Operator.in, LDValue.of("userkey")); - FlagModel.Rule rule = ruleBuilder().id("ruleid").clauses(clause).variation(999).build(); - FlagModel.FeatureFlag f = featureFlagWithRules("feature", rule); + DataModel.Clause clause = clause("key", DataModel.Operator.in, LDValue.of("userkey")); + DataModel.Rule rule = ruleBuilder().id("ruleid").clauses(clause).variation(999).build(); + DataModel.FeatureFlag f = featureFlagWithRules("feature", rule); LDUser user = new LDUser.Builder("userkey").build(); Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); @@ -51,9 +51,9 @@ public void ruleWithTooHighVariationReturnsMalformedFlagError() { @Test public void ruleWithNegativeVariationReturnsMalformedFlagError() { - FlagModel.Clause clause = clause("key", Operator.in, LDValue.of("userkey")); - FlagModel.Rule rule = ruleBuilder().id("ruleid").clauses(clause).variation(-1).build(); - FlagModel.FeatureFlag f = featureFlagWithRules("feature", rule); + DataModel.Clause clause = clause("key", DataModel.Operator.in, LDValue.of("userkey")); + DataModel.Rule rule = ruleBuilder().id("ruleid").clauses(clause).variation(-1).build(); + DataModel.FeatureFlag f = featureFlagWithRules("feature", rule); LDUser user = new LDUser.Builder("userkey").build(); Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); @@ -63,9 +63,9 @@ public void ruleWithNegativeVariationReturnsMalformedFlagError() { @Test public void ruleWithNoVariationOrRolloutReturnsMalformedFlagError() { - FlagModel.Clause clause = clause("key", Operator.in, LDValue.of("userkey")); - FlagModel.Rule rule = ruleBuilder().id("ruleid").clauses(clause).build(); - FlagModel.FeatureFlag f = featureFlagWithRules("feature", rule); + DataModel.Clause clause = clause("key", DataModel.Operator.in, LDValue.of("userkey")); + DataModel.Rule rule = ruleBuilder().id("ruleid").clauses(clause).build(); + DataModel.FeatureFlag f = featureFlagWithRules("feature", rule); LDUser user = new LDUser.Builder("userkey").build(); Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); @@ -75,9 +75,9 @@ public void ruleWithNoVariationOrRolloutReturnsMalformedFlagError() { @Test public void ruleWithRolloutWithEmptyVariationsListReturnsMalformedFlagError() { - FlagModel.Clause clause = clause("key", Operator.in, LDValue.of("userkey")); - FlagModel.Rule rule = ruleBuilder().id("ruleid").clauses(clause).rollout(emptyRollout()).build(); - FlagModel.FeatureFlag f = featureFlagWithRules("feature", rule); + DataModel.Clause clause = clause("key", DataModel.Operator.in, LDValue.of("userkey")); + DataModel.Rule rule = ruleBuilder().id("ruleid").clauses(clause).rollout(emptyRollout()).build(); + DataModel.FeatureFlag f = featureFlagWithRules("feature", rule); LDUser user = new LDUser.Builder("userkey").build(); Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); @@ -85,7 +85,7 @@ public void ruleWithRolloutWithEmptyVariationsListReturnsMalformedFlagError() { assertThat(result.getPrerequisiteEvents(), emptyIterable()); } - private FlagModel.FeatureFlag featureFlagWithRules(String flagKey, FlagModel.Rule... rules) { + private DataModel.FeatureFlag featureFlagWithRules(String flagKey, DataModel.Rule... rules) { return flagBuilder(flagKey) .on(true) .rules(rules) diff --git a/src/test/java/com/launchdarkly/client/EvaluatorSegmentMatchTest.java b/src/test/java/com/launchdarkly/client/EvaluatorSegmentMatchTest.java index 84f8f7d9c..931dea809 100644 --- a/src/test/java/com/launchdarkly/client/EvaluatorSegmentMatchTest.java +++ b/src/test/java/com/launchdarkly/client/EvaluatorSegmentMatchTest.java @@ -19,7 +19,7 @@ public class EvaluatorSegmentMatchTest { @Test public void explicitIncludeUser() { - FlagModel.Segment s = segmentBuilder("test") + DataModel.Segment s = segmentBuilder("test") .included("foo") .salt("abcdef") .version(1) @@ -31,7 +31,7 @@ public void explicitIncludeUser() { @Test public void explicitExcludeUser() { - FlagModel.Segment s = segmentBuilder("test") + DataModel.Segment s = segmentBuilder("test") .excluded("foo") .salt("abcdef") .version(1) @@ -43,7 +43,7 @@ public void explicitExcludeUser() { @Test public void explicitIncludeHasPrecedence() { - FlagModel.Segment s = segmentBuilder("test") + DataModel.Segment s = segmentBuilder("test") .included("foo") .excluded("foo") .salt("abcdef") @@ -56,9 +56,9 @@ public void explicitIncludeHasPrecedence() { @Test public void matchingRuleWithFullRollout() { - FlagModel.Clause clause = clause("email", Operator.in, LDValue.of("test@example.com")); - FlagModel.SegmentRule rule = segmentRuleBuilder().clauses(clause).weight(maxWeight).build(); - FlagModel.Segment s = segmentBuilder("test") + DataModel.Clause clause = clause("email", DataModel.Operator.in, LDValue.of("test@example.com")); + DataModel.SegmentRule rule = segmentRuleBuilder().clauses(clause).weight(maxWeight).build(); + DataModel.Segment s = segmentBuilder("test") .salt("abcdef") .rules(rule) .build(); @@ -69,9 +69,9 @@ public void matchingRuleWithFullRollout() { @Test public void matchingRuleWithZeroRollout() { - FlagModel.Clause clause = clause("email", Operator.in, LDValue.of("test@example.com")); - FlagModel.SegmentRule rule = segmentRuleBuilder().clauses(clause).weight(0).build(); - FlagModel.Segment s = segmentBuilder("test") + DataModel.Clause clause = clause("email", DataModel.Operator.in, LDValue.of("test@example.com")); + DataModel.SegmentRule rule = segmentRuleBuilder().clauses(clause).weight(0).build(); + DataModel.Segment s = segmentBuilder("test") .salt("abcdef") .rules(rule) .build(); @@ -82,10 +82,10 @@ public void matchingRuleWithZeroRollout() { @Test public void matchingRuleWithMultipleClauses() { - FlagModel.Clause clause1 = clause("email", Operator.in, LDValue.of("test@example.com")); - FlagModel.Clause clause2 = clause("name", Operator.in, LDValue.of("bob")); - FlagModel.SegmentRule rule = segmentRuleBuilder().clauses(clause1, clause2).build(); - FlagModel.Segment s = segmentBuilder("test") + DataModel.Clause clause1 = clause("email", DataModel.Operator.in, LDValue.of("test@example.com")); + DataModel.Clause clause2 = clause("name", DataModel.Operator.in, LDValue.of("bob")); + DataModel.SegmentRule rule = segmentRuleBuilder().clauses(clause1, clause2).build(); + DataModel.Segment s = segmentBuilder("test") .salt("abcdef") .rules(rule) .build(); @@ -96,10 +96,10 @@ public void matchingRuleWithMultipleClauses() { @Test public void nonMatchingRuleWithMultipleClauses() { - FlagModel.Clause clause1 = clause("email", Operator.in, LDValue.of("test@example.com")); - FlagModel.Clause clause2 = clause("name", Operator.in, LDValue.of("bill")); - FlagModel.SegmentRule rule = segmentRuleBuilder().clauses(clause1, clause2).build(); - FlagModel.Segment s = segmentBuilder("test") + DataModel.Clause clause1 = clause("email", DataModel.Operator.in, LDValue.of("test@example.com")); + DataModel.Clause clause2 = clause("name", DataModel.Operator.in, LDValue.of("bill")); + DataModel.SegmentRule rule = segmentRuleBuilder().clauses(clause1, clause2).build(); + DataModel.Segment s = segmentBuilder("test") .salt("abcdef") .rules(rule) .build(); @@ -108,9 +108,9 @@ public void nonMatchingRuleWithMultipleClauses() { assertFalse(segmentMatchesUser(s, u)); } - private static boolean segmentMatchesUser(FlagModel.Segment segment, LDUser user) { - FlagModel.Clause clause = clause("", Operator.segmentMatch, LDValue.of(segment.getKey())); - FlagModel.FeatureFlag flag = booleanFlagWithClauses("flag", clause); + private static boolean segmentMatchesUser(DataModel.Segment segment, LDUser user) { + DataModel.Clause clause = clause("", DataModel.Operator.segmentMatch, LDValue.of(segment.getKey())); + DataModel.FeatureFlag flag = booleanFlagWithClauses("flag", clause); Evaluator e = evaluatorBuilder().withStoredSegments(segment).build(); return e.evaluate(flag, user, EventFactory.DEFAULT).getValue().booleanValue(); } diff --git a/src/test/java/com/launchdarkly/client/EvaluatorTest.java b/src/test/java/com/launchdarkly/client/EvaluatorTest.java index 366127e6d..b56e45781 100644 --- a/src/test/java/com/launchdarkly/client/EvaluatorTest.java +++ b/src/test/java/com/launchdarkly/client/EvaluatorTest.java @@ -26,7 +26,7 @@ public class EvaluatorTest { @Test public void flagReturnsOffVariationIfFlagIsOff() throws Exception { - FlagModel.FeatureFlag f = flagBuilder("feature") + DataModel.FeatureFlag f = flagBuilder("feature") .on(false) .offVariation(1) .fallthrough(fallthroughVariation(0)) @@ -40,7 +40,7 @@ public void flagReturnsOffVariationIfFlagIsOff() throws Exception { @Test public void flagReturnsNullIfFlagIsOffAndOffVariationIsUnspecified() throws Exception { - FlagModel.FeatureFlag f = flagBuilder("feature") + DataModel.FeatureFlag f = flagBuilder("feature") .on(false) .fallthrough(fallthroughVariation(0)) .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) @@ -53,7 +53,7 @@ public void flagReturnsNullIfFlagIsOffAndOffVariationIsUnspecified() throws Exce @Test public void flagReturnsErrorIfFlagIsOffAndOffVariationIsTooHigh() throws Exception { - FlagModel.FeatureFlag f = flagBuilder("feature") + DataModel.FeatureFlag f = flagBuilder("feature") .on(false) .offVariation(999) .fallthrough(fallthroughVariation(0)) @@ -67,7 +67,7 @@ public void flagReturnsErrorIfFlagIsOffAndOffVariationIsTooHigh() throws Excepti @Test public void flagReturnsErrorIfFlagIsOffAndOffVariationIsNegative() throws Exception { - FlagModel.FeatureFlag f = flagBuilder("feature") + DataModel.FeatureFlag f = flagBuilder("feature") .on(false) .offVariation(-1) .fallthrough(fallthroughVariation(0)) @@ -81,7 +81,7 @@ public void flagReturnsErrorIfFlagIsOffAndOffVariationIsNegative() throws Except @Test public void flagReturnsFallthroughIfFlagIsOnAndThereAreNoRules() throws Exception { - FlagModel.FeatureFlag f = flagBuilder("feature") + DataModel.FeatureFlag f = flagBuilder("feature") .on(true) .offVariation(1) .fallthrough(fallthroughVariation(0)) @@ -95,7 +95,7 @@ public void flagReturnsFallthroughIfFlagIsOnAndThereAreNoRules() throws Exceptio @Test public void flagReturnsErrorIfFallthroughHasTooHighVariation() throws Exception { - FlagModel.FeatureFlag f = flagBuilder("feature") + DataModel.FeatureFlag f = flagBuilder("feature") .on(true) .offVariation(1) .fallthrough(fallthroughVariation(999)) @@ -109,7 +109,7 @@ public void flagReturnsErrorIfFallthroughHasTooHighVariation() throws Exception @Test public void flagReturnsErrorIfFallthroughHasNegativeVariation() throws Exception { - FlagModel.FeatureFlag f = flagBuilder("feature") + DataModel.FeatureFlag f = flagBuilder("feature") .on(true) .offVariation(1) .fallthrough(fallthroughVariation(-1)) @@ -123,10 +123,10 @@ public void flagReturnsErrorIfFallthroughHasNegativeVariation() throws Exception @Test public void flagReturnsErrorIfFallthroughHasNeitherVariationNorRollout() throws Exception { - FlagModel.FeatureFlag f = flagBuilder("feature") + DataModel.FeatureFlag f = flagBuilder("feature") .on(true) .offVariation(1) - .fallthrough(new FlagModel.VariationOrRollout(null, null)) + .fallthrough(new DataModel.VariationOrRollout(null, null)) .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .build(); Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); @@ -137,10 +137,10 @@ public void flagReturnsErrorIfFallthroughHasNeitherVariationNorRollout() throws @Test public void flagReturnsErrorIfFallthroughHasEmptyRolloutVariationList() throws Exception { - FlagModel.FeatureFlag f = flagBuilder("feature") + DataModel.FeatureFlag f = flagBuilder("feature") .on(true) .offVariation(1) - .fallthrough(new FlagModel.VariationOrRollout(null, ModelBuilders.emptyRollout())) + .fallthrough(new DataModel.VariationOrRollout(null, ModelBuilders.emptyRollout())) .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .build(); Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); @@ -151,7 +151,7 @@ public void flagReturnsErrorIfFallthroughHasEmptyRolloutVariationList() throws E @Test public void flagReturnsOffVariationIfPrerequisiteIsNotFound() throws Exception { - FlagModel.FeatureFlag f0 = flagBuilder("feature0") + DataModel.FeatureFlag f0 = flagBuilder("feature0") .on(true) .prerequisites(prerequisite("feature1", 1)) .fallthrough(fallthroughVariation(0)) @@ -168,7 +168,7 @@ public void flagReturnsOffVariationIfPrerequisiteIsNotFound() throws Exception { @Test public void flagReturnsOffVariationAndEventIfPrerequisiteIsOff() throws Exception { - FlagModel.FeatureFlag f0 = flagBuilder("feature0") + DataModel.FeatureFlag f0 = flagBuilder("feature0") .on(true) .prerequisites(prerequisite("feature1", 1)) .fallthrough(fallthroughVariation(0)) @@ -176,7 +176,7 @@ public void flagReturnsOffVariationAndEventIfPrerequisiteIsOff() throws Exceptio .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .version(1) .build(); - FlagModel.FeatureFlag f1 = flagBuilder("feature1") + DataModel.FeatureFlag f1 = flagBuilder("feature1") .on(false) .offVariation(1) // note that even though it returns the desired variation, it is still off and therefore not a match @@ -200,7 +200,7 @@ public void flagReturnsOffVariationAndEventIfPrerequisiteIsOff() throws Exceptio @Test public void flagReturnsOffVariationAndEventIfPrerequisiteIsNotMet() throws Exception { - FlagModel.FeatureFlag f0 = flagBuilder("feature0") + DataModel.FeatureFlag f0 = flagBuilder("feature0") .on(true) .prerequisites(prerequisite("feature1", 1)) .fallthrough(fallthroughVariation(0)) @@ -208,7 +208,7 @@ public void flagReturnsOffVariationAndEventIfPrerequisiteIsNotMet() throws Excep .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .version(1) .build(); - FlagModel.FeatureFlag f1 = flagBuilder("feature1") + DataModel.FeatureFlag f1 = flagBuilder("feature1") .on(true) .fallthrough(fallthroughVariation(0)) .variations(LDValue.of("nogo"), LDValue.of("go")) @@ -230,7 +230,7 @@ public void flagReturnsOffVariationAndEventIfPrerequisiteIsNotMet() throws Excep @Test public void prerequisiteFailedReasonInstanceIsReusedForSamePrerequisite() throws Exception { - FlagModel.FeatureFlag f0 = flagBuilder("feature0") + DataModel.FeatureFlag f0 = flagBuilder("feature0") .on(true) .prerequisites(prerequisite("feature1", 1)) .fallthrough(fallthroughVariation(0)) @@ -248,7 +248,7 @@ public void prerequisiteFailedReasonInstanceIsReusedForSamePrerequisite() throws @Test public void flagReturnsFallthroughVariationAndEventIfPrerequisiteIsMetAndThereAreNoRules() throws Exception { - FlagModel.FeatureFlag f0 = flagBuilder("feature0") + DataModel.FeatureFlag f0 = flagBuilder("feature0") .on(true) .prerequisites(prerequisite("feature1", 1)) .fallthrough(fallthroughVariation(0)) @@ -256,7 +256,7 @@ public void flagReturnsFallthroughVariationAndEventIfPrerequisiteIsMetAndThereAr .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .version(1) .build(); - FlagModel.FeatureFlag f1 = flagBuilder("feature1") + DataModel.FeatureFlag f1 = flagBuilder("feature1") .on(true) .fallthrough(fallthroughVariation(1)) .variations(LDValue.of("nogo"), LDValue.of("go")) @@ -277,7 +277,7 @@ public void flagReturnsFallthroughVariationAndEventIfPrerequisiteIsMetAndThereAr @Test public void multipleLevelsOfPrerequisitesProduceMultipleEvents() throws Exception { - FlagModel.FeatureFlag f0 = flagBuilder("feature0") + DataModel.FeatureFlag f0 = flagBuilder("feature0") .on(true) .prerequisites(prerequisite("feature1", 1)) .fallthrough(fallthroughVariation(0)) @@ -285,14 +285,14 @@ public void multipleLevelsOfPrerequisitesProduceMultipleEvents() throws Exceptio .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .version(1) .build(); - FlagModel.FeatureFlag f1 = flagBuilder("feature1") + DataModel.FeatureFlag f1 = flagBuilder("feature1") .on(true) .prerequisites(prerequisite("feature2", 1)) .fallthrough(fallthroughVariation(1)) .variations(LDValue.of("nogo"), LDValue.of("go")) .version(2) .build(); - FlagModel.FeatureFlag f2 = flagBuilder("feature2") + DataModel.FeatureFlag f2 = flagBuilder("feature2") .on(true) .fallthrough(fallthroughVariation(1)) .variations(LDValue.of("nogo"), LDValue.of("go")) @@ -319,7 +319,7 @@ public void multipleLevelsOfPrerequisitesProduceMultipleEvents() throws Exceptio @Test public void flagMatchesUserFromTargets() throws Exception { - FlagModel.FeatureFlag f = flagBuilder("feature") + DataModel.FeatureFlag f = flagBuilder("feature") .on(true) .targets(target(2, "whoever", "userkey")) .fallthrough(fallthroughVariation(0)) @@ -335,11 +335,11 @@ public void flagMatchesUserFromTargets() throws Exception { @Test public void flagMatchesUserFromRules() { - FlagModel.Clause clause0 = clause("key", Operator.in, LDValue.of("wrongkey")); - FlagModel.Clause clause1 = clause("key", Operator.in, LDValue.of("userkey")); - FlagModel.Rule rule0 = ruleBuilder().id("ruleid0").clauses(clause0).variation(2).build(); - FlagModel.Rule rule1 = ruleBuilder().id("ruleid1").clauses(clause1).variation(2).build(); - FlagModel.FeatureFlag f = featureFlagWithRules("feature", rule0, rule1); + DataModel.Clause clause0 = clause("key", DataModel.Operator.in, LDValue.of("wrongkey")); + DataModel.Clause clause1 = clause("key", DataModel.Operator.in, LDValue.of("userkey")); + DataModel.Rule rule0 = ruleBuilder().id("ruleid0").clauses(clause0).variation(2).build(); + DataModel.Rule rule1 = ruleBuilder().id("ruleid1").clauses(clause1).variation(2).build(); + DataModel.FeatureFlag f = featureFlagWithRules("feature", rule0, rule1); LDUser user = new LDUser.Builder("userkey").build(); Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); @@ -347,7 +347,7 @@ public void flagMatchesUserFromRules() { assertThat(result.getPrerequisiteEvents(), emptyIterable()); } - private FlagModel.FeatureFlag featureFlagWithRules(String flagKey, FlagModel.Rule... rules) { + private DataModel.FeatureFlag featureFlagWithRules(String flagKey, DataModel.Rule... rules) { return flagBuilder(flagKey) .on(true) .rules(rules) diff --git a/src/test/java/com/launchdarkly/client/EvaluatorTestUtil.java b/src/test/java/com/launchdarkly/client/EvaluatorTestUtil.java index 993a76c35..7806d1f69 100644 --- a/src/test/java/com/launchdarkly/client/EvaluatorTestUtil.java +++ b/src/test/java/com/launchdarkly/client/EvaluatorTestUtil.java @@ -13,11 +13,11 @@ public static class EvaluatorBuilder { EvaluatorBuilder() { getters = new Evaluator.Getters() { - public FlagModel.FeatureFlag getFlag(String key) { + public DataModel.FeatureFlag getFlag(String key) { throw new IllegalStateException("Evaluator unexpectedly tried to query flag: " + key); } - public FlagModel.Segment getSegment(String key) { + public DataModel.Segment getSegment(String key) { throw new IllegalStateException("Evaluator unexpectedly tried to query segment: " + key); } }; @@ -27,11 +27,11 @@ public Evaluator build() { return new Evaluator(getters); } - public EvaluatorBuilder withStoredFlags(final FlagModel.FeatureFlag... flags) { + public EvaluatorBuilder withStoredFlags(final DataModel.FeatureFlag... flags) { final Evaluator.Getters baseGetters = getters; getters = new Evaluator.Getters() { - public FlagModel.FeatureFlag getFlag(String key) { - for (FlagModel.FeatureFlag f: flags) { + public DataModel.FeatureFlag getFlag(String key) { + for (DataModel.FeatureFlag f: flags) { if (f.getKey().equals(key)) { return f; } @@ -39,7 +39,7 @@ public FlagModel.FeatureFlag getFlag(String key) { return baseGetters.getFlag(key); } - public FlagModel.Segment getSegment(String key) { + public DataModel.Segment getSegment(String key) { return baseGetters.getSegment(key); } }; @@ -49,29 +49,29 @@ public FlagModel.Segment getSegment(String key) { public EvaluatorBuilder withNonexistentFlag(final String nonexistentFlagKey) { final Evaluator.Getters baseGetters = getters; getters = new Evaluator.Getters() { - public FlagModel.FeatureFlag getFlag(String key) { + public DataModel.FeatureFlag getFlag(String key) { if (key.equals(nonexistentFlagKey)) { return null; } return baseGetters.getFlag(key); } - public FlagModel.Segment getSegment(String key) { + public DataModel.Segment getSegment(String key) { return baseGetters.getSegment(key); } }; return this; } - public EvaluatorBuilder withStoredSegments(final FlagModel.Segment... segments) { + public EvaluatorBuilder withStoredSegments(final DataModel.Segment... segments) { final Evaluator.Getters baseGetters = getters; getters = new Evaluator.Getters() { - public FlagModel.FeatureFlag getFlag(String key) { + public DataModel.FeatureFlag getFlag(String key) { return baseGetters.getFlag(key); } - public FlagModel.Segment getSegment(String key) { - for (FlagModel.Segment s: segments) { + public DataModel.Segment getSegment(String key) { + for (DataModel.Segment s: segments) { if (s.getKey().equals(key)) { return s; } @@ -85,11 +85,11 @@ public FlagModel.Segment getSegment(String key) { public EvaluatorBuilder withNonexistentSegment(final String nonexistentSegmentKey) { final Evaluator.Getters baseGetters = getters; getters = new Evaluator.Getters() { - public FlagModel.FeatureFlag getFlag(String key) { + public DataModel.FeatureFlag getFlag(String key) { return baseGetters.getFlag(key); } - public FlagModel.Segment getSegment(String key) { + public DataModel.Segment getSegment(String key) { if (key.equals(nonexistentSegmentKey)) { return null; } diff --git a/src/test/java/com/launchdarkly/client/EventOutputTest.java b/src/test/java/com/launchdarkly/client/EventOutputTest.java index e9ea3d585..cebc4eb20 100644 --- a/src/test/java/com/launchdarkly/client/EventOutputTest.java +++ b/src/test/java/com/launchdarkly/client/EventOutputTest.java @@ -163,7 +163,7 @@ private void testPrivateAttributes(LDConfig config, LDUser user, String... priva public void featureEventIsSerialized() throws Exception { EventFactory factory = eventFactoryWithTimestamp(100000, false); EventFactory factoryWithReason = eventFactoryWithTimestamp(100000, true); - FlagModel.FeatureFlag flag = flagBuilder("flag").version(11).build(); + DataModel.FeatureFlag flag = flagBuilder("flag").version(11).build(); LDUser user = new LDUser.Builder("userkey").name("me").build(); EventOutputFormatter f = new EventOutputFormatter(LDConfig.DEFAULT); diff --git a/src/test/java/com/launchdarkly/client/EventSummarizerTest.java b/src/test/java/com/launchdarkly/client/EventSummarizerTest.java index c67e53743..c1919a345 100644 --- a/src/test/java/com/launchdarkly/client/EventSummarizerTest.java +++ b/src/test/java/com/launchdarkly/client/EventSummarizerTest.java @@ -51,7 +51,7 @@ public void summarizeEventDoesNothingForCustomEvent() { @Test public void summarizeEventSetsStartAndEndDates() { EventSummarizer es = new EventSummarizer(); - FlagModel.FeatureFlag flag = flagBuilder("key").build(); + DataModel.FeatureFlag flag = flagBuilder("key").build(); eventTimestamp = 2000; Event event1 = eventFactory.newFeatureRequestEvent(flag, user, null, null); eventTimestamp = 1000; @@ -70,8 +70,8 @@ public void summarizeEventSetsStartAndEndDates() { @Test public void summarizeEventIncrementsCounters() { EventSummarizer es = new EventSummarizer(); - FlagModel.FeatureFlag flag1 = flagBuilder("key1").version(11).build(); - FlagModel.FeatureFlag flag2 = flagBuilder("key2").version(22).build(); + DataModel.FeatureFlag flag1 = flagBuilder("key1").version(11).build(); + DataModel.FeatureFlag flag2 = flagBuilder("key2").version(22).build(); String unknownFlagKey = "badkey"; Event event1 = eventFactory.newFeatureRequestEvent(flag1, user, simpleEvaluation(1, LDValue.of("value1")), LDValue.of("default1")); diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java b/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java index 4250bccaf..3649cf402 100644 --- a/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java @@ -19,7 +19,7 @@ public class FeatureFlagsStateTest { @Test public void canGetFlagValue() { Evaluator.EvalResult eval = new Evaluator.EvalResult(LDValue.of("value"), 1, EvaluationReason.off()); - FlagModel.FeatureFlag flag = flagBuilder("key").build(); + DataModel.FeatureFlag flag = flagBuilder("key").build(); FeatureFlagsState state = new FeatureFlagsState.Builder().addFlag(flag, eval).build(); assertEquals(js("value"), state.getFlagValue("key")); @@ -35,7 +35,7 @@ public void unknownFlagReturnsNullValue() { @Test public void canGetFlagReason() { Evaluator.EvalResult eval = new Evaluator.EvalResult(LDValue.of("value1"), 1, EvaluationReason.off()); - FlagModel.FeatureFlag flag = flagBuilder("key").build(); + DataModel.FeatureFlag flag = flagBuilder("key").build(); FeatureFlagsState state = new FeatureFlagsState.Builder(FlagsStateOption.WITH_REASONS) .addFlag(flag, eval).build(); @@ -52,7 +52,7 @@ public void unknownFlagReturnsNullReason() { @Test public void reasonIsNullIfReasonsWereNotRecorded() { Evaluator.EvalResult eval = new Evaluator.EvalResult(LDValue.of("value1"), 1, EvaluationReason.off()); - FlagModel.FeatureFlag flag = flagBuilder("key").build(); + DataModel.FeatureFlag flag = flagBuilder("key").build(); FeatureFlagsState state = new FeatureFlagsState.Builder().addFlag(flag, eval).build(); assertNull(state.getFlagReason("key")); @@ -61,7 +61,7 @@ public void reasonIsNullIfReasonsWereNotRecorded() { @Test public void flagCanHaveNullValue() { Evaluator.EvalResult eval = new Evaluator.EvalResult(LDValue.ofNull(), 1, null); - FlagModel.FeatureFlag flag = flagBuilder("key").build(); + DataModel.FeatureFlag flag = flagBuilder("key").build(); FeatureFlagsState state = new FeatureFlagsState.Builder().addFlag(flag, eval).build(); assertNull(state.getFlagValue("key")); @@ -70,9 +70,9 @@ public void flagCanHaveNullValue() { @Test public void canConvertToValuesMap() { Evaluator.EvalResult eval1 = new Evaluator.EvalResult(LDValue.of("value1"), 0, EvaluationReason.off()); - FlagModel.FeatureFlag flag1 = flagBuilder("key1").build(); + DataModel.FeatureFlag flag1 = flagBuilder("key1").build(); Evaluator.EvalResult eval2 = new Evaluator.EvalResult(LDValue.of("value2"), 1, EvaluationReason.fallthrough()); - FlagModel.FeatureFlag flag2 = flagBuilder("key2").build(); + DataModel.FeatureFlag flag2 = flagBuilder("key2").build(); FeatureFlagsState state = new FeatureFlagsState.Builder() .addFlag(flag1, eval1).addFlag(flag2, eval2).build(); @@ -83,9 +83,9 @@ public void canConvertToValuesMap() { @Test public void canConvertToJson() { Evaluator.EvalResult eval1 = new Evaluator.EvalResult(LDValue.of("value1"), 0, EvaluationReason.off()); - FlagModel.FeatureFlag flag1 = flagBuilder("key1").version(100).trackEvents(false).build(); + DataModel.FeatureFlag flag1 = flagBuilder("key1").version(100).trackEvents(false).build(); Evaluator.EvalResult eval2 = new Evaluator.EvalResult(LDValue.of("value2"), 1, EvaluationReason.fallthrough()); - FlagModel.FeatureFlag flag2 = flagBuilder("key2").version(200).trackEvents(true).debugEventsUntilDate(1000L).build(); + DataModel.FeatureFlag flag2 = flagBuilder("key2").version(200).trackEvents(true).debugEventsUntilDate(1000L).build(); FeatureFlagsState state = new FeatureFlagsState.Builder(FlagsStateOption.WITH_REASONS) .addFlag(flag1, eval1).addFlag(flag2, eval2).build(); @@ -106,9 +106,9 @@ public void canConvertToJson() { @Test public void canConvertFromJson() { Evaluator.EvalResult eval1 = new Evaluator.EvalResult(LDValue.of("value1"), 0, EvaluationReason.off()); - FlagModel.FeatureFlag flag1 = flagBuilder("key1").version(100).trackEvents(false).build(); + DataModel.FeatureFlag flag1 = flagBuilder("key1").version(100).trackEvents(false).build(); Evaluator.EvalResult eval2 = new Evaluator.EvalResult(LDValue.of("value2"), 1, EvaluationReason.off()); - FlagModel.FeatureFlag flag2 = flagBuilder("key2").version(200).trackEvents(true).debugEventsUntilDate(1000L).build(); + DataModel.FeatureFlag flag2 = flagBuilder("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/FeatureRequestorTest.java b/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java index 2f1a00212..33a1505f5 100644 --- a/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java @@ -62,7 +62,7 @@ public void requestFlag() throws Exception { try (MockWebServer server = makeStartedServer(resp)) { try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(sdkKey, basePollingConfig(server).build())) { - FlagModel.FeatureFlag flag = r.getFlag(flag1Key); + DataModel.FeatureFlag flag = r.getFlag(flag1Key); RecordedRequest req = server.takeRequest(); assertEquals("/sdk/latest-flags/" + flag1Key, req.getPath()); @@ -79,7 +79,7 @@ public void requestSegment() throws Exception { try (MockWebServer server = makeStartedServer(resp)) { try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(sdkKey, basePollingConfig(server).build())) { - FlagModel.Segment segment = r.getSegment(segment1Key); + DataModel.Segment segment = r.getSegment(segment1Key); RecordedRequest req = server.takeRequest(); assertEquals("/sdk/latest-segments/" + segment1Key, req.getPath()); @@ -130,7 +130,7 @@ public void requestsAreCached() throws Exception { try (MockWebServer server = makeStartedServer(cacheableResp)) { try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(sdkKey, basePollingConfig(server).build())) { - FlagModel.FeatureFlag flag1a = r.getFlag(flag1Key); + DataModel.FeatureFlag flag1a = r.getFlag(flag1Key); RecordedRequest req1 = server.takeRequest(); assertEquals("/sdk/latest-flags/" + flag1Key, req1.getPath()); @@ -138,7 +138,7 @@ public void requestsAreCached() throws Exception { verifyFlag(flag1a, flag1Key); - FlagModel.FeatureFlag flag1b = r.getFlag(flag1Key); + DataModel.FeatureFlag flag1b = r.getFlag(flag1Key); verifyFlag(flag1b, flag1Key); assertNull(server.takeRequest(0, TimeUnit.SECONDS)); // there was no second request, due to the cache hit } @@ -172,7 +172,7 @@ public void httpClientCanUseCustomTlsConfig() throws Exception { .build(); try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(sdkKey, config)) { - FlagModel.FeatureFlag flag = r.getFlag(flag1Key); + DataModel.FeatureFlag flag = r.getFlag(flag1Key); verifyFlag(flag, flag1Key); } } @@ -190,7 +190,7 @@ public void httpClientCanUseProxyConfig() throws Exception { .build(); try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(sdkKey, config)) { - FlagModel.FeatureFlag flag = r.getFlag(flag1Key); + DataModel.FeatureFlag flag = r.getFlag(flag1Key); verifyFlag(flag, flag1Key); assertEquals(1, server.getRequestCount()); @@ -208,12 +208,12 @@ private void verifyHeaders(RecordedRequest req) { assertEquals("JavaClient/" + LDClient.CLIENT_VERSION, req.getHeader("User-Agent")); } - private void verifyFlag(FlagModel.FeatureFlag flag, String key) { + private void verifyFlag(DataModel.FeatureFlag flag, String key) { assertNotNull(flag); assertEquals(key, flag.getKey()); } - private void verifySegment(FlagModel.Segment segment, String key) { + private void verifySegment(DataModel.Segment segment, String key) { assertNotNull(segment); assertEquals(key, segment.getKey()); } diff --git a/src/test/java/com/launchdarkly/client/FeatureStoreDatabaseTestBase.java b/src/test/java/com/launchdarkly/client/FeatureStoreDatabaseTestBase.java index 0ca41606e..460411b24 100644 --- a/src/test/java/com/launchdarkly/client/FeatureStoreDatabaseTestBase.java +++ b/src/test/java/com/launchdarkly/client/FeatureStoreDatabaseTestBase.java @@ -134,13 +134,13 @@ public void handlesUpsertRaceConditionAgainstExternalClientWithLowerVersion() th final int store2VersionEnd = 4; int store1VersionEnd = 10; - final FlagModel.FeatureFlag flag1 = flagBuilder("foo").version(startVersion).build(); + final DataModel.FeatureFlag flag1 = flagBuilder("foo").version(startVersion).build(); Runnable concurrentModifier = new Runnable() { int versionCounter = store2VersionStart; public void run() { if (versionCounter <= store2VersionEnd) { - FlagModel.FeatureFlag f = flagBuilder(flag1).version(versionCounter).build(); + DataModel.FeatureFlag f = flagBuilder(flag1).version(versionCounter).build(); store2.upsert(FEATURES, f); versionCounter++; } @@ -152,10 +152,10 @@ public void run() { store.init(new DataBuilder().add(FEATURES, flag1).build()); - FlagModel.FeatureFlag store1End = flagBuilder(flag1).version(store1VersionEnd).build(); + DataModel.FeatureFlag store1End = flagBuilder(flag1).version(store1VersionEnd).build(); store.upsert(FEATURES, store1End); - FlagModel.FeatureFlag result = store.get(FEATURES, flag1.getKey()); + DataModel.FeatureFlag result = store.get(FEATURES, flag1.getKey()); assertEquals(store1VersionEnd, result.getVersion()); } finally { store2.close(); @@ -170,11 +170,11 @@ public void handlesUpsertRaceConditionAgainstExternalClientWithHigherVersion() t final int store2Version = 3; int store1VersionEnd = 2; - final FlagModel.FeatureFlag flag1 = flagBuilder("foo").version(startVersion).build(); + final DataModel.FeatureFlag flag1 = flagBuilder("foo").version(startVersion).build(); Runnable concurrentModifier = new Runnable() { public void run() { - FlagModel.FeatureFlag f = flagBuilder(flag1).version(store2Version).build(); + DataModel.FeatureFlag f = flagBuilder(flag1).version(store2Version).build(); store2.upsert(FEATURES, f); } }; @@ -184,10 +184,10 @@ public void run() { store.init(new DataBuilder().add(FEATURES, flag1).build()); - FlagModel.FeatureFlag store1End = flagBuilder(flag1).version(store1VersionEnd).build(); + DataModel.FeatureFlag store1End = flagBuilder(flag1).version(store1VersionEnd).build(); store.upsert(FEATURES, store1End); - FlagModel.FeatureFlag result = store.get(FEATURES, flag1.getKey()); + DataModel.FeatureFlag result = store.get(FEATURES, flag1.getKey()); assertEquals(store2Version, result.getVersion()); } finally { store2.close(); @@ -207,10 +207,10 @@ public void storesWithDifferentPrefixAreIndependent() throws Exception { assertFalse(store1.initialized()); assertFalse(store2.initialized()); - FlagModel.FeatureFlag flag1a = flagBuilder("flag-a").version(1).build(); - FlagModel.FeatureFlag flag1b = flagBuilder("flag-b").version(1).build(); - FlagModel.FeatureFlag flag2a = flagBuilder("flag-a").version(2).build(); - FlagModel.FeatureFlag flag2c = flagBuilder("flag-c").version(2).build(); + DataModel.FeatureFlag flag1a = flagBuilder("flag-a").version(1).build(); + DataModel.FeatureFlag flag1b = flagBuilder("flag-b").version(1).build(); + DataModel.FeatureFlag flag2a = flagBuilder("flag-a").version(2).build(); + DataModel.FeatureFlag flag2c = flagBuilder("flag-c").version(2).build(); store1.init(new DataBuilder().add(FEATURES, flag1a, flag1b).build()); assertTrue(store1.initialized()); @@ -220,8 +220,8 @@ public void storesWithDifferentPrefixAreIndependent() throws Exception { assertTrue(store1.initialized()); assertTrue(store2.initialized()); - Map items1 = store1.all(FEATURES); - Map items2 = store2.all(FEATURES); + 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()); diff --git a/src/test/java/com/launchdarkly/client/FeatureStoreTestBase.java b/src/test/java/com/launchdarkly/client/FeatureStoreTestBase.java index 6cc46e98f..c70cda8af 100644 --- a/src/test/java/com/launchdarkly/client/FeatureStoreTestBase.java +++ b/src/test/java/com/launchdarkly/client/FeatureStoreTestBase.java @@ -28,17 +28,17 @@ public abstract class FeatureStoreTestBase { protected T store; protected boolean cached; - protected FlagModel.FeatureFlag feature1 = flagBuilder("foo") + protected DataModel.FeatureFlag feature1 = flagBuilder("foo") .version(10) .salt("abc") .build(); - protected FlagModel.FeatureFlag feature2 = flagBuilder("bar") + protected DataModel.FeatureFlag feature2 = flagBuilder("bar") .version(10) .salt("abc") .build(); - protected FlagModel.Segment segment1 = segmentBuilder("foo") + protected DataModel.Segment segment1 = segmentBuilder("foo") .version(11) .build(); @@ -95,12 +95,12 @@ public void initCompletelyReplacesPreviousData() { new DataBuilder().add(FEATURES, feature1, feature2).add(SEGMENTS, segment1).build(); store.init(allData); - FlagModel.FeatureFlag feature2v2 = flagBuilder(feature2).version(feature2.getVersion() + 1).build(); + DataModel.FeatureFlag feature2v2 = flagBuilder(feature2).version(feature2.getVersion() + 1).build(); allData = new DataBuilder().add(FEATURES, feature2v2).add(SEGMENTS).build(); store.init(allData); assertNull(store.get(FEATURES, feature1.getKey())); - FlagModel.FeatureFlag item2 = store.get(FEATURES, feature2.getKey()); + DataModel.FeatureFlag item2 = store.get(FEATURES, feature2.getKey()); assertNotNull(item2); assertEquals(feature2v2.getVersion(), item2.getVersion()); assertNull(store.get(SEGMENTS, segment1.getKey())); @@ -109,7 +109,7 @@ public void initCompletelyReplacesPreviousData() { @Test public void getExistingFeature() { store.init(new DataBuilder().add(FEATURES, feature1, feature2).build()); - FlagModel.FeatureFlag result = store.get(FEATURES, feature1.getKey()); + DataModel.FeatureFlag result = store.get(FEATURES, feature1.getKey()); assertEquals(feature1.getKey(), result.getKey()); } @@ -122,12 +122,12 @@ public void getNonexistingFeature() { @Test public void getAll() { store.init(new DataBuilder().add(FEATURES, feature1, feature2).add(SEGMENTS, segment1).build()); - Map items = store.all(FEATURES); + Map items = store.all(FEATURES); assertEquals(2, items.size()); - FlagModel.FeatureFlag item1 = items.get(feature1.getKey()); + DataModel.FeatureFlag item1 = items.get(feature1.getKey()); assertNotNull(item1); assertEquals(feature1.getVersion(), item1.getVersion()); - FlagModel.FeatureFlag item2 = items.get(feature2.getKey()); + DataModel.FeatureFlag item2 = items.get(feature2.getKey()); assertNotNull(item2); assertEquals(feature2.getVersion(), item2.getVersion()); } @@ -136,9 +136,9 @@ public void getAll() { 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); + Map items = store.all(FEATURES); assertEquals(1, items.size()); - FlagModel.FeatureFlag item2 = items.get(feature2.getKey()); + DataModel.FeatureFlag item2 = items.get(feature2.getKey()); assertNotNull(item2); assertEquals(feature2.getVersion(), item2.getVersion()); } @@ -146,33 +146,33 @@ public void getAllWithDeletedItem() { @Test public void upsertWithNewerVersion() { store.init(new DataBuilder().add(FEATURES, feature1, feature2).build()); - FlagModel.FeatureFlag newVer = flagBuilder(feature1) + DataModel.FeatureFlag newVer = flagBuilder(feature1) .version(feature1.getVersion() + 1) .build(); store.upsert(FEATURES, newVer); - FlagModel.FeatureFlag result = store.get(FEATURES, newVer.getKey()); + DataModel.FeatureFlag result = store.get(FEATURES, newVer.getKey()); assertEquals(newVer.getVersion(), result.getVersion()); } @Test public void upsertWithOlderVersion() { store.init(new DataBuilder().add(FEATURES, feature1, feature2).build()); - FlagModel.FeatureFlag oldVer = flagBuilder(feature1) + DataModel.FeatureFlag oldVer = flagBuilder(feature1) .version(feature1.getVersion() - 1) .build(); store.upsert(FEATURES, oldVer); - FlagModel.FeatureFlag result = store.get(FEATURES, oldVer.getKey()); + DataModel.FeatureFlag result = store.get(FEATURES, oldVer.getKey()); assertEquals(feature1.getVersion(), result.getVersion()); } @Test public void upsertNewFeature() { store.init(new DataBuilder().add(FEATURES, feature1, feature2).build()); - FlagModel.FeatureFlag newFeature = flagBuilder("biz") + DataModel.FeatureFlag newFeature = flagBuilder("biz") .version(99) .build(); store.upsert(FEATURES, newFeature); - FlagModel.FeatureFlag result = store.get(FEATURES, newFeature.getKey()); + DataModel.FeatureFlag result = store.get(FEATURES, newFeature.getKey()); assertEquals(newFeature.getKey(), result.getKey()); } diff --git a/src/test/java/com/launchdarkly/client/FlagModelDeserializationTest.java b/src/test/java/com/launchdarkly/client/FlagModelDeserializationTest.java index 985fa1119..ee6d63956 100644 --- a/src/test/java/com/launchdarkly/client/FlagModelDeserializationTest.java +++ b/src/test/java/com/launchdarkly/client/FlagModelDeserializationTest.java @@ -14,7 +14,7 @@ public class FlagModelDeserializationTest { @Test public void precomputedReasonsAreAddedToPrerequisites() { String flagJson = "{\"key\":\"flagkey\",\"prerequisites\":[{\"key\":\"prereq0\"},{\"key\":\"prereq1\"}]}"; - FlagModel.FeatureFlag flag = gson.fromJson(flagJson, FlagModel.FeatureFlag.class); + DataModel.FeatureFlag flag = gson.fromJson(flagJson, DataModel.FeatureFlag.class); assertNotNull(flag.getPrerequisites()); assertEquals(2, flag.getPrerequisites().size()); assertEquals(EvaluationReason.prerequisiteFailed("prereq0"), flag.getPrerequisites().get(0).getPrerequisiteFailedReason()); @@ -24,7 +24,7 @@ public void precomputedReasonsAreAddedToPrerequisites() { @Test public void precomputedReasonsAreAddedToRules() { String flagJson = "{\"key\":\"flagkey\",\"rules\":[{\"id\":\"ruleid0\"},{\"id\":\"ruleid1\"}]}"; - FlagModel.FeatureFlag flag = gson.fromJson(flagJson, FlagModel.FeatureFlag.class); + DataModel.FeatureFlag flag = gson.fromJson(flagJson, DataModel.FeatureFlag.class); assertNotNull(flag.getRules()); assertEquals(2, flag.getRules().size()); assertEquals(EvaluationReason.ruleMatch(0, "ruleid0"), flag.getRules().get(0).getRuleMatchReason()); diff --git a/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java b/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java index cfdede733..f04f974d9 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java @@ -22,7 +22,7 @@ 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 FlagModel.FeatureFlag flag = flagBuilder(flagKey) + private static final DataModel.FeatureFlag flag = flagBuilder(flagKey) .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 5c09596b1..ab6beed5d 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java @@ -178,14 +178,14 @@ public void jsonValueVariationReturnsDefaultValueForUnknownFlag() throws Excepti @Test public void canMatchUserBySegment() throws Exception { // This is similar to one of the tests in FeatureFlagTest, but more end-to-end - FlagModel.Segment segment = segmentBuilder("segment1") + DataModel.Segment segment = segmentBuilder("segment1") .version(1) .included(user.getKeyAsString()) .build(); featureStore.upsert(SEGMENTS, segment); - FlagModel.Clause clause = clause("", Operator.segmentMatch, LDValue.of("segment1")); - FlagModel.FeatureFlag feature = booleanFlagWithClauses("feature", clause); + DataModel.Clause clause = clause("", DataModel.Operator.segmentMatch, LDValue.of("segment1")); + DataModel.FeatureFlag feature = booleanFlagWithClauses("feature", clause); featureStore.upsert(FEATURES, feature); assertTrue(client.boolVariation("feature", user, false)); @@ -202,7 +202,7 @@ public void canGetDetailsForSuccessfulEvaluation() throws Exception { @Test public void variationReturnsDefaultIfFlagEvaluatesToNull() { - FlagModel.FeatureFlag flag = flagBuilder("key").on(false).offVariation(null).build(); + DataModel.FeatureFlag flag = flagBuilder("key").on(false).offVariation(null).build(); featureStore.upsert(FEATURES, flag); assertEquals("default", client.stringVariation("key", user, "default")); @@ -210,7 +210,7 @@ public void variationReturnsDefaultIfFlagEvaluatesToNull() { @Test public void variationDetailReturnsDefaultIfFlagEvaluatesToNull() { - FlagModel.FeatureFlag flag = flagBuilder("key").on(false).offVariation(null).build(); + DataModel.FeatureFlag flag = flagBuilder("key").on(false).offVariation(null).build(); featureStore.upsert(FEATURES, flag); EvaluationDetail expected = EvaluationDetail.fromValue("default", @@ -304,14 +304,14 @@ public void allFlagsReturnsNullForNullUserKey() throws Exception { @Test public void allFlagsStateReturnsState() throws Exception { - FlagModel.FeatureFlag flag1 = flagBuilder("key1") + DataModel.FeatureFlag flag1 = flagBuilder("key1") .version(100) .trackEvents(false) .on(false) .offVariation(0) .variations(LDValue.of("value1")) .build(); - FlagModel.FeatureFlag flag2 = flagBuilder("key2") + DataModel.FeatureFlag flag2 = flagBuilder("key2") .version(200) .trackEvents(true) .debugEventsUntilDate(1000L) @@ -341,11 +341,11 @@ public void allFlagsStateReturnsState() throws Exception { @Test public void allFlagsStateCanFilterForOnlyClientSideFlags() { - FlagModel.FeatureFlag flag1 = flagBuilder("server-side-1").build(); - FlagModel.FeatureFlag flag2 = flagBuilder("server-side-2").build(); - FlagModel.FeatureFlag flag3 = flagBuilder("client-side-1").clientSide(true) + DataModel.FeatureFlag flag1 = flagBuilder("server-side-1").build(); + DataModel.FeatureFlag flag2 = flagBuilder("server-side-2").build(); + DataModel.FeatureFlag flag3 = flagBuilder("client-side-1").clientSide(true) .variations(LDValue.of("value1")).offVariation(0).build(); - FlagModel.FeatureFlag flag4 = flagBuilder("client-side-2").clientSide(true) + DataModel.FeatureFlag flag4 = flagBuilder("client-side-2").clientSide(true) .variations(LDValue.of("value2")).offVariation(0).build(); featureStore.upsert(FEATURES, flag1); featureStore.upsert(FEATURES, flag2); @@ -361,14 +361,14 @@ public void allFlagsStateCanFilterForOnlyClientSideFlags() { @Test public void allFlagsStateReturnsStateWithReasons() { - FlagModel.FeatureFlag flag1 = flagBuilder("key1") + DataModel.FeatureFlag flag1 = flagBuilder("key1") .version(100) .trackEvents(false) .on(false) .offVariation(0) .variations(LDValue.of("value1")) .build(); - FlagModel.FeatureFlag flag2 = flagBuilder("key2") + DataModel.FeatureFlag flag2 = flagBuilder("key2") .version(200) .trackEvents(true) .debugEventsUntilDate(1000L) @@ -399,21 +399,21 @@ public void allFlagsStateReturnsStateWithReasons() { @Test public void allFlagsStateCanOmitDetailsForUntrackedFlags() { long futureTime = System.currentTimeMillis() + 1000000; - FlagModel.FeatureFlag flag1 = flagBuilder("key1") + DataModel.FeatureFlag flag1 = flagBuilder("key1") .version(100) .trackEvents(false) .on(false) .offVariation(0) .variations(LDValue.of("value1")) .build(); - FlagModel.FeatureFlag flag2 = flagBuilder("key2") + DataModel.FeatureFlag flag2 = flagBuilder("key2") .version(200) .trackEvents(true) .on(true) .fallthrough(fallthroughVariation(1)) .variations(LDValue.of("off"), LDValue.of("value2")) .build(); - FlagModel.FeatureFlag flag3 = flagBuilder("key3") + DataModel.FeatureFlag flag3 = flagBuilder("key3") .version(300) .trackEvents(false) .debugEventsUntilDate(futureTime) // event tracking is turned on temporarily even though trackEvents is false diff --git a/src/test/java/com/launchdarkly/client/LDClientEventTest.java b/src/test/java/com/launchdarkly/client/LDClientEventTest.java index 02d6c61a8..985c6874c 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEventTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEventTest.java @@ -148,7 +148,7 @@ public void trackWithUserWithNoKeyDoesNotSendEvent() { @Test public void boolVariationSendsEvent() throws Exception { - FlagModel.FeatureFlag flag = flagWithValue("key", LDValue.of(true)); + DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of(true)); featureStore.upsert(FEATURES, flag); client.boolVariation("key", user, false); @@ -165,7 +165,7 @@ public void boolVariationSendsEventForUnknownFlag() throws Exception { @Test public void boolVariationDetailSendsEvent() throws Exception { - FlagModel.FeatureFlag flag = flagWithValue("key", LDValue.of(true)); + DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of(true)); featureStore.upsert(FEATURES, flag); client.boolVariationDetail("key", user, false); @@ -183,7 +183,7 @@ public void boolVariationDetailSendsEventForUnknownFlag() throws Exception { @Test public void intVariationSendsEvent() throws Exception { - FlagModel.FeatureFlag flag = flagWithValue("key", LDValue.of(2)); + DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of(2)); featureStore.upsert(FEATURES, flag); client.intVariation("key", user, 1); @@ -200,7 +200,7 @@ public void intVariationSendsEventForUnknownFlag() throws Exception { @Test public void intVariationDetailSendsEvent() throws Exception { - FlagModel.FeatureFlag flag = flagWithValue("key", LDValue.of(2)); + DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of(2)); featureStore.upsert(FEATURES, flag); client.intVariationDetail("key", user, 1); @@ -218,7 +218,7 @@ public void intVariationDetailSendsEventForUnknownFlag() throws Exception { @Test public void doubleVariationSendsEvent() throws Exception { - FlagModel.FeatureFlag flag = flagWithValue("key", LDValue.of(2.5d)); + DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of(2.5d)); featureStore.upsert(FEATURES, flag); client.doubleVariation("key", user, 1.0d); @@ -235,7 +235,7 @@ public void doubleVariationSendsEventForUnknownFlag() throws Exception { @Test public void doubleVariationDetailSendsEvent() throws Exception { - FlagModel.FeatureFlag flag = flagWithValue("key", LDValue.of(2.5d)); + DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of(2.5d)); featureStore.upsert(FEATURES, flag); client.doubleVariationDetail("key", user, 1.0d); @@ -253,7 +253,7 @@ public void doubleVariationDetailSendsEventForUnknownFlag() throws Exception { @Test public void stringVariationSendsEvent() throws Exception { - FlagModel.FeatureFlag flag = flagWithValue("key", LDValue.of("b")); + DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of("b")); featureStore.upsert(FEATURES, flag); client.stringVariation("key", user, "a"); @@ -270,7 +270,7 @@ public void stringVariationSendsEventForUnknownFlag() throws Exception { @Test public void stringVariationDetailSendsEvent() throws Exception { - FlagModel.FeatureFlag flag = flagWithValue("key", LDValue.of("b")); + DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of("b")); featureStore.upsert(FEATURES, flag); client.stringVariationDetail("key", user, "a"); @@ -290,7 +290,7 @@ public void stringVariationDetailSendsEventForUnknownFlag() throws Exception { @Test public void jsonVariationSendsEvent() throws Exception { LDValue data = LDValue.buildObject().put("thing", LDValue.of("stuff")).build(); - FlagModel.FeatureFlag flag = flagWithValue("key", data); + DataModel.FeatureFlag flag = flagWithValue("key", data); featureStore.upsert(FEATURES, flag); LDValue defaultVal = LDValue.of(42); @@ -313,7 +313,7 @@ public void jsonVariationSendsEventForUnknownFlag() throws Exception { @Test public void jsonVariationDetailSendsEvent() throws Exception { LDValue data = LDValue.buildObject().put("thing", LDValue.of("stuff")).build(); - FlagModel.FeatureFlag flag = flagWithValue("key", data); + DataModel.FeatureFlag flag = flagWithValue("key", data); featureStore.upsert(FEATURES, flag); LDValue defaultVal = LDValue.of(42); @@ -336,7 +336,7 @@ public void jsonVariationDetailSendsEventForUnknownFlag() throws Exception { @Test public void jsonValueVariationDetailSendsEvent() throws Exception { LDValue data = LDValue.buildObject().put("thing", LDValue.of("stuff")).build(); - FlagModel.FeatureFlag flag = flagWithValue("key", data); + DataModel.FeatureFlag flag = flagWithValue("key", data); featureStore.upsert(FEATURES, flag); LDValue defaultVal = LDValue.of(42); @@ -357,9 +357,9 @@ public void jsonValueVariationDetailSendsEventForUnknownFlag() throws Exception @Test public void eventTrackingAndReasonCanBeForcedForRule() throws Exception { - FlagModel.Clause clause = clauseMatchingUser(user); - FlagModel.Rule rule = ruleBuilder().id("id").clauses(clause).variation(1).trackEvents(true).build(); - FlagModel.FeatureFlag flag = flagBuilder("flag") + DataModel.Clause clause = clauseMatchingUser(user); + DataModel.Rule rule = ruleBuilder().id("id").clauses(clause).variation(1).trackEvents(true).build(); + DataModel.FeatureFlag flag = flagBuilder("flag") .on(true) .rules(rule) .offVariation(0) @@ -380,11 +380,11 @@ public void eventTrackingAndReasonCanBeForcedForRule() throws Exception { @Test public void eventTrackingAndReasonAreNotForcedIfFlagIsNotSetForMatchingRule() throws Exception { - FlagModel.Clause clause0 = clauseNotMatchingUser(user); - FlagModel.Clause clause1 = clauseMatchingUser(user); - FlagModel.Rule rule0 = ruleBuilder().id("id0").clauses(clause0).variation(1).trackEvents(true).build(); - FlagModel.Rule rule1 = ruleBuilder().id("id1").clauses(clause1).variation(1).trackEvents(false).build(); - FlagModel.FeatureFlag flag = flagBuilder("flag") + DataModel.Clause clause0 = clauseNotMatchingUser(user); + DataModel.Clause clause1 = clauseMatchingUser(user); + DataModel.Rule rule0 = ruleBuilder().id("id0").clauses(clause0).variation(1).trackEvents(true).build(); + DataModel.Rule rule1 = ruleBuilder().id("id1").clauses(clause1).variation(1).trackEvents(false).build(); + DataModel.FeatureFlag flag = flagBuilder("flag") .on(true) .rules(rule0, rule1) .offVariation(0) @@ -404,9 +404,9 @@ public void eventTrackingAndReasonAreNotForcedIfFlagIsNotSetForMatchingRule() th @Test public void eventTrackingAndReasonCanBeForcedForFallthrough() throws Exception { - FlagModel.FeatureFlag flag = flagBuilder("flag") + DataModel.FeatureFlag flag = flagBuilder("flag") .on(true) - .fallthrough(new FlagModel.VariationOrRollout(0, null)) + .fallthrough(new DataModel.VariationOrRollout(0, null)) .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .trackEventsFallthrough(true) .build(); @@ -425,9 +425,9 @@ public void eventTrackingAndReasonCanBeForcedForFallthrough() throws Exception { @Test public void eventTrackingAndReasonAreNotForcedForFallthroughIfFlagIsNotSet() throws Exception { - FlagModel.FeatureFlag flag = flagBuilder("flag") + DataModel.FeatureFlag flag = flagBuilder("flag") .on(true) - .fallthrough(new FlagModel.VariationOrRollout(0, null)) + .fallthrough(new DataModel.VariationOrRollout(0, null)) .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .trackEventsFallthrough(false) .build(); @@ -443,10 +443,10 @@ public void eventTrackingAndReasonAreNotForcedForFallthroughIfFlagIsNotSet() thr @Test public void eventTrackingAndReasonAreNotForcedForFallthroughIfReasonIsNotFallthrough() throws Exception { - FlagModel.FeatureFlag flag = flagBuilder("flag") + DataModel.FeatureFlag flag = flagBuilder("flag") .on(false) // so the evaluation reason will be OFF, not FALLTHROUGH .offVariation(1) - .fallthrough(new FlagModel.VariationOrRollout(0, null)) + .fallthrough(new DataModel.VariationOrRollout(0, null)) .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .trackEventsFallthrough(true) .build(); @@ -462,7 +462,7 @@ public void eventTrackingAndReasonAreNotForcedForFallthroughIfReasonIsNotFallthr @Test public void eventIsSentForExistingPrererequisiteFlag() throws Exception { - FlagModel.FeatureFlag f0 = flagBuilder("feature0") + DataModel.FeatureFlag f0 = flagBuilder("feature0") .on(true) .prerequisites(prerequisite("feature1", 1)) .fallthrough(fallthroughVariation(0)) @@ -470,7 +470,7 @@ public void eventIsSentForExistingPrererequisiteFlag() throws Exception { .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .version(1) .build(); - FlagModel.FeatureFlag f1 = flagBuilder("feature1") + DataModel.FeatureFlag f1 = flagBuilder("feature1") .on(true) .fallthrough(fallthroughVariation(1)) .variations(LDValue.of("nogo"), LDValue.of("go")) @@ -488,7 +488,7 @@ public void eventIsSentForExistingPrererequisiteFlag() throws Exception { @Test public void eventIsSentWithReasonForExistingPrererequisiteFlag() throws Exception { - FlagModel.FeatureFlag f0 = flagBuilder("feature0") + DataModel.FeatureFlag f0 = flagBuilder("feature0") .on(true) .prerequisites(prerequisite("feature1", 1)) .fallthrough(fallthroughVariation(0)) @@ -496,7 +496,7 @@ public void eventIsSentWithReasonForExistingPrererequisiteFlag() throws Exceptio .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .version(1) .build(); - FlagModel.FeatureFlag f1 = flagBuilder("feature1") + DataModel.FeatureFlag f1 = flagBuilder("feature1") .on(true) .fallthrough(fallthroughVariation(1)) .variations(LDValue.of("nogo"), LDValue.of("go")) @@ -514,7 +514,7 @@ public void eventIsSentWithReasonForExistingPrererequisiteFlag() throws Exceptio @Test public void eventIsNotSentForUnknownPrererequisiteFlag() throws Exception { - FlagModel.FeatureFlag f0 = flagBuilder("feature0") + DataModel.FeatureFlag f0 = flagBuilder("feature0") .on(true) .prerequisites(prerequisite("feature1", 1)) .fallthrough(fallthroughVariation(0)) @@ -532,7 +532,7 @@ public void eventIsNotSentForUnknownPrererequisiteFlag() throws Exception { @Test public void failureReasonIsGivenForUnknownPrererequisiteFlagIfDetailsWereRequested() throws Exception { - FlagModel.FeatureFlag f0 = flagBuilder("feature0") + DataModel.FeatureFlag f0 = flagBuilder("feature0") .on(true) .prerequisites(prerequisite("feature1", 1)) .fallthrough(fallthroughVariation(0)) @@ -549,7 +549,7 @@ public void failureReasonIsGivenForUnknownPrererequisiteFlagIfDetailsWereRequest EvaluationReason.prerequisiteFailed("feature1")); } - private void checkFeatureEvent(Event e, FlagModel.FeatureFlag flag, LDValue value, LDValue defaultVal, + private void checkFeatureEvent(Event e, DataModel.FeatureFlag flag, LDValue value, 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 79c263aae..b8f88e3c5 100644 --- a/src/test/java/com/launchdarkly/client/LDClientLddModeTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientLddModeTest.java @@ -52,7 +52,7 @@ public void lddModeClientGetsFlagFromFeatureStore() throws IOException { .useLdd(true) .featureStoreFactory(specificFeatureStore(testFeatureStore)) .build(); - FlagModel.FeatureFlag flag = flagWithValue("key", LDValue.of(true)); + DataModel.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/LDClientTest.java b/src/test/java/com/launchdarkly/client/LDClientTest.java index 9b67cf085..64b36e149 100644 --- a/src/test/java/com/launchdarkly/client/LDClientTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientTest.java @@ -315,9 +315,9 @@ public void dataSetIsPassedToFeatureStoreInCorrectOrder() throws Exception { List list1 = ImmutableList.copyOf(map1.values()); assertEquals(DEPENDENCY_ORDERING_TEST_DATA.get(FEATURES).size(), map1.size()); for (int itemIndex = 0; itemIndex < list1.size(); itemIndex++) { - FlagModel.FeatureFlag item = (FlagModel.FeatureFlag)list1.get(itemIndex); - for (FlagModel.Prerequisite prereq: item.getPrerequisites()) { - FlagModel.FeatureFlag depFlag = (FlagModel.FeatureFlag)map1.get(prereq.getKey()); + DataModel.FeatureFlag item = (DataModel.FeatureFlag)list1.get(itemIndex); + for (DataModel.Prerequisite prereq: item.getPrerequisites()) { + DataModel.FeatureFlag depFlag = (DataModel.FeatureFlag)map1.get(prereq.getKey()); int depIndex = list1.indexOf(depFlag); if (depIndex > itemIndex) { Iterable allKeys = Iterables.transform(list1, new Function() { diff --git a/src/test/java/com/launchdarkly/client/ModelBuilders.java b/src/test/java/com/launchdarkly/client/ModelBuilders.java index 37a583140..019a5b8ca 100644 --- a/src/test/java/com/launchdarkly/client/ModelBuilders.java +++ b/src/test/java/com/launchdarkly/client/ModelBuilders.java @@ -1,7 +1,7 @@ package com.launchdarkly.client; import com.google.common.collect.ImmutableList; -import com.launchdarkly.client.FlagModel.FeatureFlag; +import com.launchdarkly.client.DataModel.FeatureFlag; import com.launchdarkly.client.value.LDValue; import java.util.ArrayList; @@ -14,12 +14,12 @@ public static FlagBuilder flagBuilder(String key) { return new FlagBuilder(key); } - public static FlagBuilder flagBuilder(FlagModel.FeatureFlag fromFlag) { + public static FlagBuilder flagBuilder(DataModel.FeatureFlag fromFlag) { return new FlagBuilder(fromFlag); } - public static FlagModel.FeatureFlag booleanFlagWithClauses(String key, FlagModel.Clause... clauses) { - FlagModel.Rule rule = ruleBuilder().variation(1).clauses(clauses).build(); + public static DataModel.FeatureFlag booleanFlagWithClauses(String key, DataModel.Clause... clauses) { + DataModel.Rule rule = ruleBuilder().variation(1).clauses(clauses).build(); return flagBuilder(key) .on(true) .rules(rule) @@ -29,7 +29,7 @@ public static FlagModel.FeatureFlag booleanFlagWithClauses(String key, FlagModel .build(); } - public static FlagModel.FeatureFlag flagWithValue(String key, LDValue value) { + public static DataModel.FeatureFlag flagWithValue(String key, LDValue value) { return flagBuilder(key) .on(false) .offVariation(0) @@ -37,40 +37,40 @@ public static FlagModel.FeatureFlag flagWithValue(String key, LDValue value) { .build(); } - public static FlagModel.VariationOrRollout fallthroughVariation(int variation) { - return new FlagModel.VariationOrRollout(variation, null); + public static DataModel.VariationOrRollout fallthroughVariation(int variation) { + return new DataModel.VariationOrRollout(variation, null); } public static RuleBuilder ruleBuilder() { return new RuleBuilder(); } - public static FlagModel.Clause clause(String attribute, Operator op, boolean negate, LDValue... values) { - return new FlagModel.Clause(attribute, op, Arrays.asList(values), negate); + public static DataModel.Clause clause(String attribute, DataModel.Operator op, boolean negate, LDValue... values) { + return new DataModel.Clause(attribute, op, Arrays.asList(values), negate); } - public static FlagModel.Clause clause(String attribute, Operator op, LDValue... values) { + public static DataModel.Clause clause(String attribute, DataModel.Operator op, LDValue... values) { return clause(attribute, op, false, values); } - public static FlagModel.Clause clauseMatchingUser(LDUser user) { - return clause("key", Operator.in, user.getKey()); + public static DataModel.Clause clauseMatchingUser(LDUser user) { + return clause("key", DataModel.Operator.in, user.getKey()); } - public static FlagModel.Clause clauseNotMatchingUser(LDUser user) { - return clause("key", Operator.in, LDValue.of("not-" + user.getKeyAsString())); + public static DataModel.Clause clauseNotMatchingUser(LDUser user) { + return clause("key", DataModel.Operator.in, LDValue.of("not-" + user.getKeyAsString())); } - public static FlagModel.Target target(int variation, String... userKeys) { - return new FlagModel.Target(Arrays.asList(userKeys), variation); + public static DataModel.Target target(int variation, String... userKeys) { + return new DataModel.Target(Arrays.asList(userKeys), variation); } - public static FlagModel.Prerequisite prerequisite(String key, int variation) { - return new FlagModel.Prerequisite(key, variation); + public static DataModel.Prerequisite prerequisite(String key, int variation) { + return new DataModel.Prerequisite(key, variation); } - public static FlagModel.Rollout emptyRollout() { - return new FlagModel.Rollout(ImmutableList.of(), null); + public static DataModel.Rollout emptyRollout() { + return new DataModel.Rollout(ImmutableList.of(), null); } public static SegmentBuilder segmentBuilder(String key) { @@ -85,11 +85,11 @@ public static class FlagBuilder { private String key; private int version; private boolean on; - private List prerequisites = new ArrayList<>(); + private List prerequisites = new ArrayList<>(); private String salt; - private List targets = new ArrayList<>(); - private List rules = new ArrayList<>(); - private FlagModel.VariationOrRollout fallthrough; + private List targets = new ArrayList<>(); + private List rules = new ArrayList<>(); + private DataModel.VariationOrRollout fallthrough; private Integer offVariation; private List variations = new ArrayList<>(); private boolean clientSide; @@ -102,7 +102,7 @@ private FlagBuilder(String key) { this.key = key; } - private FlagBuilder(FlagModel.FeatureFlag f) { + private FlagBuilder(DataModel.FeatureFlag f) { if (f != null) { this.key = f.getKey(); this.version = f.getVersion(); @@ -132,7 +132,7 @@ FlagBuilder on(boolean on) { return this; } - FlagBuilder prerequisites(FlagModel.Prerequisite... prerequisites) { + FlagBuilder prerequisites(DataModel.Prerequisite... prerequisites) { this.prerequisites = Arrays.asList(prerequisites); return this; } @@ -142,17 +142,17 @@ FlagBuilder salt(String salt) { return this; } - FlagBuilder targets(FlagModel.Target... targets) { + FlagBuilder targets(DataModel.Target... targets) { this.targets = Arrays.asList(targets); return this; } - FlagBuilder rules(FlagModel.Rule... rules) { + FlagBuilder rules(DataModel.Rule... rules) { this.rules = Arrays.asList(rules); return this; } - FlagBuilder fallthrough(FlagModel.VariationOrRollout fallthrough) { + FlagBuilder fallthrough(DataModel.VariationOrRollout fallthrough) { this.fallthrough = fallthrough; return this; } @@ -192,8 +192,8 @@ FlagBuilder deleted(boolean deleted) { return this; } - FlagModel.FeatureFlag build() { - FeatureFlag flag = new FlagModel.FeatureFlag(key, version, on, prerequisites, salt, targets, rules, fallthrough, offVariation, variations, + DataModel.FeatureFlag build() { + FeatureFlag flag = new DataModel.FeatureFlag(key, version, on, prerequisites, salt, targets, rules, fallthrough, offVariation, variations, clientSide, trackEvents, trackEventsFallthrough, debugEventsUntilDate, deleted); flag.afterDeserialized(); return flag; @@ -202,16 +202,16 @@ FlagModel.FeatureFlag build() { public static class RuleBuilder { private String id; - private List clauses = new ArrayList<>(); + private List clauses = new ArrayList<>(); private Integer variation; - private FlagModel.Rollout rollout; + private DataModel.Rollout rollout; private boolean trackEvents; private RuleBuilder() { } - public FlagModel.Rule build() { - return new FlagModel.Rule(id, clauses, variation, rollout, trackEvents); + public DataModel.Rule build() { + return new DataModel.Rule(id, clauses, variation, rollout, trackEvents); } public RuleBuilder id(String id) { @@ -219,7 +219,7 @@ public RuleBuilder id(String id) { return this; } - public RuleBuilder clauses(FlagModel.Clause... clauses) { + public RuleBuilder clauses(DataModel.Clause... clauses) { this.clauses = ImmutableList.copyOf(clauses); return this; } @@ -229,7 +229,7 @@ public RuleBuilder variation(Integer variation) { return this; } - public RuleBuilder rollout(FlagModel.Rollout rollout) { + public RuleBuilder rollout(DataModel.Rollout rollout) { this.rollout = rollout; return this; } @@ -245,7 +245,7 @@ public static class SegmentBuilder { private List included = new ArrayList<>(); private List excluded = new ArrayList<>(); private String salt = ""; - private List rules = new ArrayList<>(); + private List rules = new ArrayList<>(); private int version = 0; private boolean deleted; @@ -253,7 +253,7 @@ private SegmentBuilder(String key) { this.key = key; } - private SegmentBuilder(FlagModel.Segment from) { + private SegmentBuilder(DataModel.Segment from) { this.key = from.getKey(); this.included = ImmutableList.copyOf(from.getIncluded()); this.excluded = ImmutableList.copyOf(from.getExcluded()); @@ -263,8 +263,8 @@ private SegmentBuilder(FlagModel.Segment from) { this.deleted = from.isDeleted(); } - public FlagModel.Segment build() { - return new FlagModel.Segment(key, included, excluded, salt, rules, version, deleted); + public DataModel.Segment build() { + return new DataModel.Segment(key, included, excluded, salt, rules, version, deleted); } public SegmentBuilder included(String... included) { @@ -282,7 +282,7 @@ public SegmentBuilder salt(String salt) { return this; } - public SegmentBuilder rules(FlagModel.SegmentRule... rules) { + public SegmentBuilder rules(DataModel.SegmentRule... rules) { this.rules = Arrays.asList(rules); return this; } @@ -299,18 +299,18 @@ public SegmentBuilder deleted(boolean deleted) { } public static class SegmentRuleBuilder { - private List clauses = new ArrayList<>(); + private List clauses = new ArrayList<>(); private Integer weight; private String bucketBy; private SegmentRuleBuilder() { } - public FlagModel.SegmentRule build() { - return new FlagModel.SegmentRule(clauses, weight, bucketBy); + public DataModel.SegmentRule build() { + return new DataModel.SegmentRule(clauses, weight, bucketBy); } - public SegmentRuleBuilder clauses(FlagModel.Clause... clauses) { + public SegmentRuleBuilder clauses(DataModel.Clause... clauses) { this.clauses = ImmutableList.copyOf(clauses); return this; } diff --git a/src/test/java/com/launchdarkly/client/OperatorParameterizedTest.java b/src/test/java/com/launchdarkly/client/OperatorParameterizedTest.java deleted file mode 100644 index e4e8ca61a..000000000 --- a/src/test/java/com/launchdarkly/client/OperatorParameterizedTest.java +++ /dev/null @@ -1,126 +0,0 @@ -package com.launchdarkly.client; - -import com.launchdarkly.client.value.LDValue; - -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.Parameterized; - -import java.util.Arrays; - -import static org.junit.Assert.assertEquals; - -@SuppressWarnings("javadoc") -@RunWith(Parameterized.class) -public class OperatorParameterizedTest { - 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 LDValue aValue; - private final LDValue bValue; - private final boolean shouldBe; - - public OperatorParameterizedTest(Operator op, LDValue aValue, LDValue bValue, boolean shouldBe) { - this.op = op; - this.aValue = aValue; - this.bValue = bValue; - this.shouldBe = shouldBe; - } - - @Parameterized.Parameters(name = "{1} {0} {2} should be {3}") - public static Iterable data() { - return Arrays.asList(new Object[][] { - // numeric comparisons - { 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, 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, 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, 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 }, - { Operator.before, dateMs1, dateMs2, true }, - { Operator.before, dateStr2, dateStr1, false }, - { Operator.before, dateMs2, dateMs1, false }, - { Operator.before, dateStr1, dateStr1, false }, - { Operator.before, dateMs1, dateMs1, false }, - { Operator.before, dateStr1, invalidDate, false }, - { Operator.after, dateStr1, dateStr2, false }, - { Operator.after, dateMs1, dateMs2, false }, - { Operator.after, dateStr2, dateStr1, true }, - { Operator.after, dateMs2, dateMs1, true }, - { Operator.after, dateStr1, dateStr1, false }, - { Operator.after, dateMs1, dateMs1, false }, - { Operator.after, dateStr1, invalidDate, false }, - - // semver - { 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 } - }); - } - - @Test - public void parameterizedTestComparison() { - assertEquals(shouldBe, op.apply(aValue, bValue)); - } -} diff --git a/src/test/java/com/launchdarkly/client/PollingProcessorTest.java b/src/test/java/com/launchdarkly/client/PollingProcessorTest.java index 3edc58e27..51990876c 100644 --- a/src/test/java/com/launchdarkly/client/PollingProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/PollingProcessorTest.java @@ -17,7 +17,7 @@ public class PollingProcessorTest { @Test public void testConnectionOk() throws Exception { MockFeatureRequestor requestor = new MockFeatureRequestor(); - requestor.allData = new FeatureRequestor.AllData(new HashMap(), new HashMap()); + requestor.allData = new FeatureRequestor.AllData(new HashMap(), new HashMap()); FeatureStore store = new InMemoryFeatureStore(); try (PollingProcessor pollingProcessor = new PollingProcessor(LDConfig.DEFAULT, requestor, store)) { @@ -116,11 +116,11 @@ private static class MockFeatureRequestor implements FeatureRequestor { public void close() throws IOException {} - public FlagModel.FeatureFlag getFlag(String featureKey) throws IOException, HttpErrorException { + public DataModel.FeatureFlag getFlag(String featureKey) throws IOException, HttpErrorException { return null; } - public FlagModel.Segment getSegment(String segmentKey) throws IOException, HttpErrorException { + public DataModel.Segment getSegment(String segmentKey) throws IOException, HttpErrorException { return null; } diff --git a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java index e399d6355..cfbcdab11 100644 --- a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java @@ -47,10 +47,10 @@ public class StreamProcessorTest extends EasyMockSupport { private static final URI STREAM_URI = URI.create("http://stream.test.com"); private static final String FEATURE1_KEY = "feature1"; private static final int FEATURE1_VERSION = 11; - private static final FlagModel.FeatureFlag FEATURE = flagBuilder(FEATURE1_KEY).version(FEATURE1_VERSION).build(); + private static final DataModel.FeatureFlag FEATURE = flagBuilder(FEATURE1_KEY).version(FEATURE1_VERSION).build(); private static final String SEGMENT1_KEY = "segment1"; private static final int SEGMENT1_VERSION = 22; - private static final FlagModel.Segment SEGMENT = segmentBuilder(SEGMENT1_KEY).version(SEGMENT1_VERSION).build(); + private static final DataModel.Segment SEGMENT = segmentBuilder(SEGMENT1_KEY).version(SEGMENT1_VERSION).build(); private static final String STREAM_RESPONSE_WITH_EMPTY_DATA = "event: put\n" + "data: {\"data\":{\"flags\":{},\"segments\":{}}}\n\n"; @@ -464,17 +464,17 @@ private MessageEvent emptyPutEvent() { return new MessageEvent("{\"data\":{\"flags\":{},\"segments\":{}}}"); } - private void setupRequestorToReturnAllDataWithFlag(FlagModel.FeatureFlag feature) throws Exception { + private void setupRequestorToReturnAllDataWithFlag(DataModel.FeatureFlag feature) throws Exception { FeatureRequestor.AllData data = new FeatureRequestor.AllData( - Collections.singletonMap(feature.getKey(), feature), Collections.emptyMap()); + Collections.singletonMap(feature.getKey(), feature), Collections.emptyMap()); expect(mockRequestor.getAllData()).andReturn(data); } - private void assertFeatureInStore(FlagModel.FeatureFlag feature) { + private void assertFeatureInStore(DataModel.FeatureFlag feature) { assertEquals(feature.getVersion(), featureStore.get(FEATURES, feature.getKey()).getVersion()); } - private void assertSegmentInStore(FlagModel.Segment segment) { + private void assertSegmentInStore(DataModel.Segment segment) { assertEquals(segment.getVersion(), featureStore.get(SEGMENTS, segment.getKey()).getVersion()); } From 2fc1f6cbe769d78d98a85150c425f426f355a549 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 17 Dec 2019 19:27:45 -0800 Subject: [PATCH 220/673] move event classes into events subpackage --- .../client/DefaultEventProcessor.java | 15 +-- .../com/launchdarkly/client/Evaluator.java | 1 + .../com/launchdarkly/client/EventFactory.java | 5 +- .../client/EventOutputFormatter.java | 41 ++++---- .../launchdarkly/client/EventSummarizer.java | 5 +- .../com/launchdarkly/client/LDClient.java | 3 +- .../client/{ => events}/Event.java | 88 +++++++++++++++++- .../client/events/package-info.java | 7 ++ .../client/interfaces/EventProcessor.java | 2 +- .../client/DefaultEventProcessorTest.java | 31 ++++--- .../launchdarkly/client/EvaluatorTest.java | 41 ++++---- .../launchdarkly/client/EventOutputTest.java | 3 +- .../client/EventSummarizerTest.java | 1 + .../client/LDClientEventTest.java | 93 ++++++++++--------- .../com/launchdarkly/client/LDClientTest.java | 1 + .../com/launchdarkly/client/TestUtil.java | 1 + 16 files changed, 221 insertions(+), 117 deletions(-) rename src/main/java/com/launchdarkly/client/{ => events}/Event.java (80%) create mode 100644 src/main/java/com/launchdarkly/client/events/package-info.java diff --git a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java index db5010422..94e746778 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.events.Event; import com.launchdarkly.client.interfaces.EventProcessor; import org.slf4j.Logger; @@ -335,7 +336,7 @@ private void processEvent(Event e, SimpleLRUCache userKeys, Even if (e instanceof Event.FeatureRequest) { if (shouldSampleEvent()) { Event.FeatureRequest fe = (Event.FeatureRequest)e; - addFullEvent = fe.trackEvents; + addFullEvent = fe.isTrackEvents(); if (shouldDebugEvent(fe)) { debugEvent = EventFactory.DEFAULT.newDebugEvent(fe); } @@ -347,7 +348,8 @@ 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 (e.user != null && e.user.getKey() != null && !noticeUser(e.user, userKeys)) { + LDUser user = e.getUser(); + if (user != null && user.getKey() != null && !noticeUser(user, userKeys)) { if (!(e instanceof Event.Identify)) { addIndexEvent = true; } @@ -355,7 +357,7 @@ private void processEvent(Event e, SimpleLRUCache userKeys, Even } if (addIndexEvent) { - Event.Index ie = new Event.Index(e.creationDate, e.user); + Event.Index ie = new Event.Index(e.getCreationDate(), e.getUser()); outbox.add(ie); } if (addFullEvent) { @@ -380,14 +382,15 @@ private boolean shouldSampleEvent() { } private boolean shouldDebugEvent(Event.FeatureRequest fe) { - if (fe.debugEventsUntilDate != null) { + Long debugEventsUntilDate = fe.getDebugEventsUntilDate(); + if (debugEventsUntilDate != null) { // The "last known past time" comes from the last HTTP response we got from the server. // In case the client's time is set wrong, at least we know that any expiration date // earlier than that point is definitely in the past. If there's any discrepancy, we // want to err on the side of cutting off event debugging sooner. long lastPast = lastKnownPastTime.get(); - if (fe.debugEventsUntilDate > lastPast && - fe.debugEventsUntilDate > System.currentTimeMillis()) { + if (debugEventsUntilDate > lastPast && + debugEventsUntilDate > System.currentTimeMillis()) { return true; } } diff --git a/src/main/java/com/launchdarkly/client/Evaluator.java b/src/main/java/com/launchdarkly/client/Evaluator.java index 2d59218e3..2329f70f6 100644 --- a/src/main/java/com/launchdarkly/client/Evaluator.java +++ b/src/main/java/com/launchdarkly/client/Evaluator.java @@ -2,6 +2,7 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.Iterables; +import com.launchdarkly.client.events.Event; import com.launchdarkly.client.value.LDValue; import com.launchdarkly.client.value.LDValueType; diff --git a/src/main/java/com/launchdarkly/client/EventFactory.java b/src/main/java/com/launchdarkly/client/EventFactory.java index 142fc0a8b..fa61d3f8e 100644 --- a/src/main/java/com/launchdarkly/client/EventFactory.java +++ b/src/main/java/com/launchdarkly/client/EventFactory.java @@ -1,5 +1,6 @@ package com.launchdarkly.client; +import com.launchdarkly.client.events.Event; import com.launchdarkly.client.value.LDValue; abstract class EventFactory { @@ -55,8 +56,8 @@ public Event.FeatureRequest newPrerequisiteFeatureRequestEvent(DataModel.Feature } 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.reason, from.prereqOf, from.trackEvents, from.debugEventsUntilDate, true); + return new Event.FeatureRequest(from.getCreationDate(), from.getKey(), from.getUser(), from.getVersion(), from.getVariation(), from.getValue(), + from.getDefaultVal(), from.getReason(), from.getPrereqOf(), from.isTrackEvents(), from.getDebugEventsUntilDate(), true); } public Event.Custom newCustomEvent(String key, LDUser user, LDValue data, Double metricValue) { diff --git a/src/main/java/com/launchdarkly/client/EventOutputFormatter.java b/src/main/java/com/launchdarkly/client/EventOutputFormatter.java index e1db41097..059259762 100644 --- a/src/main/java/com/launchdarkly/client/EventOutputFormatter.java +++ b/src/main/java/com/launchdarkly/client/EventOutputFormatter.java @@ -3,6 +3,7 @@ import com.google.gson.stream.JsonWriter; import com.launchdarkly.client.EventSummarizer.CounterKey; import com.launchdarkly.client.EventSummarizer.CounterValue; +import com.launchdarkly.client.events.Event; import com.launchdarkly.client.value.LDValue; import java.io.IOException; @@ -41,41 +42,41 @@ int writeOutputEvents(Event[] events, EventSummarizer.EventSummary summary, Writ 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) { + startEvent(fe, fe.isDebug() ? "debug" : "feature", fe.getKey(), jw); + writeUserOrKey(fe, fe.isDebug(), jw); + if (fe.getVersion() != null) { jw.name("version"); - jw.value(fe.version); + jw.value(fe.getVersion()); } - if (fe.variation != null) { + if (fe.getVariation() != null) { jw.name("variation"); - jw.value(fe.variation); + jw.value(fe.getVariation()); } - writeLDValue("value", fe.value, jw); - writeLDValue("default", fe.defaultVal, jw); - if (fe.prereqOf != null) { + writeLDValue("value", fe.getValue(), jw); + writeLDValue("default", fe.getDefaultVal(), jw); + if (fe.getPrereqOf() != null) { jw.name("prereqOf"); - jw.value(fe.prereqOf); + jw.value(fe.getPrereqOf()); } - writeEvaluationReason("reason", fe.reason, jw); + writeEvaluationReason("reason", fe.getReason(), jw); jw.endObject(); } else if (event instanceof Event.Identify) { - startEvent(event, "identify", event.user == null ? null : event.user.getKeyAsString(), jw); - writeUser(event.user, jw); + startEvent(event, "identify", event.getUser() == null ? null : event.getUser().getKeyAsString(), jw); + writeUser(event.getUser(), jw); jw.endObject(); } else if (event instanceof Event.Custom) { Event.Custom ce = (Event.Custom)event; - startEvent(event, "custom", ce.key, jw); + startEvent(event, "custom", ce.getKey(), jw); writeUserOrKey(ce, false, jw); - writeLDValue("data", ce.data, jw); - if (ce.metricValue != null) { + writeLDValue("data", ce.getData(), jw); + if (ce.getMetricValue() != null) { jw.name("metricValue"); - jw.value(ce.metricValue); + jw.value(ce.getMetricValue()); } jw.endObject(); } else if (event instanceof Event.Index) { startEvent(event, "index", null, jw); - writeUser(event.user, jw); + writeUser(event.getUser(), jw); jw.endObject(); } else { return false; @@ -157,7 +158,7 @@ private void startEvent(Event event, String kind, String key, JsonWriter jw) thr jw.name("kind"); jw.value(kind); jw.name("creationDate"); - jw.value(event.creationDate); + jw.value(event.getCreationDate()); if (key != null) { jw.name("key"); jw.value(key); @@ -165,7 +166,7 @@ private void startEvent(Event event, String kind, String key, JsonWriter jw) thr } private void writeUserOrKey(Event event, boolean forceInline, JsonWriter jw) throws IOException { - LDUser user = event.user; + LDUser user = event.getUser(); if (user != null) { if (config.inlineUsersInEvents || forceInline) { writeUser(user, jw); diff --git a/src/main/java/com/launchdarkly/client/EventSummarizer.java b/src/main/java/com/launchdarkly/client/EventSummarizer.java index b21c0d870..fb49b494e 100644 --- a/src/main/java/com/launchdarkly/client/EventSummarizer.java +++ b/src/main/java/com/launchdarkly/client/EventSummarizer.java @@ -1,5 +1,6 @@ package com.launchdarkly.client; +import com.launchdarkly.client.events.Event; import com.launchdarkly.client.value.LDValue; import java.util.HashMap; @@ -25,8 +26,8 @@ final class EventSummarizer { void summarizeEvent(Event event) { if (event instanceof Event.FeatureRequest) { Event.FeatureRequest fe = (Event.FeatureRequest)event; - eventsState.incrementCounter(fe.key, fe.variation, fe.version, fe.value, fe.defaultVal); - eventsState.noteTimestamp(fe.creationDate); + eventsState.incrementCounter(fe.getKey(), fe.getVariation(), fe.getVersion(), fe.getValue(), fe.getDefaultVal()); + eventsState.noteTimestamp(fe.getCreationDate()); } } diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index d4d67f1b7..64215f2d9 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.events.Event; import com.launchdarkly.client.interfaces.EventProcessor; import com.launchdarkly.client.interfaces.EventProcessorFactory; import com.launchdarkly.client.interfaces.FeatureStore; @@ -174,7 +175,7 @@ public void identify(LDUser user) { private void sendFlagRequestEvent(Event.FeatureRequest event) { eventProcessor.sendEvent(event); - NewRelicReflector.annotateTransaction(event.key, String.valueOf(event.value)); + NewRelicReflector.annotateTransaction(event.getKey(), String.valueOf(event.getValue())); } @Override diff --git a/src/main/java/com/launchdarkly/client/Event.java b/src/main/java/com/launchdarkly/client/events/Event.java similarity index 80% rename from src/main/java/com/launchdarkly/client/Event.java rename to src/main/java/com/launchdarkly/client/events/Event.java index 40ff0053c..921cb94f7 100644 --- a/src/main/java/com/launchdarkly/client/Event.java +++ b/src/main/java/com/launchdarkly/client/events/Event.java @@ -1,6 +1,9 @@ -package com.launchdarkly.client; +package com.launchdarkly.client.events; import com.google.gson.JsonElement; +import com.launchdarkly.client.EvaluationReason; +import com.launchdarkly.client.LDClientInterface; +import com.launchdarkly.client.LDUser; import com.launchdarkly.client.value.LDValue; /** @@ -12,7 +15,7 @@ public class Event { /** * Base event constructor. - * @param creationDate the timetamp in milliseconds + * @param creationDate the timestamp in milliseconds * @param user the user associated with the event */ public Event(long creationDate, LDUser user) { @@ -20,6 +23,22 @@ public Event(long creationDate, LDUser user) { this.user = user; } + /** + * The event timestamp. + * @return the timestamp in milliseconds + */ + public long getCreationDate() { + return creationDate; + } + + /** + * The user associated with the event. + * @return the user object + */ + public LDUser getUser() { + return user; + } + /** * A custom event created with {@link LDClientInterface#track(String, LDUser)} or one of its overloads. */ @@ -56,6 +75,30 @@ public Custom(long timestamp, String key, LDUser user, LDValue data, Double metr public Custom(long timestamp, String key, LDUser user, JsonElement data) { this(timestamp, key, user, LDValue.unsafeFromJsonElement(data), null); } + + /** + * The custom event key. + * @return the event key + */ + public String getKey() { + return key; + } + + /** + * The custom data associated with the event, if any. + * @return the event data (null is equivalent to {@link LDValue#ofNull()}) + */ + public LDValue getData() { + return data; + } + + /** + * The numeric metric value associated with the event, if any. + * @return the metric value or null + */ + public Double getMetricValue() { + return metricValue; + } } /** @@ -176,6 +219,45 @@ public FeatureRequest(long timestamp, String key, LDUser user, Integer version, this(timestamp, key, user, version, variation, LDValue.unsafeFromJsonElement(value), LDValue.unsafeFromJsonElement(defaultVal), reason, prereqOf, trackEvents, debugEventsUntilDate, debug); } - } + public String getKey() { + return key; + } + + public Integer getVariation() { + return variation; + } + + public LDValue getValue() { + return value; + } + + public LDValue getDefaultVal() { + return defaultVal; + } + + public Integer getVersion() { + return version; + } + + public String getPrereqOf() { + return prereqOf; + } + + public boolean isTrackEvents() { + return trackEvents; + } + + public Long getDebugEventsUntilDate() { + return debugEventsUntilDate; + } + + public EvaluationReason getReason() { + return reason; + } + + public boolean isDebug() { + return debug; + } + } } \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/events/package-info.java b/src/main/java/com/launchdarkly/client/events/package-info.java new file mode 100644 index 000000000..e09e8963f --- /dev/null +++ b/src/main/java/com/launchdarkly/client/events/package-info.java @@ -0,0 +1,7 @@ +/** + * The LaunchDarkly analytics event model. + *

    + * You will not need to refer to these types in your code unless you are creating a + * custom implementation of {@link com.launchdarkly.client.interfaces.EventProcessor}. + */ +package com.launchdarkly.client.events; diff --git a/src/main/java/com/launchdarkly/client/interfaces/EventProcessor.java b/src/main/java/com/launchdarkly/client/interfaces/EventProcessor.java index d4ef04ad6..429a88aca 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/EventProcessor.java +++ b/src/main/java/com/launchdarkly/client/interfaces/EventProcessor.java @@ -1,6 +1,6 @@ package com.launchdarkly.client.interfaces; -import com.launchdarkly.client.Event; +import com.launchdarkly.client.events.Event; import java.io.Closeable; diff --git a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java index 40ac22bf5..355988273 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.events.Event; import com.launchdarkly.client.value.LDValue; import org.hamcrest.Matcher; @@ -276,7 +277,7 @@ public void debugModeExpiresBasedOnClientTimeIfClientTimeIsLaterThanServerTime() assertThat(getEventsFromLastRequest(server), contains( isIndexEvent(fe, userJson), - isSummaryEvent(fe.creationDate, fe.creationDate) + isSummaryEvent(fe.getCreationDate(), fe.getCreationDate()) )); } } @@ -311,7 +312,7 @@ public void debugModeExpiresBasedOnServerTimeIfServerTimeIsLaterThanClientTime() // Should get a summary event only, not a full feature event assertThat(getEventsFromLastRequest(server), contains( isIndexEvent(fe, userJson), - isSummaryEvent(fe.creationDate, fe.creationDate) + isSummaryEvent(fe.getCreationDate(), fe.getCreationDate()) )); } } @@ -337,7 +338,7 @@ public void twoFeatureEventsForSameUserGenerateOnlyOneIndexEvent() throws Except isIndexEvent(fe1, userJson), isFeatureEvent(fe1, flag1, false, null), isFeatureEvent(fe2, flag2, false, null), - isSummaryEvent(fe1.creationDate, fe2.creationDate) + isSummaryEvent(fe1.getCreationDate(), fe2.getCreationDate()) )); } } @@ -371,7 +372,7 @@ public void nonTrackedEventsAreSummarized() throws Exception { assertThat(getEventsFromLastRequest(server), contains( isIndexEvent(fe1a, userJson), allOf( - isSummaryEvent(fe1a.creationDate, fe2.creationDate), + isSummaryEvent(fe1a.getCreationDate(), fe2.getCreationDate()), hasSummaryFlag(flag1.getKey(), default1, Matchers.containsInAnyOrder( isSummaryEventCounter(flag1, 1, value1, 2), @@ -617,7 +618,7 @@ private JsonArray getEventsFromLastRequest(MockWebServer server) throws Exceptio private Matcher isIdentifyEvent(Event sourceEvent, JsonElement user) { return allOf( hasJsonProperty("kind", "identify"), - hasJsonProperty("creationDate", (double)sourceEvent.creationDate), + hasJsonProperty("creationDate", (double)sourceEvent.getCreationDate()), hasJsonProperty("user", user) ); } @@ -625,7 +626,7 @@ private Matcher isIdentifyEvent(Event sourceEvent, JsonElement user private Matcher isIndexEvent(Event sourceEvent, JsonElement user) { return allOf( hasJsonProperty("kind", "index"), - hasJsonProperty("creationDate", (double)sourceEvent.creationDate), + hasJsonProperty("creationDate", (double)sourceEvent.getCreationDate()), hasJsonProperty("user", user) ); } @@ -639,13 +640,13 @@ private Matcher isFeatureEvent(Event.FeatureRequest sourceEvent, Da EvaluationReason reason) { return allOf( hasJsonProperty("kind", debug ? "debug" : "feature"), - hasJsonProperty("creationDate", (double)sourceEvent.creationDate), + hasJsonProperty("creationDate", (double)sourceEvent.getCreationDate()), hasJsonProperty("key", flag.getKey()), hasJsonProperty("version", (double)flag.getVersion()), - hasJsonProperty("variation", sourceEvent.variation), - hasJsonProperty("value", sourceEvent.value), + hasJsonProperty("variation", sourceEvent.getVariation()), + hasJsonProperty("value", sourceEvent.getValue()), (inlineUser != null) ? hasJsonProperty("userKey", nullValue(JsonElement.class)) : - hasJsonProperty("userKey", sourceEvent.user.getKeyAsString()), + hasJsonProperty("userKey", sourceEvent.getUser().getKeyAsString()), (inlineUser != null) ? hasJsonProperty("user", inlineUser) : hasJsonProperty("user", nullValue(JsonElement.class)), (reason == null) ? hasJsonProperty("reason", nullValue(JsonElement.class)) : @@ -657,15 +658,15 @@ private Matcher isFeatureEvent(Event.FeatureRequest sourceEvent, Da private Matcher isCustomEvent(Event.Custom sourceEvent, JsonElement inlineUser) { return allOf( hasJsonProperty("kind", "custom"), - hasJsonProperty("creationDate", (double)sourceEvent.creationDate), + hasJsonProperty("creationDate", (double)sourceEvent.getCreationDate()), hasJsonProperty("key", "eventkey"), (inlineUser != null) ? hasJsonProperty("userKey", nullValue(JsonElement.class)) : - hasJsonProperty("userKey", sourceEvent.user.getKeyAsString()), + hasJsonProperty("userKey", sourceEvent.getUser().getKeyAsString()), (inlineUser != null) ? hasJsonProperty("user", inlineUser) : hasJsonProperty("user", nullValue(JsonElement.class)), - hasJsonProperty("data", sourceEvent.data), - (sourceEvent.metricValue == null) ? hasJsonProperty("metricValue", nullValue(JsonElement.class)) : - hasJsonProperty("metricValue", sourceEvent.metricValue.doubleValue()) + hasJsonProperty("data", sourceEvent.getData()), + (sourceEvent.getMetricValue() == null) ? hasJsonProperty("metricValue", nullValue(JsonElement.class)) : + hasJsonProperty("metricValue", sourceEvent.getMetricValue().doubleValue()) ); } diff --git a/src/test/java/com/launchdarkly/client/EvaluatorTest.java b/src/test/java/com/launchdarkly/client/EvaluatorTest.java index b56e45781..aa7bb2e6b 100644 --- a/src/test/java/com/launchdarkly/client/EvaluatorTest.java +++ b/src/test/java/com/launchdarkly/client/EvaluatorTest.java @@ -1,6 +1,7 @@ package com.launchdarkly.client; import com.google.common.collect.Iterables; +import com.launchdarkly.client.events.Event; import com.launchdarkly.client.value.LDValue; import org.junit.Test; @@ -192,10 +193,10 @@ public void flagReturnsOffVariationAndEventIfPrerequisiteIsOff() throws Exceptio assertEquals(1, Iterables.size(result.getPrerequisiteEvents())); Event.FeatureRequest event = Iterables.get(result.getPrerequisiteEvents(), 0); - assertEquals(f1.getKey(), event.key); - assertEquals(LDValue.of("go"), event.value); - assertEquals(f1.getVersion(), event.version.intValue()); - assertEquals(f0.getKey(), event.prereqOf); + assertEquals(f1.getKey(), event.getKey()); + assertEquals(LDValue.of("go"), event.getValue()); + assertEquals(f1.getVersion(), event.getVersion().intValue()); + assertEquals(f0.getKey(), event.getPrereqOf()); } @Test @@ -222,10 +223,10 @@ public void flagReturnsOffVariationAndEventIfPrerequisiteIsNotMet() throws Excep assertEquals(1, Iterables.size(result.getPrerequisiteEvents())); Event.FeatureRequest event = Iterables.get(result.getPrerequisiteEvents(), 0); - assertEquals(f1.getKey(), event.key); - assertEquals(LDValue.of("nogo"), event.value); - assertEquals(f1.getVersion(), event.version.intValue()); - assertEquals(f0.getKey(), event.prereqOf); + assertEquals(f1.getKey(), event.getKey()); + assertEquals(LDValue.of("nogo"), event.getValue()); + assertEquals(f1.getVersion(), event.getVersion().intValue()); + assertEquals(f0.getKey(), event.getPrereqOf()); } @Test @@ -269,10 +270,10 @@ public void flagReturnsFallthroughVariationAndEventIfPrerequisiteIsMetAndThereAr assertEquals(1, Iterables.size(result.getPrerequisiteEvents())); Event.FeatureRequest event = Iterables.get(result.getPrerequisiteEvents(), 0); - assertEquals(f1.getKey(), event.key); - assertEquals(LDValue.of("go"), event.value); - assertEquals(f1.getVersion(), event.version.intValue()); - assertEquals(f0.getKey(), event.prereqOf); + assertEquals(f1.getKey(), event.getKey()); + assertEquals(LDValue.of("go"), event.getValue()); + assertEquals(f1.getVersion(), event.getVersion().intValue()); + assertEquals(f0.getKey(), event.getPrereqOf()); } @Test @@ -305,16 +306,16 @@ public void multipleLevelsOfPrerequisitesProduceMultipleEvents() throws Exceptio assertEquals(2, Iterables.size(result.getPrerequisiteEvents())); Event.FeatureRequest event0 = Iterables.get(result.getPrerequisiteEvents(), 0); - assertEquals(f2.getKey(), event0.key); - assertEquals(LDValue.of("go"), event0.value); - assertEquals(f2.getVersion(), event0.version.intValue()); - assertEquals(f1.getKey(), event0.prereqOf); + assertEquals(f2.getKey(), event0.getKey()); + assertEquals(LDValue.of("go"), event0.getValue()); + assertEquals(f2.getVersion(), event0.getVersion().intValue()); + assertEquals(f1.getKey(), event0.getPrereqOf()); Event.FeatureRequest event1 = Iterables.get(result.getPrerequisiteEvents(), 1); - assertEquals(f1.getKey(), event1.key); - assertEquals(LDValue.of("go"), event1.value); - assertEquals(f1.getVersion(), event1.version.intValue()); - assertEquals(f0.getKey(), event1.prereqOf); + assertEquals(f1.getKey(), event1.getKey()); + assertEquals(LDValue.of("go"), event1.getValue()); + assertEquals(f1.getVersion(), event1.getVersion().intValue()); + assertEquals(f0.getKey(), event1.getPrereqOf()); } @Test diff --git a/src/test/java/com/launchdarkly/client/EventOutputTest.java b/src/test/java/com/launchdarkly/client/EventOutputTest.java index cebc4eb20..8bf850c75 100644 --- a/src/test/java/com/launchdarkly/client/EventOutputTest.java +++ b/src/test/java/com/launchdarkly/client/EventOutputTest.java @@ -2,8 +2,9 @@ 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.events.Event; +import com.launchdarkly.client.events.Event.FeatureRequest; import com.launchdarkly.client.value.LDValue; import com.launchdarkly.client.value.ObjectBuilder; diff --git a/src/test/java/com/launchdarkly/client/EventSummarizerTest.java b/src/test/java/com/launchdarkly/client/EventSummarizerTest.java index c1919a345..e439c0187 100644 --- a/src/test/java/com/launchdarkly/client/EventSummarizerTest.java +++ b/src/test/java/com/launchdarkly/client/EventSummarizerTest.java @@ -1,5 +1,6 @@ package com.launchdarkly.client; +import com.launchdarkly.client.events.Event; import com.launchdarkly.client.value.LDValue; import org.junit.Test; diff --git a/src/test/java/com/launchdarkly/client/LDClientEventTest.java b/src/test/java/com/launchdarkly/client/LDClientEventTest.java index c11d6ac71..27ce87b3a 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEventTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEventTest.java @@ -3,6 +3,7 @@ import com.google.gson.JsonElement; import com.google.gson.JsonPrimitive; import com.launchdarkly.client.EvaluationReason.ErrorKind; +import com.launchdarkly.client.events.Event; import com.launchdarkly.client.interfaces.FeatureStore; import com.launchdarkly.client.value.LDValue; @@ -45,7 +46,7 @@ public void identifySendsEvent() throws Exception { Event e = eventSink.events.get(0); assertEquals(Event.Identify.class, e.getClass()); Event.Identify ie = (Event.Identify)e; - assertEquals(user.getKey(), ie.user.getKey()); + assertEquals(user.getKey(), ie.getUser().getKey()); } @Test @@ -68,9 +69,9 @@ public void trackSendsEventWithoutData() throws Exception { 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(LDValue.ofNull(), ce.data); + assertEquals(user.getKey(), ce.getUser().getKey()); + assertEquals("eventkey", ce.getKey()); + assertEquals(LDValue.ofNull(), ce.getData()); } @Test @@ -82,9 +83,9 @@ public void trackSendsEventWithData() throws Exception { 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(user.getKey(), ce.getUser().getKey()); + assertEquals("eventkey", ce.getKey()); + assertEquals(data, ce.getData()); } @Test @@ -97,10 +98,10 @@ public void trackSendsEventWithDataAndMetricValue() throws Exception { 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); + assertEquals(user.getKey(), ce.getUser().getKey()); + assertEquals("eventkey", ce.getKey()); + assertEquals(data, ce.getData()); + assertEquals(new Double(metricValue), ce.getMetricValue()); } @SuppressWarnings("deprecation") @@ -113,9 +114,9 @@ public void deprecatedTrackSendsEventWithData() throws Exception { 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(user.getKey(), ce.getUser().getKey()); + assertEquals("eventkey", ce.getKey()); + assertEquals(data, ce.getData().asJsonElement()); } @SuppressWarnings("deprecation") @@ -129,10 +130,10 @@ public void deprecatedTrackSendsEventWithDataAndMetricValue() throws Exception { 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); + assertEquals(user.getKey(), ce.getUser().getKey()); + assertEquals("eventkey", ce.getKey()); + assertEquals(data, ce.getData().asJsonElement()); + assertEquals(new Double(metricValue), ce.getMetricValue()); } @Test @@ -375,8 +376,8 @@ public void eventTrackingAndReasonCanBeForcedForRule() throws Exception { assertEquals(1, eventSink.events.size()); Event.FeatureRequest event = (Event.FeatureRequest)eventSink.events.get(0); - assertTrue(event.trackEvents); - assertEquals(EvaluationReason.ruleMatch(0, "id"), event.reason); + assertTrue(event.isTrackEvents()); + assertEquals(EvaluationReason.ruleMatch(0, "id"), event.getReason()); } @Test @@ -399,8 +400,8 @@ public void eventTrackingAndReasonAreNotForcedIfFlagIsNotSetForMatchingRule() th assertEquals(1, eventSink.events.size()); Event.FeatureRequest event = (Event.FeatureRequest)eventSink.events.get(0); - assertFalse(event.trackEvents); - assertNull(event.reason); + assertFalse(event.isTrackEvents()); + assertNull(event.getReason()); } @Test @@ -420,8 +421,8 @@ public void eventTrackingAndReasonCanBeForcedForFallthrough() throws Exception { assertEquals(1, eventSink.events.size()); Event.FeatureRequest event = (Event.FeatureRequest)eventSink.events.get(0); - assertTrue(event.trackEvents); - assertEquals(EvaluationReason.fallthrough(), event.reason); + assertTrue(event.isTrackEvents()); + assertEquals(EvaluationReason.fallthrough(), event.getReason()); } @Test @@ -438,8 +439,8 @@ public void eventTrackingAndReasonAreNotForcedForFallthroughIfFlagIsNotSet() thr assertEquals(1, eventSink.events.size()); Event.FeatureRequest event = (Event.FeatureRequest)eventSink.events.get(0); - assertFalse(event.trackEvents); - assertNull(event.reason); + assertFalse(event.isTrackEvents()); + assertNull(event.getReason()); } @Test @@ -457,8 +458,8 @@ public void eventTrackingAndReasonAreNotForcedForFallthroughIfReasonIsNotFallthr assertEquals(1, eventSink.events.size()); Event.FeatureRequest event = (Event.FeatureRequest)eventSink.events.get(0); - assertFalse(event.trackEvents); - assertNull(event.reason); + assertFalse(event.isTrackEvents()); + assertNull(event.getReason()); } @Test @@ -554,29 +555,29 @@ private void checkFeatureEvent(Event e, DataModel.FeatureFlag flag, LDValue valu String prereqOf, EvaluationReason reason) { assertEquals(Event.FeatureRequest.class, e.getClass()); Event.FeatureRequest fe = (Event.FeatureRequest)e; - assertEquals(flag.getKey(), fe.key); - assertEquals(user.getKey(), fe.user.getKey()); - assertEquals(new Integer(flag.getVersion()), fe.version); - assertEquals(value, fe.value); - assertEquals(defaultVal, fe.defaultVal); - assertEquals(prereqOf, fe.prereqOf); - assertEquals(reason, fe.reason); - assertEquals(flag.isTrackEvents(), fe.trackEvents); - assertEquals(flag.getDebugEventsUntilDate(), fe.debugEventsUntilDate); + assertEquals(flag.getKey(), fe.getKey()); + assertEquals(user.getKey(), fe.getUser().getKey()); + assertEquals(new Integer(flag.getVersion()), fe.getVersion()); + assertEquals(value, fe.getValue()); + assertEquals(defaultVal, fe.getDefaultVal()); + assertEquals(prereqOf, fe.getPrereqOf()); + assertEquals(reason, fe.getReason()); + assertEquals(flag.isTrackEvents(), fe.isTrackEvents()); + assertEquals(flag.getDebugEventsUntilDate(), fe.getDebugEventsUntilDate()); } 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; - assertEquals(key, fe.key); - assertEquals(user.getKey(), fe.user.getKey()); - assertNull(fe.version); - assertEquals(defaultVal, fe.value); - assertEquals(defaultVal, fe.defaultVal); - assertEquals(prereqOf, fe.prereqOf); - assertEquals(reason, fe.reason); - assertFalse(fe.trackEvents); - assertNull(fe.debugEventsUntilDate); + assertEquals(key, fe.getKey()); + assertEquals(user.getKey(), fe.getUser().getKey()); + assertNull(fe.getVersion()); + assertEquals(defaultVal, fe.getValue()); + assertEquals(defaultVal, fe.getDefaultVal()); + assertEquals(prereqOf, fe.getPrereqOf()); + assertEquals(reason, fe.getReason()); + assertFalse(fe.isTrackEvents()); + assertNull(fe.getDebugEventsUntilDate()); } } diff --git a/src/test/java/com/launchdarkly/client/LDClientTest.java b/src/test/java/com/launchdarkly/client/LDClientTest.java index 2e200b908..3794c6f8f 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.events.Event; import com.launchdarkly.client.interfaces.EventProcessor; import com.launchdarkly.client.interfaces.FeatureStore; import com.launchdarkly.client.interfaces.UpdateProcessor; diff --git a/src/test/java/com/launchdarkly/client/TestUtil.java b/src/test/java/com/launchdarkly/client/TestUtil.java index cdbbe7587..7ed58fbe1 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.events.Event; import com.launchdarkly.client.interfaces.EventProcessor; import com.launchdarkly.client.interfaces.EventProcessorFactory; import com.launchdarkly.client.interfaces.FeatureStore; From 4b6f3e77ae1945d6dbf041c31aa21c559cffdcd5 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 17 Dec 2019 19:30:34 -0800 Subject: [PATCH 221/673] refactor helper methods --- .../client/EvaluatorOperators.java | 21 +++++- .../java/com/launchdarkly/client/Util.java | 25 ------- .../EvaluatorOperatorsParameterizedTest.java | 6 ++ .../com/launchdarkly/client/UtilTest.java | 71 ------------------- 4 files changed, 24 insertions(+), 99 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/EvaluatorOperators.java b/src/main/java/com/launchdarkly/client/EvaluatorOperators.java index 38f4b8897..fbb38c325 100644 --- a/src/main/java/com/launchdarkly/client/EvaluatorOperators.java +++ b/src/main/java/com/launchdarkly/client/EvaluatorOperators.java @@ -3,6 +3,7 @@ import com.launchdarkly.client.value.LDValue; import org.joda.time.DateTime; +import org.joda.time.DateTimeZone; import java.util.regex.Pattern; @@ -98,8 +99,8 @@ private static boolean compareNumeric(ComparisonOp op, LDValue userValue, LDValu } private static boolean compareDate(ComparisonOp op, LDValue userValue, LDValue clauseValue) { - DateTime dt1 = Util.jsonPrimitiveToDateTime(userValue); - DateTime dt2 = Util.jsonPrimitiveToDateTime(clauseValue); + DateTime dt1 = valueToDateTime(userValue); + DateTime dt2 = valueToDateTime(clauseValue); if (dt1 == null || dt2 == null) { return false; } @@ -114,7 +115,21 @@ private static boolean compareSemVer(ComparisonOp op, LDValue userValue, LDValue } return op.test(sv1.compareTo(sv2)); } - + + private static DateTime valueToDateTime(LDValue value) { + if (value.isNumber()) { + return new DateTime(value.longValue()); + } else if (value.isString()) { + try { + return new DateTime(value.stringValue(), DateTimeZone.UTC); + } catch (Throwable t) { + return null; + } + } else { + return null; + } + } + private static SemanticVersion valueToSemVer(LDValue value) { if (!value.isString()) { return null; diff --git a/src/main/java/com/launchdarkly/client/Util.java b/src/main/java/com/launchdarkly/client/Util.java index 757aa8cdb..712c9ffd9 100644 --- a/src/main/java/com/launchdarkly/client/Util.java +++ b/src/main/java/com/launchdarkly/client/Util.java @@ -1,11 +1,5 @@ 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 java.util.concurrent.TimeUnit; import okhttp3.ConnectionPool; @@ -13,25 +7,6 @@ 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. - * @return null if input is not a valid format. - */ - static DateTime jsonPrimitiveToDateTime(LDValue maybeDate) { - if (maybeDate.isNumber()) { - return new DateTime((long)maybeDate.doubleValue()); - } else if (maybeDate.isString()) { - try { - return new DateTime(maybeDate.stringValue(), DateTimeZone.UTC); - } catch (Throwable t) { - return null; - } - } else { - return null; - } - } - static void configureHttpClientBuilder(LDConfig config, OkHttpClient.Builder builder) { builder.connectionPool(new ConnectionPool(5, 5, TimeUnit.SECONDS)) .connectTimeout(config.connectTimeout, config.connectTimeoutUnit) diff --git a/src/test/java/com/launchdarkly/client/EvaluatorOperatorsParameterizedTest.java b/src/test/java/com/launchdarkly/client/EvaluatorOperatorsParameterizedTest.java index dc417c18e..1d69208e9 100644 --- a/src/test/java/com/launchdarkly/client/EvaluatorOperatorsParameterizedTest.java +++ b/src/test/java/com/launchdarkly/client/EvaluatorOperatorsParameterizedTest.java @@ -15,6 +15,8 @@ public class EvaluatorOperatorsParameterizedTest { 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 dateStrUtc1 = LDValue.of("2017-12-06T00:00:00.000Z"); + private static LDValue dateStrUtc2 = LDValue.of("2017-12-06T00:01:01.000Z"); private static LDValue dateMs1 = LDValue.of(10000000); private static LDValue dateMs2 = LDValue.of(10000001); private static LDValue invalidDate = LDValue.of("hey what's this?"); @@ -83,15 +85,19 @@ public static Iterable data() { // dates { DataModel.Operator.before, dateStr1, dateStr2, true }, + { DataModel.Operator.before, dateStrUtc1, dateStrUtc2, true }, { DataModel.Operator.before, dateMs1, dateMs2, true }, { DataModel.Operator.before, dateStr2, dateStr1, false }, + { DataModel.Operator.before, dateStrUtc2, dateStrUtc1, false }, { DataModel.Operator.before, dateMs2, dateMs1, false }, { DataModel.Operator.before, dateStr1, dateStr1, false }, { DataModel.Operator.before, dateMs1, dateMs1, false }, { DataModel.Operator.before, dateStr1, invalidDate, false }, { DataModel.Operator.after, dateStr1, dateStr2, false }, + { DataModel.Operator.after, dateStrUtc1, dateStrUtc2, false }, { DataModel.Operator.after, dateMs1, dateMs2, false }, { DataModel.Operator.after, dateStr2, dateStr1, true }, + { DataModel.Operator.after, dateStrUtc2, dateStrUtc1, true }, { DataModel.Operator.after, dateMs2, dateMs1, true }, { DataModel.Operator.after, dateStr1, dateStr1, false }, { DataModel.Operator.after, dateMs1, dateMs1, false }, diff --git a/src/test/java/com/launchdarkly/client/UtilTest.java b/src/test/java/com/launchdarkly/client/UtilTest.java index a6423aee1..b607536de 100644 --- a/src/test/java/com/launchdarkly/client/UtilTest.java +++ b/src/test/java/com/launchdarkly/client/UtilTest.java @@ -1,10 +1,5 @@ package com.launchdarkly.client; -import com.launchdarkly.client.value.LDValue; - -import org.joda.time.DateTime; -import org.joda.time.DateTimeZone; -import org.junit.Assert; import org.junit.Test; import static com.launchdarkly.client.Util.configureHttpClientBuilder; @@ -15,72 +10,6 @@ @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(LDValue.of(validRFC3339String)); - Assert.assertEquals(expected, actual.toString()); - } - - @Test - public void testDateTimeConversionWithUtc() { - String validRFC3339String = "1970-01-01T00:00:01.001Z"; - - DateTime actual = Util.jsonPrimitiveToDateTime(LDValue.of(validRFC3339String)); - Assert.assertEquals(validRFC3339String, actual.toString()); - } - - @Test - public void testDateTimeConversionWithNoTimeZone() { - String validRFC3339String = "2016-04-16T17:09:12.759"; - String expected = "2016-04-16T17:09:12.759Z"; - - DateTime actual = Util.jsonPrimitiveToDateTime(LDValue.of(validRFC3339String)); - Assert.assertEquals(expected, actual.toString()); - } - - @Test - public void testDateTimeConversionTimestampWithNoMillis() { - String validRFC3339String = "2016-04-16T17:09:12"; - String expected = "2016-04-16T17:09:12.000Z"; - - DateTime actual = Util.jsonPrimitiveToDateTime(LDValue.of(validRFC3339String)); - Assert.assertEquals(expected, actual.toString()); - } - - @Test - public void testDateTimeConversionAsUnixMillis() { - long unixMillis = 1000; - String expected = "1970-01-01T00:00:01.000Z"; - DateTime actual = Util.jsonPrimitiveToDateTime(LDValue.of(unixMillis)); - Assert.assertEquals(expected, actual.withZone(DateTimeZone.UTC).toString()); - } - - @Test - public void testDateTimeConversionCompare() { - long aMillis = 1001; - String bStamp = "1970-01-01T00:00:01.001Z"; - 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(LDValue.of(unixMillis)); - Assert.assertEquals(unixMillis, actual.getMillis()); - } - - @Test - public void testDateTimeConversionInvalidString() { - String invalidTimestamp = "May 3, 1980"; - DateTime actual = Util.jsonPrimitiveToDateTime(LDValue.of(invalidTimestamp)); - Assert.assertNull(actual); - } - @Test public void testConnectTimeoutSpecifiedInSeconds() { LDConfig config = new LDConfig.Builder().connectTimeout(3).build(); From 0203781452d51e091d2e10ba2fe54cbbc79e0a34 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 17 Dec 2019 20:12:25 -0800 Subject: [PATCH 222/673] rename FeatureStore/UpdateProcessor to DataStore/DataSource + misc interface cleanup --- .../com/launchdarkly/client/Components.java | 143 +++++++++++++----- ...eConfig.java => DataStoreCacheConfig.java} | 54 +++---- ...apper.java => DataStoreClientWrapper.java} | 12 +- ...orter.java => DataStoreDataSetSorter.java} | 6 +- .../com/launchdarkly/client/Evaluator.java | 2 +- ...atureStore.java => InMemoryDataStore.java} | 10 +- .../com/launchdarkly/client/LDClient.java | 79 ++++------ .../com/launchdarkly/client/LDConfig.java | 60 +++----- .../launchdarkly/client/PollingProcessor.java | 12 +- ...sFeatureStore.java => RedisDataStore.java} | 38 ++--- ...uilder.java => RedisDataStoreBuilder.java} | 104 ++++++------- .../launchdarkly/client/StreamProcessor.java | 12 +- ...stFeatureStore.java => TestDataStore.java} | 6 +- .../client/files/DataBuilder.java | 2 +- .../client/files/FileComponents.java | 2 +- .../client/files/FileDataSource.java | 12 +- .../client/files/FileDataSourceFactory.java | 14 +- .../{UpdateProcessor.java => DataSource.java} | 31 +--- ...sorFactory.java => DataSourceFactory.java} | 12 +- .../{FeatureStore.java => DataStore.java} | 4 +- .../DataStoreCore.java} | 21 ++- ...toreFactory.java => DataStoreFactory.java} | 10 +- .../client/interfaces/EventProcessor.java | 17 --- .../client/interfaces/VersionedData.java | 2 +- .../client/interfaces/VersionedDataKind.java | 6 +- .../client/utils/CachingStoreWrapper.java | 37 ++--- ...toreHelpers.java => DataStoreHelpers.java} | 17 ++- ...ingTest.java => DataStoreCachingTest.java} | 49 +++--- ...se.java => DataStoreDatabaseTestBase.java} | 15 +- ...reTestBase.java => DataStoreTestBase.java} | 16 +- .../client/InMemoryDataStoreTest.java | 9 ++ .../client/InMemoryFeatureStoreTest.java | 9 -- .../client/LDClientEvaluationTest.java | 114 +++++++------- .../client/LDClientEventTest.java | 56 +++---- .../client/LDClientLddModeTest.java | 19 ++- .../client/LDClientOfflineTest.java | 30 ++-- .../com/launchdarkly/client/LDClientTest.java | 108 ++++++------- .../client/PollingProcessorTest.java | 10 +- ...st.java => RedisDataStoreBuilderTest.java} | 44 +++--- ...StoreTest.java => RedisDataStoreTest.java} | 18 +-- .../client/StreamProcessorTest.java | 36 ++--- .../com/launchdarkly/client/TestUtil.java | 42 ++--- .../files/ClientWithFileDataSourceTest.java | 2 +- .../client/files/FileDataSourceTest.java | 26 ++-- .../client/utils/CachingStoreWrapperTest.java | 11 +- 45 files changed, 663 insertions(+), 676 deletions(-) rename src/main/java/com/launchdarkly/client/{FeatureStoreCacheConfig.java => DataStoreCacheConfig.java} (77%) rename src/main/java/com/launchdarkly/client/{FeatureStoreClientWrapper.java => DataStoreClientWrapper.java} (80%) rename src/main/java/com/launchdarkly/client/{FeatureStoreDataSetSorter.java => DataStoreDataSetSorter.java} (96%) rename src/main/java/com/launchdarkly/client/{InMemoryFeatureStore.java => InMemoryDataStore.java} (94%) rename src/main/java/com/launchdarkly/client/{RedisFeatureStore.java => RedisDataStore.java} (86%) rename src/main/java/com/launchdarkly/client/{RedisFeatureStoreBuilder.java => RedisDataStoreBuilder.java} (63%) rename src/main/java/com/launchdarkly/client/{TestFeatureStore.java => TestDataStore.java} (93%) rename src/main/java/com/launchdarkly/client/interfaces/{UpdateProcessor.java => DataSource.java} (52%) rename src/main/java/com/launchdarkly/client/interfaces/{UpdateProcessorFactory.java => DataSourceFactory.java} (54%) rename src/main/java/com/launchdarkly/client/interfaces/{FeatureStore.java => DataStore.java} (98%) rename src/main/java/com/launchdarkly/client/{utils/FeatureStoreCore.java => interfaces/DataStoreCore.java} (84%) rename src/main/java/com/launchdarkly/client/interfaces/{FeatureStoreFactory.java => DataStoreFactory.java} (61%) rename src/main/java/com/launchdarkly/client/utils/{FeatureStoreHelpers.java => DataStoreHelpers.java} (65%) rename src/test/java/com/launchdarkly/client/{FeatureStoreCachingTest.java => DataStoreCachingTest.java} (59%) rename src/test/java/com/launchdarkly/client/{FeatureStoreDatabaseTestBase.java => DataStoreDatabaseTestBase.java} (92%) rename src/test/java/com/launchdarkly/client/{FeatureStoreTestBase.java => DataStoreTestBase.java} (93%) create mode 100644 src/test/java/com/launchdarkly/client/InMemoryDataStoreTest.java delete mode 100644 src/test/java/com/launchdarkly/client/InMemoryFeatureStoreTest.java rename src/test/java/com/launchdarkly/client/{RedisFeatureStoreBuilderTest.java => RedisDataStoreBuilderTest.java} (56%) rename src/test/java/com/launchdarkly/client/{RedisFeatureStoreTest.java => RedisDataStoreTest.java} (55%) diff --git a/src/main/java/com/launchdarkly/client/Components.java b/src/main/java/com/launchdarkly/client/Components.java index f2c861a58..5f0eb4b9c 100644 --- a/src/main/java/com/launchdarkly/client/Components.java +++ b/src/main/java/com/launchdarkly/client/Components.java @@ -2,52 +2,51 @@ import com.launchdarkly.client.interfaces.EventProcessor; import com.launchdarkly.client.interfaces.EventProcessorFactory; -import com.launchdarkly.client.interfaces.FeatureStore; -import com.launchdarkly.client.interfaces.FeatureStoreFactory; -import com.launchdarkly.client.interfaces.UpdateProcessor; -import com.launchdarkly.client.interfaces.UpdateProcessorFactory; +import com.launchdarkly.client.interfaces.DataStore; +import com.launchdarkly.client.interfaces.DataStoreFactory; +import com.launchdarkly.client.events.Event; +import com.launchdarkly.client.interfaces.DataSource; +import com.launchdarkly.client.interfaces.DataSourceFactory; 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. * @since 4.0.0 */ public abstract class Components { - private static final FeatureStoreFactory inMemoryFeatureStoreFactory = new InMemoryFeatureStoreFactory(); - private static final EventProcessorFactory defaultEventProcessorFactory = new DefaultEventProcessorFactory(); - private static final EventProcessorFactory nullEventProcessorFactory = new NullEventProcessorFactory(); - private static final UpdateProcessorFactory defaultUpdateProcessorFactory = new DefaultUpdateProcessorFactory(); - 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 {@link DataStore}. * @return a factory object */ - public static FeatureStoreFactory inMemoryFeatureStore() { - return inMemoryFeatureStoreFactory; + public static DataStoreFactory inMemoryDataStore() { + return InMemoryDataStoreFactory.INSTANCE; } /** - * Returns a factory with builder methods for creating a Redis-backed implementation of {@link FeatureStore}, - * using {@link RedisFeatureStoreBuilder#DEFAULT_URI}. + * Returns a factory with builder methods for creating a Redis-backed implementation of {@link DataStore}, + * using {@link RedisDataStoreBuilder#DEFAULT_URI}. * @return a factory/builder object */ - public static RedisFeatureStoreBuilder redisFeatureStore() { - return new RedisFeatureStoreBuilder(); + public static RedisDataStoreBuilder redisDataStore() { + return new RedisDataStoreBuilder(); } /** - * Returns a factory with builder methods for creating a Redis-backed implementation of {@link FeatureStore}, + * Returns a factory with builder methods for creating a Redis-backed implementation of {@link DataStore}, * 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); + public static RedisDataStoreBuilder redisDataStore(URI redisUri) { + return new RedisDataStoreBuilder(redisUri); } /** @@ -57,7 +56,7 @@ public static RedisFeatureStoreBuilder redisFeatureStore(URI redisUri) { * @return a factory object */ public static EventProcessorFactory defaultEventProcessor() { - return defaultEventProcessorFactory; + return DefaultEventProcessorFactory.INSTANCE; } /** @@ -66,40 +65,48 @@ public static EventProcessorFactory defaultEventProcessor() { * @return a factory object */ public static EventProcessorFactory nullEventProcessor() { - return nullEventProcessorFactory; + return NullEventProcessorFactory.INSTANCE; } /** - * Returns a factory for the default implementation of {@link UpdateProcessor}, which receives + * Returns a factory for the default implementation of {@link DataSource}, 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; + public static DataSourceFactory defaultDataSource() { + return DefaultDataSourceFactory.INSTANCE; } /** - * Returns a factory for a null implementation of {@link UpdateProcessor}, which does not + * Returns a factory for a null implementation of {@link DataSource}, which does not * connect to LaunchDarkly, regardless of any other configuration. * @return a factory object */ - public static UpdateProcessorFactory nullUpdateProcessor() { - return nullUpdateProcessorFactory; + public static DataSourceFactory nullDataSource() { + return NullDataSourceFactory.INSTANCE; } - private static final class InMemoryFeatureStoreFactory implements FeatureStoreFactory { + private static final class InMemoryDataStoreFactory implements DataStoreFactory { + static final InMemoryDataStoreFactory INSTANCE = new InMemoryDataStoreFactory(); + + private InMemoryDataStoreFactory() {} + @Override - public FeatureStore createFeatureStore() { - return new InMemoryFeatureStore(); + public DataStore createDataStore() { + return new InMemoryDataStore(); } } private static final class DefaultEventProcessorFactory implements EventProcessorFactory { + static final DefaultEventProcessorFactory INSTANCE = new DefaultEventProcessorFactory(); + + private DefaultEventProcessorFactory() {} + @Override public EventProcessor createEventProcessor(String sdkKey, LDConfig config) { if (config.offline || !config.sendEvents) { - return new EventProcessor.NullEventProcessor(); + return new NullEventProcessor(); } else { return new DefaultEventProcessor(sdkKey, config); } @@ -107,41 +114,93 @@ public EventProcessor createEventProcessor(String sdkKey, LDConfig config) { } private static final class NullEventProcessorFactory implements EventProcessorFactory { + static final NullEventProcessorFactory INSTANCE = new NullEventProcessorFactory(); + + private NullEventProcessorFactory() {} + public EventProcessor createEventProcessor(String sdkKey, LDConfig config) { - return new EventProcessor.NullEventProcessor(); + return NullEventProcessor.INSTANCE; + } + } + + /** + * Stub implementation of {@link EventProcessor} for when we don't want to send any events. + */ + static final class NullEventProcessor implements EventProcessor { + static final NullEventProcessor INSTANCE = new NullEventProcessor(); + + private NullEventProcessor() {} + + @Override + public void sendEvent(Event e) { + } + + @Override + public void flush() { + } + + @Override + public void close() { } } - private static final class DefaultUpdateProcessorFactory implements UpdateProcessorFactory { + private static final class DefaultDataSourceFactory implements DataSourceFactory { // Note, logger uses LDClient class name for backward compatibility private static final Logger logger = LoggerFactory.getLogger(LDClient.class); + static final DefaultDataSourceFactory INSTANCE = new DefaultDataSourceFactory(); + + private DefaultDataSourceFactory() {} @Override - public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, FeatureStore featureStore) { + public DataSource createDataSource(String sdkKey, LDConfig config, DataStore dataStore) { if (config.offline) { logger.info("Starting LaunchDarkly client in offline mode"); - return new UpdateProcessor.NullUpdateProcessor(); + return new NullDataSource(); } else if (config.useLdd) { logger.info("Starting LaunchDarkly in LDD mode. Skipping direct feature retrieval."); - return new UpdateProcessor.NullUpdateProcessor(); + return new NullDataSource(); } else { 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, dataStore, 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 new PollingProcessor(config, requestor, dataStore); } } } } - private static final class NullUpdateProcessorFactory implements UpdateProcessorFactory { + private static final class NullDataSourceFactory implements DataSourceFactory { + static final NullDataSourceFactory INSTANCE = new NullDataSourceFactory(); + + private NullDataSourceFactory() {} + + @Override + public DataSource createDataSource(String sdkKey, LDConfig config, DataStore dataStore) { + return NullDataSource.INSTANCE; + } + } + + // exposed as package-private for testing + static final class NullDataSource implements DataSource { + static final NullDataSource INSTANCE = new NullDataSource(); + + private NullDataSource() {} + @Override - public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, FeatureStore featureStore) { - return new UpdateProcessor.NullUpdateProcessor(); + public Future start() { + return immediateFuture(null); } + + @Override + public boolean initialized() { + return true; + } + + @Override + public void close() throws IOException {} } } diff --git a/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java b/src/main/java/com/launchdarkly/client/DataStoreCacheConfig.java similarity index 77% rename from src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java rename to src/main/java/com/launchdarkly/client/DataStoreCacheConfig.java index 4c8363709..0c2ac65ab 100644 --- a/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java +++ b/src/main/java/com/launchdarkly/client/DataStoreCacheConfig.java @@ -1,13 +1,13 @@ package com.launchdarkly.client; import com.google.common.cache.CacheBuilder; -import com.launchdarkly.client.interfaces.FeatureStore; +import com.launchdarkly.client.interfaces.DataStore; import java.util.Objects; import java.util.concurrent.TimeUnit; /** - * Parameters that can be used for {@link FeatureStore} implementations that support local caching. + * Parameters that can be used for {@link DataStore} 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. *

    @@ -16,39 +16,39 @@ * to set other properties: * *

    
    - *     Components.redisFeatureStore()
    + *     Components.redisDataStore()
      *         .caching(
    - *             FeatureStoreCacheConfig.enabled()
    + *             DataStoreCacheConfig.enabled()
      *                 .ttlSeconds(30)
    - *                 .staleValuesPolicy(FeatureStoreCacheConfig.StaleValuesPolicy.REFRESH)
    + *                 .staleValuesPolicy(DataStoreCacheConfig.StaleValuesPolicy.REFRESH)
      *         )
      * 
    * - * @see RedisFeatureStoreBuilder#caching(FeatureStoreCacheConfig) + * @see RedisDataStoreBuilder#caching(DataStoreCacheConfig) * @since 4.6.0 */ -public final class FeatureStoreCacheConfig { +public final class DataStoreCacheConfig { /** * 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 + * The caching parameters that the data 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 FeatureStoreCacheConfig DEFAULT = - new FeatureStoreCacheConfig(DEFAULT_TIME_SECONDS, TimeUnit.SECONDS, StaleValuesPolicy.EVICT); + public static final DataStoreCacheConfig DEFAULT = + new DataStoreCacheConfig(DEFAULT_TIME_SECONDS, TimeUnit.SECONDS, StaleValuesPolicy.EVICT); - private static final FeatureStoreCacheConfig DISABLED = - new FeatureStoreCacheConfig(0, TimeUnit.MILLISECONDS, StaleValuesPolicy.EVICT); + private static final DataStoreCacheConfig DISABLED = + new DataStoreCacheConfig(0, TimeUnit.MILLISECONDS, StaleValuesPolicy.EVICT); private final long cacheTime; private final TimeUnit cacheTimeUnit; private final StaleValuesPolicy staleValuesPolicy; /** - * Possible values for {@link FeatureStoreCacheConfig#staleValuesPolicy(StaleValuesPolicy)}. + * Possible values for {@link DataStoreCacheConfig#staleValuesPolicy(StaleValuesPolicy)}. */ public enum StaleValuesPolicy { /** @@ -82,7 +82,7 @@ public enum StaleValuesPolicy { * 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 + * stale values for the period of the async refresh. For example, configuring this data store * with a very low cache time and enabling this feature would see great performance benefit by * decoupling calls from network I/O. *

    @@ -95,9 +95,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 FeatureStoreCacheConfig} instance + * @return a {@link DataStoreCacheConfig} instance */ - public static FeatureStoreCacheConfig disabled() { + public static DataStoreCacheConfig disabled() { return DISABLED; } @@ -105,13 +105,13 @@ public static FeatureStoreCacheConfig 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 FeatureStoreCacheConfig} instance + * @return a {@link DataStoreCacheConfig} instance */ - public static FeatureStoreCacheConfig enabled() { + public static DataStoreCacheConfig enabled() { return DEFAULT; } - private FeatureStoreCacheConfig(long cacheTime, TimeUnit cacheTimeUnit, StaleValuesPolicy staleValuesPolicy) { + private DataStoreCacheConfig(long cacheTime, TimeUnit cacheTimeUnit, StaleValuesPolicy staleValuesPolicy) { this.cacheTime = cacheTime; this.cacheTimeUnit = cacheTimeUnit; this.staleValuesPolicy = staleValuesPolicy; @@ -167,8 +167,8 @@ public StaleValuesPolicy getStaleValuesPolicy() { * @param timeUnit the time unit * @return an updated parameters object */ - public FeatureStoreCacheConfig ttl(long cacheTime, TimeUnit timeUnit) { - return new FeatureStoreCacheConfig(cacheTime, timeUnit, staleValuesPolicy); + public DataStoreCacheConfig ttl(long cacheTime, TimeUnit timeUnit) { + return new DataStoreCacheConfig(cacheTime, timeUnit, staleValuesPolicy); } /** @@ -177,7 +177,7 @@ public FeatureStoreCacheConfig ttl(long cacheTime, TimeUnit timeUnit) { * @param millis the cache TTL in milliseconds * @return an updated parameters object */ - public FeatureStoreCacheConfig ttlMillis(long millis) { + public DataStoreCacheConfig ttlMillis(long millis) { return ttl(millis, TimeUnit.MILLISECONDS); } @@ -187,7 +187,7 @@ public FeatureStoreCacheConfig ttlMillis(long millis) { * @param seconds the cache TTL in seconds * @return an updated parameters object */ - public FeatureStoreCacheConfig ttlSeconds(long seconds) { + public DataStoreCacheConfig ttlSeconds(long seconds) { return ttl(seconds, TimeUnit.SECONDS); } @@ -198,14 +198,14 @@ public FeatureStoreCacheConfig ttlSeconds(long seconds) { * @param policy a {@link StaleValuesPolicy} constant * @return an updated parameters object */ - public FeatureStoreCacheConfig staleValuesPolicy(StaleValuesPolicy policy) { - return new FeatureStoreCacheConfig(cacheTime, cacheTimeUnit, policy); + public DataStoreCacheConfig staleValuesPolicy(StaleValuesPolicy policy) { + return new DataStoreCacheConfig(cacheTime, cacheTimeUnit, policy); } @Override public boolean equals(Object other) { - if (other instanceof FeatureStoreCacheConfig) { - FeatureStoreCacheConfig o = (FeatureStoreCacheConfig) other; + if (other instanceof DataStoreCacheConfig) { + DataStoreCacheConfig o = (DataStoreCacheConfig) other; return o.cacheTime == this.cacheTime && o.cacheTimeUnit == this.cacheTimeUnit && o.staleValuesPolicy == this.staleValuesPolicy; } diff --git a/src/main/java/com/launchdarkly/client/FeatureStoreClientWrapper.java b/src/main/java/com/launchdarkly/client/DataStoreClientWrapper.java similarity index 80% rename from src/main/java/com/launchdarkly/client/FeatureStoreClientWrapper.java rename to src/main/java/com/launchdarkly/client/DataStoreClientWrapper.java index 583f4dc4f..ba2c4a5a6 100644 --- a/src/main/java/com/launchdarkly/client/FeatureStoreClientWrapper.java +++ b/src/main/java/com/launchdarkly/client/DataStoreClientWrapper.java @@ -1,6 +1,6 @@ package com.launchdarkly.client; -import com.launchdarkly.client.interfaces.FeatureStore; +import com.launchdarkly.client.interfaces.DataStore; import com.launchdarkly.client.interfaces.VersionedData; import com.launchdarkly.client.interfaces.VersionedDataKind; @@ -8,22 +8,22 @@ import java.util.Map; /** - * Provides additional behavior that the client requires before or after feature store operations. + * Provides additional behavior that the client requires before or after data 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; +class DataStoreClientWrapper implements DataStore { + private final DataStore store; - public FeatureStoreClientWrapper(FeatureStore store) { + public DataStoreClientWrapper(DataStore store) { this.store = store; } @Override public void init(Map, Map> allData) { - store.init(FeatureStoreDataSetSorter.sortAllCollections(allData)); + store.init(DataStoreDataSetSorter.sortAllCollections(allData)); } @Override diff --git a/src/main/java/com/launchdarkly/client/FeatureStoreDataSetSorter.java b/src/main/java/com/launchdarkly/client/DataStoreDataSetSorter.java similarity index 96% rename from src/main/java/com/launchdarkly/client/FeatureStoreDataSetSorter.java rename to src/main/java/com/launchdarkly/client/DataStoreDataSetSorter.java index 92eb49919..8ba1174f0 100644 --- a/src/main/java/com/launchdarkly/client/FeatureStoreDataSetSorter.java +++ b/src/main/java/com/launchdarkly/client/DataStoreDataSetSorter.java @@ -10,12 +10,12 @@ 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.interfaces.FeatureStore#init(Map)}. + * Implements a dependency graph ordering for data to be stored in a data store. We must use this + * on every data set that will be passed to {@link com.launchdarkly.client.interfaces.DataStore#init(Map)}. * * @since 4.6.1 */ -abstract class FeatureStoreDataSetSorter { +abstract class DataStoreDataSetSorter { /** * 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 diff --git a/src/main/java/com/launchdarkly/client/Evaluator.java b/src/main/java/com/launchdarkly/client/Evaluator.java index 2329f70f6..08fe53e6e 100644 --- a/src/main/java/com/launchdarkly/client/Evaluator.java +++ b/src/main/java/com/launchdarkly/client/Evaluator.java @@ -24,7 +24,7 @@ class Evaluator { private final Getters getters; /** - * An abstraction of getting flags or segments by key. This ensures that Evaluator cannot modify the feature store, + * An abstraction of getting flags or segments by key. This ensures that Evaluator cannot modify the data store, * and simplifies testing. */ static interface Getters { diff --git a/src/main/java/com/launchdarkly/client/InMemoryFeatureStore.java b/src/main/java/com/launchdarkly/client/InMemoryDataStore.java similarity index 94% rename from src/main/java/com/launchdarkly/client/InMemoryFeatureStore.java rename to src/main/java/com/launchdarkly/client/InMemoryDataStore.java index 2405f7f7d..fd3116788 100644 --- a/src/main/java/com/launchdarkly/client/InMemoryFeatureStore.java +++ b/src/main/java/com/launchdarkly/client/InMemoryDataStore.java @@ -1,6 +1,6 @@ package com.launchdarkly.client; -import com.launchdarkly.client.interfaces.FeatureStore; +import com.launchdarkly.client.interfaces.DataStore; import com.launchdarkly.client.interfaces.VersionedData; import com.launchdarkly.client.interfaces.VersionedDataKind; @@ -14,10 +14,10 @@ /** * A thread-safe, versioned store for feature flags and related data based on a - * {@link HashMap}. This is the default implementation of {@link FeatureStore}. + * {@link HashMap}. This is the default implementation of {@link DataStore}. */ -public class InMemoryFeatureStore implements FeatureStore { - private static final Logger logger = LoggerFactory.getLogger(InMemoryFeatureStore.class); +public class InMemoryDataStore implements DataStore { + private static final Logger logger = LoggerFactory.getLogger(InMemoryDataStore.class); private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); private final Map, Map> allData = new HashMap<>(); @@ -79,7 +79,7 @@ public void init(Map, Map> lock.writeLock().lock(); this.allData.clear(); for (Map.Entry, Map> entry: allData.entrySet()) { - // Note, the FeatureStore contract specifies that we should clone all of the maps. This doesn't + // Note, the DataStore 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())); diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index 64215f2d9..ea783ae7b 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -4,10 +4,10 @@ import com.launchdarkly.client.events.Event; import com.launchdarkly.client.interfaces.EventProcessor; import com.launchdarkly.client.interfaces.EventProcessorFactory; -import com.launchdarkly.client.interfaces.FeatureStore; -import com.launchdarkly.client.interfaces.FeatureStoreFactory; -import com.launchdarkly.client.interfaces.UpdateProcessor; -import com.launchdarkly.client.interfaces.UpdateProcessorFactory; +import com.launchdarkly.client.interfaces.DataStore; +import com.launchdarkly.client.interfaces.DataStoreFactory; +import com.launchdarkly.client.interfaces.DataSource; +import com.launchdarkly.client.interfaces.DataSourceFactory; import com.launchdarkly.client.value.LDValue; import org.apache.commons.codec.binary.Hex; @@ -46,9 +46,8 @@ public final class LDClient implements LDClientInterface { private final String sdkKey; private final Evaluator evaluator; final EventProcessor eventProcessor; - final UpdateProcessor updateProcessor; - final FeatureStore featureStore; - final boolean shouldCloseFeatureStore; + final DataSource dataSource; + final DataStore dataStore; /** * Creates a new client instance that connects to LaunchDarkly with the default configuration. In most @@ -71,30 +70,18 @@ public LDClient(String sdkKey, LDConfig config) { this.config = checkNotNull(config, "config must not be null"); this.sdkKey = checkNotNull(sdkKey, "sdkKey must not be null"); - FeatureStore store; - if (config.deprecatedFeatureStore != null) { - 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 - // doing so until the next major version eliminates that mechanism. We will always dispose - // of instances that we created ourselves from a factory. - this.shouldCloseFeatureStore = false; - } else { - FeatureStoreFactory factory = config.featureStoreFactory == null ? - Components.inMemoryFeatureStore() : config.featureStoreFactory; - store = factory.createFeatureStore(); - this.shouldCloseFeatureStore = true; - } - this.featureStore = new FeatureStoreClientWrapper(store); + DataStoreFactory factory = config.dataStoreFactory == null ? + Components.inMemoryDataStore() : config.dataStoreFactory; + DataStore store = factory.createDataStore(); + this.dataStore = new DataStoreClientWrapper(store); this.evaluator = new Evaluator(new Evaluator.Getters() { public DataModel.FeatureFlag getFlag(String key) { - return LDClient.this.featureStore.get(FEATURES, key); + return LDClient.this.dataStore.get(FEATURES, key); } public DataModel.Segment getSegment(String key) { - return LDClient.this.featureStore.get(SEGMENTS, key); + return LDClient.this.dataStore.get(SEGMENTS, key); } }); @@ -102,10 +89,10 @@ public DataModel.Segment getSegment(String key) { Components.defaultEventProcessor() : config.eventProcessorFactory; this.eventProcessor = epFactory.createEventProcessor(sdkKey, config); - UpdateProcessorFactory upFactory = config.updateProcessorFactory == null ? - Components.defaultUpdateProcessor() : config.updateProcessorFactory; - this.updateProcessor = upFactory.createUpdateProcessor(sdkKey, config, featureStore); - Future startFuture = updateProcessor.start(); + DataSourceFactory dataSourceFactory = config.dataSourceFactory == null ? + Components.defaultDataSource() : config.dataSourceFactory; + this.dataSource = dataSourceFactory.createDataSource(sdkKey, config, dataStore); + Future startFuture = dataSource.start(); if (config.startWaitMillis > 0L) { if (!config.offline && !config.useLdd) { logger.info("Waiting up to " + config.startWaitMillis + " milliseconds for LaunchDarkly client to start..."); @@ -118,7 +105,7 @@ public DataModel.Segment getSegment(String key) { logger.error("Exception encountered waiting for LaunchDarkly client initialization: {}", e.toString()); logger.debug(e.toString(), e); } - if (!updateProcessor.initialized()) { + if (!dataSource.initialized()) { logger.warn("LaunchDarkly client was not successfully initialized"); } } @@ -126,7 +113,7 @@ public DataModel.Segment getSegment(String key) { @Override public boolean initialized() { - return updateProcessor.initialized(); + return dataSource.initialized(); } @Override @@ -196,10 +183,10 @@ public FeatureFlagsState allFlagsState(LDUser user, FlagsStateOption... options) } if (!initialized()) { - if (featureStore.initialized()) { - logger.warn("allFlagsState() was called before client initialized; using last known values from feature store"); + if (dataStore.initialized()) { + logger.warn("allFlagsState() was called before client initialized; using last known values from data store"); } else { - logger.warn("allFlagsState() was called before client initialized; feature store unavailable, returning no data"); + logger.warn("allFlagsState() was called before client initialized; data store unavailable, returning no data"); return builder.valid(false).build(); } } @@ -210,7 +197,7 @@ public FeatureFlagsState allFlagsState(LDUser user, FlagsStateOption... options) } boolean clientSideOnly = FlagsStateOption.hasOption(options, FlagsStateOption.CLIENT_SIDE_ONLY); - Map flags = featureStore.all(FEATURES); + Map flags = dataStore.all(FEATURES); for (Map.Entry entry : flags.entrySet()) { DataModel.FeatureFlag flag = entry.getValue(); if (clientSideOnly && !flag.isClientSide()) { @@ -309,16 +296,16 @@ public EvaluationDetail jsonValueVariationDetail(String featureKey, LDU @Override public boolean isFlagKnown(String featureKey) { if (!initialized()) { - if (featureStore.initialized()) { - logger.warn("isFlagKnown called before client initialized for feature flag \"{}\"; using last known values from feature store", featureKey); + if (dataStore.initialized()) { + logger.warn("isFlagKnown called before client initialized for feature flag \"{}\"; using last known values from data store", featureKey); } else { - logger.warn("isFlagKnown called before client initialized for feature flag \"{}\"; feature store unavailable, returning false", featureKey); + logger.warn("isFlagKnown called before client initialized for feature flag \"{}\"; data store unavailable, returning false", featureKey); return false; } } try { - if (featureStore.get(FEATURES, featureKey) != null) { + if (dataStore.get(FEATURES, featureKey) != null) { return true; } } catch (Exception e) { @@ -340,10 +327,10 @@ private Evaluator.EvalResult errorResult(EvaluationReason.ErrorKind errorKind, f private Evaluator.EvalResult evaluateInternal(String featureKey, LDUser user, LDValue defaultValue, boolean checkType, 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); + if (dataStore.initialized()) { + logger.warn("Evaluation called before client initialized for feature flag \"{}\"; using last known values from data store", featureKey); } else { - logger.warn("Evaluation called before client initialized for feature flag \"{}\"; feature store unavailable, returning default value", featureKey); + logger.warn("Evaluation called before client initialized for feature flag \"{}\"; data store unavailable, returning default value", featureKey); sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue, EvaluationReason.ErrorKind.CLIENT_NOT_READY)); return errorResult(EvaluationReason.ErrorKind.CLIENT_NOT_READY, defaultValue); @@ -352,7 +339,7 @@ private Evaluator.EvalResult evaluateInternal(String featureKey, LDUser user, LD DataModel.FeatureFlag featureFlag = null; try { - featureFlag = featureStore.get(FEATURES, featureKey); + featureFlag = dataStore.get(FEATURES, featureKey); if (featureFlag == null) { logger.info("Unknown feature flag \"{}\"; returning default value", featureKey); sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue, @@ -402,11 +389,9 @@ private Evaluator.EvalResult evaluateInternal(String featureKey, LDUser user, LD @Override public void close() throws IOException { logger.info("Closing LaunchDarkly Client"); - if (shouldCloseFeatureStore) { // see comment in constructor about this variable - this.featureStore.close(); - } + this.dataStore.close(); this.eventProcessor.close(); - this.updateProcessor.close(); + this.dataSource.close(); } @Override diff --git a/src/main/java/com/launchdarkly/client/LDConfig.java b/src/main/java/com/launchdarkly/client/LDConfig.java index 202db1126..2f42b49d4 100644 --- a/src/main/java/com/launchdarkly/client/LDConfig.java +++ b/src/main/java/com/launchdarkly/client/LDConfig.java @@ -4,10 +4,10 @@ import com.google.gson.GsonBuilder; import com.launchdarkly.client.interfaces.EventProcessor; import com.launchdarkly.client.interfaces.EventProcessorFactory; -import com.launchdarkly.client.interfaces.FeatureStore; -import com.launchdarkly.client.interfaces.FeatureStoreFactory; -import com.launchdarkly.client.interfaces.UpdateProcessor; -import com.launchdarkly.client.interfaces.UpdateProcessorFactory; +import com.launchdarkly.client.interfaces.DataStore; +import com.launchdarkly.client.interfaces.DataStoreFactory; +import com.launchdarkly.client.interfaces.DataSource; +import com.launchdarkly.client.interfaces.DataSourceFactory; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -61,10 +61,9 @@ public final class LDConfig { final Proxy proxy; final Authenticator proxyAuthenticator; final boolean stream; - final FeatureStore deprecatedFeatureStore; - final FeatureStoreFactory featureStoreFactory; + final DataStoreFactory dataStoreFactory; final EventProcessorFactory eventProcessorFactory; - final UpdateProcessorFactory updateProcessorFactory; + final DataSourceFactory dataSourceFactory; final boolean useLdd; final boolean offline; final boolean allAttributesPrivate; @@ -93,10 +92,9 @@ protected LDConfig(Builder builder) { this.proxyAuthenticator = builder.proxyAuthenticator(); 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; @@ -160,10 +158,9 @@ public static class Builder { private boolean allAttributesPrivate = false; private boolean sendEvents = true; private long pollingIntervalMillis = MIN_POLLING_INTERVAL_MILLIS; - private FeatureStore featureStore = null; - private FeatureStoreFactory featureStoreFactory = Components.inMemoryFeatureStore(); + private DataStoreFactory dataStoreFactory = Components.inMemoryDataStore(); private EventProcessorFactory eventProcessorFactory = Components.defaultEventProcessor(); - private UpdateProcessorFactory updateProcessorFactory = Components.defaultUpdateProcessor(); + private DataSourceFactory dataSourceFactory = Components.defaultDataSource(); private long startWaitMillis = DEFAULT_START_WAIT_MILLIS; private int samplingInterval = DEFAULT_SAMPLING_INTERVAL; private long reconnectTimeMillis = DEFAULT_RECONNECT_TIME_MILLIS; @@ -214,29 +211,16 @@ public Builder streamURI(URI streamURI) { } /** - * 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 - * you may use {@link RedisFeatureStore} or a custom implementation. - * @param store the feature store implementation - * @return the builder - * @deprecated Please use {@link #featureStoreFactory(FeatureStoreFactory)}. - */ - public Builder featureStore(FeatureStore store) { - this.featureStore = store; - return this; - } - - /** - * Sets the implementation of {@link FeatureStore} to be used for holding feature flags and + * Sets the implementation of {@link DataStore} 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()} + * {@link Components#inMemoryDataStore()}, but you may use {@link Components#redisDataStore()} * or a custom implementation. * @param factory the factory object * @return the builder - * @since 4.0.0 + * @since 5.0.0 */ - public Builder featureStoreFactory(FeatureStoreFactory factory) { - this.featureStoreFactory = factory; + public Builder dataStore(DataStoreFactory factory) { + this.dataStoreFactory = factory; return this; } @@ -246,23 +230,23 @@ 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.0.0 + * @since 5.0.0 */ - public Builder eventProcessorFactory(EventProcessorFactory factory) { + public Builder eventProcessor(EventProcessorFactory factory) { this.eventProcessorFactory = factory; return this; } /** - * 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 {@link DataSource} to be used for receiving feature flag data, + * using a factory object. The default is {@link Components#defaultDataSource()}, but * you may choose to use a custom implementation (for instance, a test fixture). * @param factory the factory object * @return the builder - * @since 4.0.0 + * @since 5.0.0 */ - public Builder updateProcessorFactory(UpdateProcessorFactory factory) { - this.updateProcessorFactory = factory; + public Builder dataSource(DataSourceFactory factory) { + this.dataSourceFactory = factory; return this; } diff --git a/src/main/java/com/launchdarkly/client/PollingProcessor.java b/src/main/java/com/launchdarkly/client/PollingProcessor.java index cb4e664bc..3e7eb4840 100644 --- a/src/main/java/com/launchdarkly/client/PollingProcessor.java +++ b/src/main/java/com/launchdarkly/client/PollingProcessor.java @@ -2,8 +2,8 @@ import com.google.common.util.concurrent.SettableFuture; import com.google.common.util.concurrent.ThreadFactoryBuilder; -import com.launchdarkly.client.interfaces.FeatureStore; -import com.launchdarkly.client.interfaces.UpdateProcessor; +import com.launchdarkly.client.interfaces.DataStore; +import com.launchdarkly.client.interfaces.DataSource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -19,19 +19,19 @@ import static com.launchdarkly.client.Util.httpErrorMessage; import static com.launchdarkly.client.Util.isHttpErrorRecoverable; -class PollingProcessor implements UpdateProcessor { +class PollingProcessor implements DataSource { private static final Logger logger = LoggerFactory.getLogger(PollingProcessor.class); private final FeatureRequestor requestor; private final LDConfig config; - private final FeatureStore store; + private final DataStore store; private AtomicBoolean initialized = new AtomicBoolean(false); private ScheduledExecutorService scheduler = null; - PollingProcessor(LDConfig config, FeatureRequestor requestor, FeatureStore featureStore) { + PollingProcessor(LDConfig config, FeatureRequestor requestor, DataStore dataStore) { this.requestor = requestor; this.config = config; - this.store = featureStore; + this.store = dataStore; } @Override diff --git a/src/main/java/com/launchdarkly/client/RedisFeatureStore.java b/src/main/java/com/launchdarkly/client/RedisDataStore.java similarity index 86% rename from src/main/java/com/launchdarkly/client/RedisFeatureStore.java rename to src/main/java/com/launchdarkly/client/RedisDataStore.java index 07df92641..a2497b379 100644 --- a/src/main/java/com/launchdarkly/client/RedisFeatureStore.java +++ b/src/main/java/com/launchdarkly/client/RedisDataStore.java @@ -2,11 +2,11 @@ import com.google.common.annotations.VisibleForTesting; import com.google.common.cache.CacheStats; -import com.launchdarkly.client.interfaces.FeatureStore; +import com.launchdarkly.client.interfaces.DataStore; +import com.launchdarkly.client.interfaces.DataStoreCore; import com.launchdarkly.client.interfaces.VersionedData; import com.launchdarkly.client.interfaces.VersionedDataKind; import com.launchdarkly.client.utils.CachingStoreWrapper; -import com.launchdarkly.client.utils.FeatureStoreCore; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -16,8 +16,8 @@ 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 static com.launchdarkly.client.utils.DataStoreHelpers.marshalJson; +import static com.launchdarkly.client.utils.DataStoreHelpers.unmarshalJson; import redis.clients.jedis.Jedis; import redis.clients.jedis.JedisPool; @@ -26,15 +26,15 @@ import redis.clients.util.JedisURIHelper; /** - * An implementation of {@link FeatureStore} backed by Redis. Also + * An implementation of {@link DataStore} backed by Redis. Also * supports an optional in-memory cache configuration that can be used to improve performance. */ -public class RedisFeatureStore implements FeatureStore { - private static final Logger logger = LoggerFactory.getLogger(RedisFeatureStore.class); +public class RedisDataStore implements DataStore { + private static final Logger logger = LoggerFactory.getLogger(RedisDataStore.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. + // simply returned the wrapper itself as the DataStore; however, for historical reasons we can't, + // because we have already exposed the RedisDataStore type. private final CachingStoreWrapper wrapper; private final Core core; @@ -83,13 +83,13 @@ public CacheStats getCacheStats() { } /** - * Creates a new store instance that connects to Redis based on the provided {@link RedisFeatureStoreBuilder}. + * Creates a new store instance that connects to Redis based on the provided {@link RedisDataStoreBuilder}. *

    - * See the {@link RedisFeatureStoreBuilder} for information on available configuration options and what they do. + * See the {@link RedisDataStoreBuilder} for information on available configuration options and what they do. * * @param builder the configured builder to construct the store with. */ - protected RedisFeatureStore(RedisFeatureStoreBuilder builder) { + protected RedisDataStore(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. @@ -103,7 +103,7 @@ protected RedisFeatureStore(RedisFeatureStoreBuilder builder) { 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)); + logger.info(String.format("Connecting to Redis data store at %s:%d/%d%s", host, port, database, extra)); JedisPoolConfig poolConfig = (builder.poolConfig != null) ? builder.poolConfig : new JedisPoolConfig(); JedisPool pool = new JedisPool(poolConfig, @@ -121,7 +121,7 @@ protected RedisFeatureStore(RedisFeatureStoreBuilder builder) { ); String prefix = (builder.prefix == null || builder.prefix.isEmpty()) ? - RedisFeatureStoreBuilder.DEFAULT_PREFIX : + RedisDataStoreBuilder.DEFAULT_PREFIX : builder.prefix; this.core = new Core(pool, prefix); @@ -131,13 +131,13 @@ protected RedisFeatureStore(RedisFeatureStoreBuilder builder) { /** * 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 Please use {@link Components#redisDataStore()} instead. */ - public RedisFeatureStore() { - this(new RedisFeatureStoreBuilder().caching(FeatureStoreCacheConfig.disabled())); + public RedisDataStore() { + this(new RedisDataStoreBuilder().caching(DataStoreCacheConfig.disabled())); } - static class Core implements FeatureStoreCore { + static class Core implements DataStoreCore { private final JedisPool pool; private final String prefix; private UpdateListener updateListener; @@ -241,7 +241,7 @@ public boolean initializedInternal() { @Override public void close() throws IOException { - logger.info("Closing LaunchDarkly RedisFeatureStore"); + logger.info("Closing LaunchDarkly RedisDataStore"); pool.destroy(); } diff --git a/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java b/src/main/java/com/launchdarkly/client/RedisDataStoreBuilder.java similarity index 63% rename from src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java rename to src/main/java/com/launchdarkly/client/RedisDataStoreBuilder.java index b5f5900e2..e09bdae70 100644 --- a/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java +++ b/src/main/java/com/launchdarkly/client/RedisDataStoreBuilder.java @@ -3,26 +3,26 @@ import redis.clients.jedis.JedisPoolConfig; import redis.clients.jedis.Protocol; -import com.launchdarkly.client.interfaces.FeatureStoreFactory; +import com.launchdarkly.client.interfaces.DataStoreFactory; import java.net.URI; import java.net.URISyntaxException; import java.util.concurrent.TimeUnit; /** - * A builder for configuring the Redis-based persistent feature store. + * A builder for configuring the Redis-based persistent data store. * - * Obtain an instance of this class by calling {@link Components#redisFeatureStore()} or {@link Components#redisFeatureStore(URI)}. + * Obtain an instance of this class by calling {@link Components#redisDataStore()} or {@link Components#redisDataStore(URI)}. * Builder calls can be chained, for example: * *

    
    - * FeatureStore store = Components.redisFeatureStore()
    + * DataeStore store = Components.redisDataStore()
      *      .database(1)
    - *      .caching(FeatureStoreCacheConfig.enabled().ttlSeconds(60))
    + *      .caching(DataStoreCacheConfig.enabled().ttlSeconds(60))
      *      .build();
      * 
    */ -public final class RedisFeatureStoreBuilder implements FeatureStoreFactory { +public final class RedisDataStoreBuilder implements DataStoreFactory { /** * The default value for the Redis URI: {@code redis://localhost:6379} * @since 4.0.0 @@ -37,10 +37,10 @@ public final class RedisFeatureStoreBuilder implements FeatureStoreFactory { /** * The default value for {@link #cacheTime(long, TimeUnit)} (in seconds). - * @deprecated Use {@link FeatureStoreCacheConfig#DEFAULT}. + * @deprecated Use {@link DataStoreCacheConfig#DEFAULT}. * @since 4.0.0 */ - public static final long DEFAULT_CACHE_TIME_SECONDS = FeatureStoreCacheConfig.DEFAULT_TIME_SECONDS; + public static final long DEFAULT_CACHE_TIME_SECONDS = DataStoreCacheConfig.DEFAULT_TIME_SECONDS; final URI uri; String prefix = DEFAULT_PREFIX; @@ -49,43 +49,43 @@ public final class RedisFeatureStoreBuilder implements FeatureStoreFactory { 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 + DataStoreCacheConfig caching = DataStoreCacheConfig.DEFAULT; + boolean refreshStaleValues = false; // this and asyncRefresh are redundant with DataStoreCacheConfig, but are used by deprecated setters boolean asyncRefresh = false; JedisPoolConfig poolConfig = null; // These constructors are called only from Implementations - RedisFeatureStoreBuilder() { + RedisDataStoreBuilder() { this.uri = DEFAULT_URI; } - RedisFeatureStoreBuilder(URI uri) { + RedisDataStoreBuilder(URI uri) { this.uri = uri; } /** - * The constructor accepts the mandatory fields that must be specified at a minimum to construct a {@link com.launchdarkly.client.RedisFeatureStore}. + * The constructor accepts the mandatory fields that must be specified at a minimum to construct a {@link com.launchdarkly.client.RedisDataStore}. * * @param uri the uri of the Redis resource to connect to. - * @param cacheTimeSecs the cache time in seconds. See {@link RedisFeatureStoreBuilder#cacheTime(long, TimeUnit)} for more information. - * @deprecated Please use {@link Components#redisFeatureStore(java.net.URI)}. + * @param cacheTimeSecs the cache time in seconds. See {@link RedisDataStoreBuilder#cacheTime(long, TimeUnit)} for more information. + * @deprecated Please use {@link Components#redisDataStore(java.net.URI)}. */ - public RedisFeatureStoreBuilder(URI uri, long cacheTimeSecs) { + public RedisDataStoreBuilder(URI uri, long cacheTimeSecs) { this.uri = uri; this.cacheTime(cacheTimeSecs, TimeUnit.SECONDS); } /** - * The constructor accepts the mandatory fields that must be specified at a minimum to construct a {@link com.launchdarkly.client.RedisFeatureStore}. + * The constructor accepts the mandatory fields that must be specified at a minimum to construct a {@link com.launchdarkly.client.RedisDataStore}. * * @param scheme the URI scheme to use * @param host the hostname to connect to * @param port the port to connect to - * @param cacheTimeSecs the cache time in seconds. See {@link RedisFeatureStoreBuilder#cacheTime(long, TimeUnit)} for more information. + * @param cacheTimeSecs the cache time in seconds. See {@link RedisDataStoreBuilder#cacheTime(long, TimeUnit)} for more information. * @throws URISyntaxException if the URI is not valid - * @deprecated Please use {@link Components#redisFeatureStore(java.net.URI)}. + * @deprecated Please use {@link Components#redisDataStore(java.net.URI)}. */ - public RedisFeatureStoreBuilder(String scheme, String host, int port, long cacheTimeSecs) throws URISyntaxException { + public RedisDataStoreBuilder(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); } @@ -101,7 +101,7 @@ public RedisFeatureStoreBuilder(String scheme, String host, int port, long cache * * @since 4.7.0 */ - public RedisFeatureStoreBuilder database(Integer database) { + public RedisDataStoreBuilder database(Integer database) { this.database = database; return this; } @@ -117,7 +117,7 @@ public RedisFeatureStoreBuilder database(Integer database) { * * @since 4.7.0 */ - public RedisFeatureStoreBuilder password(String password) { + public RedisDataStoreBuilder password(String password) { this.password = password; return this; } @@ -134,53 +134,53 @@ public RedisFeatureStoreBuilder password(String password) { * * @since 4.7.0 */ - public RedisFeatureStoreBuilder tls(boolean tls) { + public RedisDataStoreBuilder tls(boolean tls) { this.tls = tls; 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. + * caching is enabled by default; see {@link DataStoreCacheConfig#DEFAULT}. To disable it, pass + * {@link DataStoreCacheConfig#disabled()} to this method. * - * @param caching a {@link FeatureStoreCacheConfig} object specifying caching parameters + * @param caching a {@link DataStoreCacheConfig} object specifying caching parameters * @return the builder * * @since 4.6.0 */ - public RedisFeatureStoreBuilder caching(FeatureStoreCacheConfig caching) { + public RedisDataStoreBuilder caching(DataStoreCacheConfig caching) { this.caching = caching; return this; } /** - * Deprecated method for setting the cache expiration policy to {@link FeatureStoreCacheConfig.StaleValuesPolicy#REFRESH} - * or {@link FeatureStoreCacheConfig.StaleValuesPolicy#REFRESH_ASYNC}. + * Deprecated method for setting the cache expiration policy to {@link DataStoreCacheConfig.StaleValuesPolicy#REFRESH} + * or {@link DataStoreCacheConfig.StaleValuesPolicy#REFRESH_ASYNC}. * * @param enabled turns on lazy refresh of cached values * @return the builder * - * @deprecated Use {@link #caching(FeatureStoreCacheConfig)} and - * {@link FeatureStoreCacheConfig#staleValuesPolicy(com.launchdarkly.client.FeatureStoreCacheConfig.StaleValuesPolicy)}. + * @deprecated Use {@link #caching(DataStoreCacheConfig)} and + * {@link DataStoreCacheConfig#staleValuesPolicy(com.launchdarkly.client.DataStoreCacheConfig.StaleValuesPolicy)}. */ - public RedisFeatureStoreBuilder refreshStaleValues(boolean enabled) { + public RedisDataStoreBuilder refreshStaleValues(boolean enabled) { this.refreshStaleValues = enabled; updateCachingStaleValuesPolicy(); return this; } /** - * Deprecated method for setting the cache expiration policy to {@link FeatureStoreCacheConfig.StaleValuesPolicy#REFRESH_ASYNC}. + * Deprecated method for setting the cache expiration policy to {@link DataStoreCacheConfig.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(FeatureStoreCacheConfig)} and - * {@link FeatureStoreCacheConfig#staleValuesPolicy(com.launchdarkly.client.FeatureStoreCacheConfig.StaleValuesPolicy)}. + * @deprecated Use {@link #caching(DataStoreCacheConfig)} and + * {@link DataStoreCacheConfig#staleValuesPolicy(com.launchdarkly.client.DataStoreCacheConfig.StaleValuesPolicy)}. */ - public RedisFeatureStoreBuilder asyncRefresh(boolean enabled) { + public RedisDataStoreBuilder asyncRefresh(boolean enabled) { this.asyncRefresh = enabled; updateCachingStaleValuesPolicy(); return this; @@ -191,10 +191,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 ? - FeatureStoreCacheConfig.StaleValuesPolicy.REFRESH_ASYNC : - FeatureStoreCacheConfig.StaleValuesPolicy.REFRESH); + DataStoreCacheConfig.StaleValuesPolicy.REFRESH_ASYNC : + DataStoreCacheConfig.StaleValuesPolicy.REFRESH); } else { - this.caching = this.caching.staleValuesPolicy(FeatureStoreCacheConfig.StaleValuesPolicy.EVICT); + this.caching = this.caching.staleValuesPolicy(DataStoreCacheConfig.StaleValuesPolicy.EVICT); } } @@ -204,22 +204,22 @@ private void updateCachingStaleValuesPolicy() { * @param prefix the namespace prefix * @return the builder */ - public RedisFeatureStoreBuilder prefix(String prefix) { + public RedisDataStoreBuilder prefix(String prefix) { this.prefix = prefix; return this; } /** * Deprecated method for enabling local caching and setting the cache TTL. Local caching is enabled - * by default; see {@link FeatureStoreCacheConfig#DEFAULT}. + * by default; see {@link DataStoreCacheConfig#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(FeatureStoreCacheConfig)} and {@link FeatureStoreCacheConfig#ttl(long, TimeUnit)}. + * @deprecated use {@link #caching(DataStoreCacheConfig)} and {@link DataStoreCacheConfig#ttl(long, TimeUnit)}. */ - public RedisFeatureStoreBuilder cacheTime(long cacheTime, TimeUnit timeUnit) { + public RedisDataStoreBuilder cacheTime(long cacheTime, TimeUnit timeUnit) { this.caching = this.caching.ttl(cacheTime, timeUnit) .staleValuesPolicy(this.caching.getStaleValuesPolicy()); return this; @@ -231,7 +231,7 @@ public RedisFeatureStoreBuilder cacheTime(long cacheTime, TimeUnit timeUnit) { * @param poolConfig the Jedis pool configuration. * @return the builder */ - public RedisFeatureStoreBuilder poolConfig(JedisPoolConfig poolConfig) { + public RedisDataStoreBuilder poolConfig(JedisPoolConfig poolConfig) { this.poolConfig = poolConfig; return this; } @@ -244,7 +244,7 @@ public RedisFeatureStoreBuilder poolConfig(JedisPoolConfig poolConfig) { * @param timeUnit the time unit for the timeout * @return the builder */ - public RedisFeatureStoreBuilder connectTimeout(int connectTimeout, TimeUnit timeUnit) { + public RedisDataStoreBuilder connectTimeout(int connectTimeout, TimeUnit timeUnit) { this.connectTimeout = (int) timeUnit.toMillis(connectTimeout); return this; } @@ -257,25 +257,25 @@ public RedisFeatureStoreBuilder connectTimeout(int connectTimeout, TimeUnit time * @param timeUnit the time unit for the timeout * @return the builder */ - public RedisFeatureStoreBuilder socketTimeout(int socketTimeout, TimeUnit timeUnit) { + public RedisDataStoreBuilder socketTimeout(int socketTimeout, TimeUnit timeUnit) { this.socketTimeout = (int) timeUnit.toMillis(socketTimeout); return this; } /** - * Build a {@link RedisFeatureStore} based on the currently configured builder object. - * @return the {@link RedisFeatureStore} configured by this builder. + * Build a {@link RedisDataStore} based on the currently configured builder object. + * @return the {@link RedisDataStore} configured by this builder. */ - public RedisFeatureStore build() { - return new RedisFeatureStore(this); + public RedisDataStore build() { + return new RedisDataStore(this); } /** * Synonym for {@link #build()}. - * @return the {@link RedisFeatureStore} configured by this builder. + * @return the {@link RedisDataStore} configured by this builder. * @since 4.0.0 */ - public RedisFeatureStore createFeatureStore() { + public RedisDataStore createDataStore() { return build(); } } diff --git a/src/main/java/com/launchdarkly/client/StreamProcessor.java b/src/main/java/com/launchdarkly/client/StreamProcessor.java index 990e0d39d..95f35a7ab 100644 --- a/src/main/java/com/launchdarkly/client/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/client/StreamProcessor.java @@ -3,8 +3,8 @@ import com.google.common.util.concurrent.SettableFuture; import com.google.gson.Gson; import com.google.gson.JsonElement; -import com.launchdarkly.client.interfaces.FeatureStore; -import com.launchdarkly.client.interfaces.UpdateProcessor; +import com.launchdarkly.client.interfaces.DataStore; +import com.launchdarkly.client.interfaces.DataSource; import com.launchdarkly.client.interfaces.VersionedDataKind; import com.launchdarkly.eventsource.ConnectionErrorHandler; import com.launchdarkly.eventsource.EventHandler; @@ -29,7 +29,7 @@ import okhttp3.Headers; import okhttp3.OkHttpClient; -final class StreamProcessor implements UpdateProcessor { +final class StreamProcessor implements DataSource { private static final String PUT = "put"; private static final String PATCH = "patch"; private static final String DELETE = "delete"; @@ -38,7 +38,7 @@ final class StreamProcessor implements UpdateProcessor { private static final Logger logger = LoggerFactory.getLogger(StreamProcessor.class); private static final int DEAD_CONNECTION_INTERVAL_MS = 300 * 1000; - private final FeatureStore store; + private final DataStore store; private final LDConfig config; private final String sdkKey; private final FeatureRequestor requestor; @@ -52,9 +52,9 @@ public static interface EventSourceCreator { EventSource createEventSource(LDConfig config, EventHandler handler, URI streamUri, ConnectionErrorHandler errorHandler, Headers headers); } - StreamProcessor(String sdkKey, LDConfig config, FeatureRequestor requestor, FeatureStore featureStore, + StreamProcessor(String sdkKey, LDConfig config, FeatureRequestor requestor, DataStore dataStore, EventSourceCreator eventSourceCreator) { - this.store = featureStore; + this.store = dataStore; this.config = config; this.sdkKey = sdkKey; this.requestor = requestor; diff --git a/src/main/java/com/launchdarkly/client/TestFeatureStore.java b/src/main/java/com/launchdarkly/client/TestDataStore.java similarity index 93% rename from src/main/java/com/launchdarkly/client/TestFeatureStore.java rename to src/main/java/com/launchdarkly/client/TestDataStore.java index 5fd0d55cd..09e613ebf 100644 --- a/src/main/java/com/launchdarkly/client/TestFeatureStore.java +++ b/src/main/java/com/launchdarkly/client/TestDataStore.java @@ -14,14 +14,14 @@ import static com.launchdarkly.client.DataModel.DataKinds.FEATURES; /** - * A decorated {@link InMemoryFeatureStore} which provides functionality to create (or override) true or false feature flags for all users. + * A decorated {@link InMemoryDataStore} which provides functionality to create (or override) true or false feature flags for all users. *

    * Using this store is useful for testing purposes when you want to have runtime support for turning specific features on or off. * * @deprecated Will be replaced by a file-based test fixture. */ @Deprecated -public class TestFeatureStore extends InMemoryFeatureStore { +public class TestDataStore extends InMemoryDataStore { static List TRUE_FALSE_VARIATIONS = Arrays.asList(LDValue.of(true), LDValue.of(false)); private AtomicInteger version = new AtomicInteger(0); @@ -128,7 +128,7 @@ public boolean initialized() { } /** - * Sets the initialization status that the feature store will report to the SDK + * Sets the initialization status that the data store will report to the SDK * @param value true if the store should show as initialized */ public void setInitialized(boolean value) { diff --git a/src/main/java/com/launchdarkly/client/files/DataBuilder.java b/src/main/java/com/launchdarkly/client/files/DataBuilder.java index 9f483ed95..5db94b4bf 100644 --- a/src/main/java/com/launchdarkly/client/files/DataBuilder.java +++ b/src/main/java/com/launchdarkly/client/files/DataBuilder.java @@ -7,7 +7,7 @@ import java.util.Map; /** - * Internal data structure that organizes flag/segment data into the format that the feature store + * Internal data structure that organizes flag/segment data into the format that the data store * expects. Will throw an exception if we try to add the same flag or segment key more than once. */ class DataBuilder { 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/FileDataSource.java b/src/main/java/com/launchdarkly/client/files/FileDataSource.java index 280257527..d589dba60 100644 --- a/src/main/java/com/launchdarkly/client/files/FileDataSource.java +++ b/src/main/java/com/launchdarkly/client/files/FileDataSource.java @@ -1,8 +1,8 @@ package com.launchdarkly.client.files; import com.google.common.util.concurrent.Futures; -import com.launchdarkly.client.interfaces.FeatureStore; -import com.launchdarkly.client.interfaces.UpdateProcessor; +import com.launchdarkly.client.interfaces.DataStore; +import com.launchdarkly.client.interfaces.DataSource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -25,18 +25,18 @@ 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 { +class FileDataSource implements DataSource { private static final Logger logger = LoggerFactory.getLogger(FileDataSource.class); - private final FeatureStore store; + private final DataStore store; private final DataLoader dataLoader; private final AtomicBoolean inited = new AtomicBoolean(false); private final FileWatcher fileWatcher; - FileDataSource(FeatureStore store, DataLoader dataLoader, boolean autoUpdate) { + FileDataSource(DataStore store, DataLoader dataLoader, boolean autoUpdate) { this.store = store; this.dataLoader = dataLoader; diff --git a/src/main/java/com/launchdarkly/client/files/FileDataSourceFactory.java b/src/main/java/com/launchdarkly/client/files/FileDataSourceFactory.java index a8a2a55e7..21f177540 100644 --- a/src/main/java/com/launchdarkly/client/files/FileDataSourceFactory.java +++ b/src/main/java/com/launchdarkly/client/files/FileDataSourceFactory.java @@ -1,9 +1,9 @@ package com.launchdarkly.client.files; import com.launchdarkly.client.LDConfig; -import com.launchdarkly.client.interfaces.FeatureStore; -import com.launchdarkly.client.interfaces.UpdateProcessor; -import com.launchdarkly.client.interfaces.UpdateProcessorFactory; +import com.launchdarkly.client.interfaces.DataStore; +import com.launchdarkly.client.interfaces.DataSource; +import com.launchdarkly.client.interfaces.DataSourceFactory; import java.nio.file.InvalidPathException; import java.nio.file.Path; @@ -14,13 +14,13 @@ /** * 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(DataSourceFactory)}. *

    * For more details, see {@link FileComponents}. * * @since 4.5.0 */ -public class FileDataSourceFactory implements UpdateProcessorFactory { +public class FileDataSourceFactory implements DataSourceFactory { private final List sources = new ArrayList<>(); private boolean autoUpdate = false; @@ -77,7 +77,7 @@ public FileDataSourceFactory autoUpdate(boolean autoUpdate) { * Used internally by the LaunchDarkly client. */ @Override - public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, FeatureStore featureStore) { - return new FileDataSource(featureStore, new DataLoader(sources), autoUpdate); + public DataSource createDataSource(String sdkKey, LDConfig config, DataStore dataStore) { + return new FileDataSource(dataStore, new DataLoader(sources), autoUpdate); } } \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/interfaces/UpdateProcessor.java b/src/main/java/com/launchdarkly/client/interfaces/DataSource.java similarity index 52% rename from src/main/java/com/launchdarkly/client/interfaces/UpdateProcessor.java rename to src/main/java/com/launchdarkly/client/interfaces/DataSource.java index 94cc15576..1d4898464 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/UpdateProcessor.java +++ b/src/main/java/com/launchdarkly/client/interfaces/DataSource.java @@ -1,19 +1,15 @@ package com.launchdarkly.client.interfaces; -import com.launchdarkly.client.Components; - import java.io.Closeable; import java.io.IOException; import java.util.concurrent.Future; -import static com.google.common.util.concurrent.Futures.immediateFuture; - /** * Interface for an object that receives updates to feature flags, user segments, and anything - * else that might come from LaunchDarkly, and passes them to a {@link FeatureStore}. - * @since 4.0.0 + * else that might come from LaunchDarkly, and passes them to a {@link DataStore}. + * @since 5.0.0 */ -public interface UpdateProcessor extends Closeable { +public interface DataSource extends Closeable { /** * Starts the client. * @return {@link Future}'s completion status indicates the client has been initialized. @@ -31,25 +27,4 @@ public interface UpdateProcessor extends Closeable { * @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() { - return immediateFuture(null); - } - - @Override - public boolean initialized() { - return true; - } - - @Override - public void close() throws IOException {} - } } diff --git a/src/main/java/com/launchdarkly/client/interfaces/UpdateProcessorFactory.java b/src/main/java/com/launchdarkly/client/interfaces/DataSourceFactory.java similarity index 54% rename from src/main/java/com/launchdarkly/client/interfaces/UpdateProcessorFactory.java rename to src/main/java/com/launchdarkly/client/interfaces/DataSourceFactory.java index 1647285af..77569a540 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/UpdateProcessorFactory.java +++ b/src/main/java/com/launchdarkly/client/interfaces/DataSourceFactory.java @@ -4,17 +4,17 @@ import com.launchdarkly.client.LDConfig; /** - * Interface for a factory that creates some implementation of {@link UpdateProcessor}. + * Interface for a factory that creates some implementation of {@link DataSource}. * @see Components - * @since 4.0.0 + * @since 5.0.0 */ -public interface UpdateProcessorFactory { +public interface DataSourceFactory { /** * Creates an implementation instance. * @param sdkKey the SDK key for your LaunchDarkly environment * @param config the LaunchDarkly configuration - * @param featureStore the {@link FeatureStore} to use for storing the latest flag state - * @return an {@link UpdateProcessor} + * @param dataStore the {@link DataStore} to use for storing the latest flag state + * @return an {@link DataSource} */ - public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, FeatureStore featureStore); + public DataSource createDataSource(String sdkKey, LDConfig config, DataStore dataStore); } diff --git a/src/main/java/com/launchdarkly/client/interfaces/FeatureStore.java b/src/main/java/com/launchdarkly/client/interfaces/DataStore.java similarity index 98% rename from src/main/java/com/launchdarkly/client/interfaces/FeatureStore.java rename to src/main/java/com/launchdarkly/client/interfaces/DataStore.java index 59b776245..3738d93e4 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/FeatureStore.java +++ b/src/main/java/com/launchdarkly/client/interfaces/DataStore.java @@ -12,9 +12,9 @@ *

    * These semantics support the primary use case for the store, which synchronizes a collection * of objects based on update messages that may be received out-of-order. - * @since 3.0.0 + * @since 5.0.0 */ -public interface FeatureStore extends Closeable { +public interface DataStore extends Closeable { /** * Returns the object to which the specified key is mapped, or * null if the key is not associated or the associated object has diff --git a/src/main/java/com/launchdarkly/client/utils/FeatureStoreCore.java b/src/main/java/com/launchdarkly/client/interfaces/DataStoreCore.java similarity index 84% rename from src/main/java/com/launchdarkly/client/utils/FeatureStoreCore.java rename to src/main/java/com/launchdarkly/client/interfaces/DataStoreCore.java index 76799790c..bc30fd7a5 100644 --- a/src/main/java/com/launchdarkly/client/utils/FeatureStoreCore.java +++ b/src/main/java/com/launchdarkly/client/interfaces/DataStoreCore.java @@ -1,29 +1,28 @@ -package com.launchdarkly.client.utils; +package com.launchdarkly.client.interfaces; -import com.launchdarkly.client.interfaces.FeatureStore; -import com.launchdarkly.client.interfaces.VersionedData; -import com.launchdarkly.client.interfaces.VersionedDataKind; +import com.launchdarkly.client.utils.CachingStoreWrapper; +import com.launchdarkly.client.utils.DataStoreHelpers; 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 + * DataStoreCore is an interface for a simplified subset of the functionality of + * {@link DataStore}, to be used in conjunction with {@link CachingStoreWrapper}. This allows + * developers of custom DataStore 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. + * only DataStoreCore 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 + * {@link VersionedDataKind#getItemClass()}; the methods in {@link DataStoreHelpers} may be * useful for this. * - * @since 4.6.0 + * @since 5.0.0 */ -public interface FeatureStoreCore extends Closeable { +public interface DataStoreCore 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, diff --git a/src/main/java/com/launchdarkly/client/interfaces/FeatureStoreFactory.java b/src/main/java/com/launchdarkly/client/interfaces/DataStoreFactory.java similarity index 61% rename from src/main/java/com/launchdarkly/client/interfaces/FeatureStoreFactory.java rename to src/main/java/com/launchdarkly/client/interfaces/DataStoreFactory.java index 4e7371fc8..5159f43e3 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/FeatureStoreFactory.java +++ b/src/main/java/com/launchdarkly/client/interfaces/DataStoreFactory.java @@ -3,14 +3,14 @@ import com.launchdarkly.client.Components; /** - * Interface for a factory that creates some implementation of {@link FeatureStore}. + * Interface for a factory that creates some implementation of {@link DataStore}. * @see Components - * @since 4.0.0 + * @since 5.0.0 */ -public interface FeatureStoreFactory { +public interface DataStoreFactory { /** * Creates an implementation instance. - * @return a {@link FeatureStore} + * @return a {@link DataStore} */ - FeatureStore createFeatureStore(); + DataStore createDataStore(); } diff --git a/src/main/java/com/launchdarkly/client/interfaces/EventProcessor.java b/src/main/java/com/launchdarkly/client/interfaces/EventProcessor.java index 429a88aca..d16a579a5 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/EventProcessor.java +++ b/src/main/java/com/launchdarkly/client/interfaces/EventProcessor.java @@ -22,21 +22,4 @@ public interface EventProcessor extends Closeable { * any events that were not yet delivered prior to shutting down. */ void flush(); - - /** - * Stub implementation of {@link EventProcessor} for when we don't want to send any events. - */ - static final class NullEventProcessor implements EventProcessor { - @Override - public void sendEvent(Event e) { - } - - @Override - public void flush() { - } - - @Override - public void close() { - } - } } diff --git a/src/main/java/com/launchdarkly/client/interfaces/VersionedData.java b/src/main/java/com/launchdarkly/client/interfaces/VersionedData.java index a27a96429..971c9ea7f 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/VersionedData.java +++ b/src/main/java/com/launchdarkly/client/interfaces/VersionedData.java @@ -1,7 +1,7 @@ package com.launchdarkly.client.interfaces; /** - * Common interface for string-keyed, versioned objects that can be kept in a {@link FeatureStore}. + * Common interface for string-keyed, versioned objects that can be kept in a {@link DataStore}. * @since 3.0.0 */ public interface VersionedData { diff --git a/src/main/java/com/launchdarkly/client/interfaces/VersionedDataKind.java b/src/main/java/com/launchdarkly/client/interfaces/VersionedDataKind.java index 19b8220b9..11df73446 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/VersionedDataKind.java +++ b/src/main/java/com/launchdarkly/client/interfaces/VersionedDataKind.java @@ -3,9 +3,9 @@ import com.google.common.collect.ImmutableList; /** - * 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} - * or writing your own {@code FeatureStore} implementation. If you are implementing a custom store, for + * The descriptor for a specific kind of {@link VersionedData} objects that may exist in a {@link DataStore}. + * You will not need to refer to this type unless you are directly manipulating a {@link DataStore} + * or writing your own {@link DataStore} implementation. If you are implementing a custom store, for * 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. diff --git a/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java b/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java index 3f4ccbdea..9149ea395 100644 --- a/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java +++ b/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java @@ -8,8 +8,9 @@ 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.FeatureStoreCacheConfig; -import com.launchdarkly.client.interfaces.FeatureStore; +import com.launchdarkly.client.DataStoreCacheConfig; +import com.launchdarkly.client.interfaces.DataStore; +import com.launchdarkly.client.interfaces.DataStoreCore; import com.launchdarkly.client.interfaces.VersionedData; import com.launchdarkly.client.interfaces.VersionedDataKind; @@ -22,19 +23,19 @@ 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. This makes it + * CachingStoreWrapper is a partial implementation of {@link DataStore} that delegates the basic + * functionality to an instance of {@link DataStoreCore}. It provides optional caching behavior and + * other logic that would otherwise be repeated in every data 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(FeatureStoreCore)}. + * Construct instances of this class with {@link CachingStoreWrapper#builder(DataStoreCore)}. * * @since 4.6.0 */ -public class CachingStoreWrapper implements FeatureStore { +public class CachingStoreWrapper implements DataStore { private static final String CACHE_REFRESH_THREAD_POOL_NAME_FORMAT = "CachingStoreWrapper-refresher-pool-%d"; - private final FeatureStoreCore core; + private final DataStoreCore core; private final LoadingCache> itemCache; private final LoadingCache, Map> allCache; private final LoadingCache initCache; @@ -43,14 +44,14 @@ public class CachingStoreWrapper implements FeatureStore { /** * Creates a new builder. - * @param core the {@link FeatureStoreCore} instance + * @param core the {@link DataStoreCore} instance * @return the builder */ - public static CachingStoreWrapper.Builder builder(FeatureStoreCore core) { + public static CachingStoreWrapper.Builder builder(DataStoreCore core) { return new Builder(core); } - protected CachingStoreWrapper(final FeatureStoreCore core, FeatureStoreCacheConfig caching) { + protected CachingStoreWrapper(final DataStoreCore core, DataStoreCacheConfig caching) { this.core = core; if (!caching.isEnabled()) { @@ -98,7 +99,7 @@ public Boolean load(String key) throws Exception { ExecutorService parentExecutor = Executors.newSingleThreadExecutor(threadFactory); executorService = MoreExecutors.listeningDecorator(parentExecutor); - if (caching.getStaleValuesPolicy() == FeatureStoreCacheConfig.StaleValuesPolicy.REFRESH_ASYNC) { + if (caching.getStaleValuesPolicy() == DataStoreCacheConfig.StaleValuesPolicy.REFRESH_ASYNC) { itemLoader = CacheLoader.asyncReloading(itemLoader, executorService); } itemCache = CacheBuilder.newBuilder().refreshAfterWrite(caching.getCacheTime(), caching.getCacheTimeUnit()).build(itemLoader); @@ -213,7 +214,7 @@ public CacheStats getCacheStats() { * * @return the underlying implementation object */ - public FeatureStoreCore getCore() { + public DataStoreCore getCore() { return core; } @@ -267,19 +268,19 @@ public int hashCode() { * Builder for instances of {@link CachingStoreWrapper}. */ public static class Builder { - private final FeatureStoreCore core; - private FeatureStoreCacheConfig caching = FeatureStoreCacheConfig.DEFAULT; + private final DataStoreCore core; + private DataStoreCacheConfig caching = DataStoreCacheConfig.DEFAULT; - Builder(FeatureStoreCore core) { + Builder(DataStoreCore core) { this.core = core; } /** * Sets the local caching properties. - * @param caching a {@link FeatureStoreCacheConfig} object specifying cache parameters + * @param caching a {@link DataStoreCacheConfig} object specifying cache parameters * @return the builder */ - public Builder caching(FeatureStoreCacheConfig caching) { + public Builder caching(DataStoreCacheConfig caching) { this.caching = caching; return this; } diff --git a/src/main/java/com/launchdarkly/client/utils/FeatureStoreHelpers.java b/src/main/java/com/launchdarkly/client/utils/DataStoreHelpers.java similarity index 65% rename from src/main/java/com/launchdarkly/client/utils/FeatureStoreHelpers.java rename to src/main/java/com/launchdarkly/client/utils/DataStoreHelpers.java index 3a5247254..45b040010 100644 --- a/src/main/java/com/launchdarkly/client/utils/FeatureStoreHelpers.java +++ b/src/main/java/com/launchdarkly/client/utils/DataStoreHelpers.java @@ -2,21 +2,22 @@ import com.google.gson.Gson; import com.google.gson.JsonParseException; -import com.launchdarkly.client.interfaces.FeatureStore; +import com.launchdarkly.client.interfaces.DataStore; +import com.launchdarkly.client.interfaces.DataStoreCore; import com.launchdarkly.client.interfaces.VersionedData; import com.launchdarkly.client.interfaces.VersionedDataKind; /** - * Helper methods that may be useful for implementing a {@link FeatureStore} or {@link FeatureStoreCore}. + * Helper methods that may be useful for implementing a {@link DataStore} or {@link DataStoreCore}. * * @since 4.6.0 */ -public abstract class FeatureStoreHelpers { +public abstract class DataStoreHelpers { 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 + * Unmarshals a data store item from a JSON string. This is a very simple wrapper around a Gson + * method, just to allow external data 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 @@ -34,8 +35,8 @@ 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 + * Marshals a data store item into a JSON string. This is a very simple wrapper around a Gson + * method, just to allow external data 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 @@ -45,7 +46,7 @@ public static String marshalJson(VersionedData item) { } /** - * Thrown by {@link FeatureStoreHelpers#unmarshalJson(VersionedDataKind, String)} for a deserialization error. + * Thrown by {@link DataStoreHelpers#unmarshalJson(VersionedDataKind, String)} for a deserialization error. */ @SuppressWarnings("serial") public static class UnmarshalException extends RuntimeException { diff --git a/src/test/java/com/launchdarkly/client/FeatureStoreCachingTest.java b/src/test/java/com/launchdarkly/client/DataStoreCachingTest.java similarity index 59% rename from src/test/java/com/launchdarkly/client/FeatureStoreCachingTest.java rename to src/test/java/com/launchdarkly/client/DataStoreCachingTest.java index f8d15f517..a5910d2a2 100644 --- a/src/test/java/com/launchdarkly/client/FeatureStoreCachingTest.java +++ b/src/test/java/com/launchdarkly/client/DataStoreCachingTest.java @@ -4,16 +4,17 @@ import java.util.concurrent.TimeUnit; -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 com.launchdarkly.client.DataStoreCacheConfig.StaleValuesPolicy.EVICT; +import static com.launchdarkly.client.DataStoreCacheConfig.StaleValuesPolicy.REFRESH; +import static com.launchdarkly.client.DataStoreCacheConfig.StaleValuesPolicy.REFRESH_ASYNC; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; -public class FeatureStoreCachingTest { +@SuppressWarnings("javadoc") +public class DataStoreCachingTest { @Test public void disabledHasExpectedProperties() { - FeatureStoreCacheConfig fsc = FeatureStoreCacheConfig.disabled(); + DataStoreCacheConfig fsc = DataStoreCacheConfig.disabled(); assertThat(fsc.getCacheTime(), equalTo(0L)); assertThat(fsc.isEnabled(), equalTo(false)); assertThat(fsc.getStaleValuesPolicy(), equalTo(EVICT)); @@ -21,8 +22,8 @@ public void disabledHasExpectedProperties() { @Test public void enabledHasExpectedProperties() { - FeatureStoreCacheConfig fsc = FeatureStoreCacheConfig.enabled(); - assertThat(fsc.getCacheTime(), equalTo(FeatureStoreCacheConfig.DEFAULT_TIME_SECONDS)); + DataStoreCacheConfig fsc = DataStoreCacheConfig.enabled(); + assertThat(fsc.getCacheTime(), equalTo(DataStoreCacheConfig.DEFAULT_TIME_SECONDS)); assertThat(fsc.getCacheTimeUnit(), equalTo(TimeUnit.SECONDS)); assertThat(fsc.isEnabled(), equalTo(true)); assertThat(fsc.getStaleValuesPolicy(), equalTo(EVICT)); @@ -30,8 +31,8 @@ public void enabledHasExpectedProperties() { @Test public void defaultIsEnabled() { - FeatureStoreCacheConfig fsc = FeatureStoreCacheConfig.DEFAULT; - assertThat(fsc.getCacheTime(), equalTo(FeatureStoreCacheConfig.DEFAULT_TIME_SECONDS)); + DataStoreCacheConfig fsc = DataStoreCacheConfig.DEFAULT; + assertThat(fsc.getCacheTime(), equalTo(DataStoreCacheConfig.DEFAULT_TIME_SECONDS)); assertThat(fsc.getCacheTimeUnit(), equalTo(TimeUnit.SECONDS)); assertThat(fsc.isEnabled(), equalTo(true)); assertThat(fsc.getStaleValuesPolicy(), equalTo(EVICT)); @@ -39,7 +40,7 @@ public void defaultIsEnabled() { @Test public void canSetTtl() { - FeatureStoreCacheConfig fsc = FeatureStoreCacheConfig.enabled() + DataStoreCacheConfig fsc = DataStoreCacheConfig.enabled() .staleValuesPolicy(REFRESH) .ttl(3, TimeUnit.DAYS); assertThat(fsc.getCacheTime(), equalTo(3L)); @@ -49,7 +50,7 @@ public void canSetTtl() { @Test public void canSetTtlInMillis() { - FeatureStoreCacheConfig fsc = FeatureStoreCacheConfig.enabled() + DataStoreCacheConfig fsc = DataStoreCacheConfig.enabled() .staleValuesPolicy(REFRESH) .ttlMillis(3); assertThat(fsc.getCacheTime(), equalTo(3L)); @@ -59,7 +60,7 @@ public void canSetTtlInMillis() { @Test public void canSetTtlInSeconds() { - FeatureStoreCacheConfig fsc = FeatureStoreCacheConfig.enabled() + DataStoreCacheConfig fsc = DataStoreCacheConfig.enabled() .staleValuesPolicy(REFRESH) .ttlSeconds(3); assertThat(fsc.getCacheTime(), equalTo(3L)); @@ -69,21 +70,21 @@ public void canSetTtlInSeconds() { @Test public void zeroTtlMeansDisabled() { - FeatureStoreCacheConfig fsc = FeatureStoreCacheConfig.enabled() + DataStoreCacheConfig fsc = DataStoreCacheConfig.enabled() .ttl(0, TimeUnit.SECONDS); assertThat(fsc.isEnabled(), equalTo(false)); } @Test public void negativeTtlMeansDisabled() { - FeatureStoreCacheConfig fsc = FeatureStoreCacheConfig.enabled() + DataStoreCacheConfig fsc = DataStoreCacheConfig.enabled() .ttl(-1, TimeUnit.SECONDS); assertThat(fsc.isEnabled(), equalTo(false)); } @Test public void canSetStaleValuesPolicy() { - FeatureStoreCacheConfig fsc = FeatureStoreCacheConfig.enabled() + DataStoreCacheConfig fsc = DataStoreCacheConfig.enabled() .ttlMillis(3) .staleValuesPolicy(REFRESH_ASYNC); assertThat(fsc.getStaleValuesPolicy(), equalTo(REFRESH_ASYNC)); @@ -93,27 +94,27 @@ public void canSetStaleValuesPolicy() { @Test public void equalityUsesTime() { - FeatureStoreCacheConfig fsc1 = FeatureStoreCacheConfig.enabled().ttlMillis(3); - FeatureStoreCacheConfig fsc2 = FeatureStoreCacheConfig.enabled().ttlMillis(3); - FeatureStoreCacheConfig fsc3 = FeatureStoreCacheConfig.enabled().ttlMillis(4); + DataStoreCacheConfig fsc1 = DataStoreCacheConfig.enabled().ttlMillis(3); + DataStoreCacheConfig fsc2 = DataStoreCacheConfig.enabled().ttlMillis(3); + DataStoreCacheConfig fsc3 = DataStoreCacheConfig.enabled().ttlMillis(4); assertThat(fsc1.equals(fsc2), equalTo(true)); assertThat(fsc1.equals(fsc3), equalTo(false)); } @Test public void equalityUsesTimeUnit() { - FeatureStoreCacheConfig fsc1 = FeatureStoreCacheConfig.enabled().ttlMillis(3); - FeatureStoreCacheConfig fsc2 = FeatureStoreCacheConfig.enabled().ttlMillis(3); - FeatureStoreCacheConfig fsc3 = FeatureStoreCacheConfig.enabled().ttlSeconds(3); + DataStoreCacheConfig fsc1 = DataStoreCacheConfig.enabled().ttlMillis(3); + DataStoreCacheConfig fsc2 = DataStoreCacheConfig.enabled().ttlMillis(3); + DataStoreCacheConfig fsc3 = DataStoreCacheConfig.enabled().ttlSeconds(3); assertThat(fsc1.equals(fsc2), equalTo(true)); assertThat(fsc1.equals(fsc3), equalTo(false)); } @Test public void equalityUsesStaleValuesPolicy() { - FeatureStoreCacheConfig fsc1 = FeatureStoreCacheConfig.enabled().staleValuesPolicy(EVICT); - FeatureStoreCacheConfig fsc2 = FeatureStoreCacheConfig.enabled().staleValuesPolicy(EVICT); - FeatureStoreCacheConfig fsc3 = FeatureStoreCacheConfig.enabled().staleValuesPolicy(REFRESH); + DataStoreCacheConfig fsc1 = DataStoreCacheConfig.enabled().staleValuesPolicy(EVICT); + DataStoreCacheConfig fsc2 = DataStoreCacheConfig.enabled().staleValuesPolicy(EVICT); + DataStoreCacheConfig fsc3 = DataStoreCacheConfig.enabled().staleValuesPolicy(REFRESH); assertThat(fsc1.equals(fsc2), equalTo(true)); assertThat(fsc1.equals(fsc3), equalTo(false)); } diff --git a/src/test/java/com/launchdarkly/client/FeatureStoreDatabaseTestBase.java b/src/test/java/com/launchdarkly/client/DataStoreDatabaseTestBase.java similarity index 92% rename from src/test/java/com/launchdarkly/client/FeatureStoreDatabaseTestBase.java rename to src/test/java/com/launchdarkly/client/DataStoreDatabaseTestBase.java index 0ba5637f9..08b902b9e 100644 --- a/src/test/java/com/launchdarkly/client/FeatureStoreDatabaseTestBase.java +++ b/src/test/java/com/launchdarkly/client/DataStoreDatabaseTestBase.java @@ -1,7 +1,7 @@ package com.launchdarkly.client; import com.launchdarkly.client.TestUtil.DataBuilder; -import com.launchdarkly.client.interfaces.FeatureStore; +import com.launchdarkly.client.interfaces.DataStore; import org.junit.After; import org.junit.Assume; @@ -23,23 +23,24 @@ import static org.junit.Assume.assumeTrue; /** - * Extends FeatureStoreTestBase with tests for feature stores where multiple store instances can + * Extends DataStoreTestBase with tests for data stores where multiple store instances can * use the same underlying data store (i.e. database implementations in general). */ +@SuppressWarnings("javadoc") @RunWith(Parameterized.class) -public abstract class FeatureStoreDatabaseTestBase extends FeatureStoreTestBase { +public abstract class DataStoreDatabaseTestBase extends DataStoreTestBase { @Parameters(name="cached={0}") public static Iterable data() { return Arrays.asList(new Boolean[] { false, true }); } - public FeatureStoreDatabaseTestBase(boolean cached) { + public DataStoreDatabaseTestBase(boolean cached) { super(cached); } /** - * Test subclasses should override this method if the feature store class supports a key prefix option + * Test subclasses should override this method if the data store class supports a key prefix option * for keeping data sets distinct within the same database. */ protected T makeStoreWithPrefix(String prefix) { @@ -47,7 +48,7 @@ protected T makeStoreWithPrefix(String prefix) { } /** - * Test classes should override this to return false if the feature store class does not have a local + * Test classes should override this to return false if the data store class does not have a local * caching option (e.g. the in-memory store). * @return */ @@ -57,7 +58,7 @@ protected boolean isCachingSupported() { /** * 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 + * possible for data to exist there before the data store is created (i.e. if * isUnderlyingDataSharedByAllInstances() returns true). */ protected void clearAllData() { diff --git a/src/test/java/com/launchdarkly/client/FeatureStoreTestBase.java b/src/test/java/com/launchdarkly/client/DataStoreTestBase.java similarity index 93% rename from src/test/java/com/launchdarkly/client/FeatureStoreTestBase.java rename to src/test/java/com/launchdarkly/client/DataStoreTestBase.java index d85b70ead..4ebb265e8 100644 --- a/src/test/java/com/launchdarkly/client/FeatureStoreTestBase.java +++ b/src/test/java/com/launchdarkly/client/DataStoreTestBase.java @@ -1,7 +1,7 @@ package com.launchdarkly.client; import com.launchdarkly.client.TestUtil.DataBuilder; -import com.launchdarkly.client.interfaces.FeatureStore; +import com.launchdarkly.client.interfaces.DataStore; import com.launchdarkly.client.interfaces.VersionedData; import com.launchdarkly.client.interfaces.VersionedDataKind; @@ -22,11 +22,11 @@ import static org.junit.Assert.assertTrue; /** - * Basic tests for FeatureStore implementations. For database implementations, use the more - * comprehensive FeatureStoreDatabaseTestBase. + * Basic tests for DataStore implementations. For database implementations, use the more + * comprehensive DataStoreDatabaseTestBase. */ @SuppressWarnings("javadoc") -public abstract class FeatureStoreTestBase { +public abstract class DataStoreTestBase { protected T store; protected boolean cached; @@ -45,16 +45,16 @@ public abstract class FeatureStoreTestBase { .version(11) .build(); - public FeatureStoreTestBase() { + public DataStoreTestBase() { this(false); } - public FeatureStoreTestBase(boolean cached) { + public DataStoreTestBase(boolean cached) { this.cached = cached; } /** - * Test subclasses must override this method to create an instance of the feature store class, with + * Test subclasses must override this method to create an instance of the data store class, with * caching either enabled or disabled depending on the "cached" property. * @return */ @@ -62,7 +62,7 @@ public FeatureStoreTestBase(boolean cached) { /** * 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 + * possible for data to exist there before the data store is created (i.e. if * isUnderlyingDataSharedByAllInstances() returns true). */ protected void clearAllData() { diff --git a/src/test/java/com/launchdarkly/client/InMemoryDataStoreTest.java b/src/test/java/com/launchdarkly/client/InMemoryDataStoreTest.java new file mode 100644 index 000000000..dc18c439d --- /dev/null +++ b/src/test/java/com/launchdarkly/client/InMemoryDataStoreTest.java @@ -0,0 +1,9 @@ +package com.launchdarkly.client; + +public class InMemoryDataStoreTest extends DataStoreTestBase { + + @Override + protected InMemoryDataStore makeStore() { + return new InMemoryDataStore(); + } +} diff --git a/src/test/java/com/launchdarkly/client/InMemoryFeatureStoreTest.java b/src/test/java/com/launchdarkly/client/InMemoryFeatureStoreTest.java deleted file mode 100644 index fec2aab7c..000000000 --- a/src/test/java/com/launchdarkly/client/InMemoryFeatureStoreTest.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.launchdarkly.client; - -public class InMemoryFeatureStoreTest extends FeatureStoreTestBase { - - @Override - protected InMemoryFeatureStore makeStore() { - return new InMemoryFeatureStore(); - } -} diff --git a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java index 1f3083be3..b0516a592 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java @@ -4,7 +4,7 @@ import com.google.gson.Gson; import com.google.gson.JsonElement; import com.google.gson.JsonPrimitive; -import com.launchdarkly.client.interfaces.FeatureStore; +import com.launchdarkly.client.interfaces.DataStore; import com.launchdarkly.client.value.LDValue; import org.junit.Test; @@ -19,10 +19,10 @@ import static com.launchdarkly.client.ModelBuilders.flagBuilder; import static com.launchdarkly.client.ModelBuilders.flagWithValue; import static com.launchdarkly.client.ModelBuilders.segmentBuilder; -import static com.launchdarkly.client.TestUtil.failedUpdateProcessor; -import static com.launchdarkly.client.TestUtil.featureStoreThatThrowsException; -import static com.launchdarkly.client.TestUtil.specificFeatureStore; -import static com.launchdarkly.client.TestUtil.specificUpdateProcessor; +import static com.launchdarkly.client.TestUtil.dataStoreThatThrowsException; +import static com.launchdarkly.client.TestUtil.failedDataSource; +import static com.launchdarkly.client.TestUtil.specificDataSource; +import static com.launchdarkly.client.TestUtil.specificDataStore; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; @@ -34,18 +34,18 @@ public class LDClientEvaluationTest { private static final LDUser userWithNullKey = new LDUser.Builder((String)null).build(); private static final Gson gson = new Gson(); - private FeatureStore featureStore = TestUtil.initedFeatureStore(); + private DataStore dataStore = TestUtil.initedDataStore(); private LDConfig config = new LDConfig.Builder() - .featureStoreFactory(specificFeatureStore(featureStore)) - .eventProcessorFactory(Components.nullEventProcessor()) - .updateProcessorFactory(Components.nullUpdateProcessor()) + .dataStore(specificDataStore(dataStore)) + .eventProcessor(Components.nullEventProcessor()) + .dataSource(Components.nullDataSource()) .build(); private LDClientInterface client = new LDClient("SDK_KEY", config); @Test public void boolVariationReturnsFlagValue() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(true))); + dataStore.upsert(FEATURES, flagWithValue("key", LDValue.of(true))); assertTrue(client.boolVariation("key", user, false)); } @@ -57,31 +57,31 @@ public void boolVariationReturnsDefaultValueForUnknownFlag() throws Exception { @Test public void boolVariationReturnsDefaultValueForWrongType() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of("wrong"))); + dataStore.upsert(FEATURES, flagWithValue("key", LDValue.of("wrong"))); assertFalse(client.boolVariation("key", user, false)); } @Test public void intVariationReturnsFlagValue() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(2))); + dataStore.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", LDValue.of(2.0))); + dataStore.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", 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))); + dataStore.upsert(FEATURES, flagWithValue("flag1", LDValue.of(2.25))); + dataStore.upsert(FEATURES, flagWithValue("flag2", LDValue.of(2.75))); + dataStore.upsert(FEATURES, flagWithValue("flag3", LDValue.of(-2.25))); + dataStore.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)); @@ -96,21 +96,21 @@ public void intVariationReturnsDefaultValueForUnknownFlag() throws Exception { @Test public void intVariationReturnsDefaultValueForWrongType() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of("wrong"))); + dataStore.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", LDValue.of(2.5d))); + dataStore.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", LDValue.of(2))); + dataStore.upsert(FEATURES, flagWithValue("key", LDValue.of(2))); assertEquals(new Double(2.0d), client.doubleVariation("key", user, 1.0d)); } @@ -122,14 +122,14 @@ public void doubleVariationReturnsDefaultValueForUnknownFlag() throws Exception @Test public void doubleVariationReturnsDefaultValueForWrongType() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of("wrong"))); + dataStore.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", LDValue.of("b"))); + dataStore.upsert(FEATURES, flagWithValue("key", LDValue.of("b"))); assertEquals("b", client.stringVariation("key", user, "a")); } @@ -141,7 +141,7 @@ public void stringVariationReturnsDefaultValueForUnknownFlag() throws Exception @Test public void stringVariationReturnsDefaultValueForWrongType() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(true))); + dataStore.upsert(FEATURES, flagWithValue("key", LDValue.of(true))); assertEquals("a", client.stringVariation("key", user, "a")); } @@ -150,7 +150,7 @@ public void stringVariationReturnsDefaultValueForWrongType() throws Exception { @Test public void deprecatedJsonVariationReturnsFlagValue() throws Exception { LDValue data = LDValue.buildObject().put("thing", LDValue.of("stuff")).build(); - featureStore.upsert(FEATURES, flagWithValue("key", data)); + dataStore.upsert(FEATURES, flagWithValue("key", data)); assertEquals(data.asJsonElement(), client.jsonVariation("key", user, new JsonPrimitive(42))); } @@ -165,7 +165,7 @@ public void deprecatedJsonVariationReturnsDefaultValueForUnknownFlag() throws Ex @Test public void jsonValueVariationReturnsFlagValue() throws Exception { LDValue data = LDValue.buildObject().put("thing", LDValue.of("stuff")).build(); - featureStore.upsert(FEATURES, flagWithValue("key", data)); + dataStore.upsert(FEATURES, flagWithValue("key", data)); assertEquals(data, client.jsonValueVariation("key", user, LDValue.of(42))); } @@ -183,18 +183,18 @@ public void canMatchUserBySegment() throws Exception { .version(1) .included(user.getKeyAsString()) .build(); - featureStore.upsert(SEGMENTS, segment); + dataStore.upsert(SEGMENTS, segment); DataModel.Clause clause = clause("", DataModel.Operator.segmentMatch, LDValue.of("segment1")); DataModel.FeatureFlag feature = booleanFlagWithClauses("feature", clause); - featureStore.upsert(FEATURES, feature); + dataStore.upsert(FEATURES, feature); assertTrue(client.boolVariation("feature", user, false)); } @Test public void canGetDetailsForSuccessfulEvaluation() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(true))); + dataStore.upsert(FEATURES, flagWithValue("key", LDValue.of(true))); EvaluationDetail expectedResult = EvaluationDetail.fromValue(true, 0, EvaluationReason.off()); @@ -204,7 +204,7 @@ public void canGetDetailsForSuccessfulEvaluation() throws Exception { @Test public void variationReturnsDefaultIfFlagEvaluatesToNull() { DataModel.FeatureFlag flag = flagBuilder("key").on(false).offVariation(null).build(); - featureStore.upsert(FEATURES, flag); + dataStore.upsert(FEATURES, flag); assertEquals("default", client.stringVariation("key", user, "default")); } @@ -212,7 +212,7 @@ public void variationReturnsDefaultIfFlagEvaluatesToNull() { @Test public void variationDetailReturnsDefaultIfFlagEvaluatesToNull() { DataModel.FeatureFlag flag = flagBuilder("key").on(false).offVariation(null).build(); - featureStore.upsert(FEATURES, flag); + dataStore.upsert(FEATURES, flag); EvaluationDetail expected = EvaluationDetail.fromValue("default", null, EvaluationReason.off()); @@ -223,11 +223,11 @@ public void variationDetailReturnsDefaultIfFlagEvaluatesToNull() { @Test public void appropriateErrorIfClientNotInitialized() throws Exception { - FeatureStore badFeatureStore = new InMemoryFeatureStore(); + DataStore badDataStore = new InMemoryDataStore(); LDConfig badConfig = new LDConfig.Builder() - .featureStoreFactory(specificFeatureStore(badFeatureStore)) - .eventProcessorFactory(Components.nullEventProcessor()) - .updateProcessorFactory(specificUpdateProcessor(failedUpdateProcessor())) + .dataStore(specificDataStore(badDataStore)) + .eventProcessor(Components.nullEventProcessor()) + .dataSource(specificDataSource(failedDataSource())) .startWaitMillis(0) .build(); try (LDClientInterface badClient = new LDClient("SDK_KEY", badConfig)) { @@ -246,7 +246,7 @@ public void appropriateErrorIfFlagDoesNotExist() throws Exception { @Test public void appropriateErrorIfUserNotSpecified() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(true))); + dataStore.upsert(FEATURES, flagWithValue("key", LDValue.of(true))); EvaluationDetail expectedResult = EvaluationDetail.fromValue("default", null, EvaluationReason.error(EvaluationReason.ErrorKind.USER_NOT_SPECIFIED)); @@ -255,7 +255,7 @@ public void appropriateErrorIfUserNotSpecified() throws Exception { @Test public void appropriateErrorIfValueWrongType() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(true))); + dataStore.upsert(FEATURES, flagWithValue("key", LDValue.of(true))); EvaluationDetail expectedResult = EvaluationDetail.fromValue(3, null, EvaluationReason.error(EvaluationReason.ErrorKind.WRONG_TYPE)); @@ -264,11 +264,11 @@ public void appropriateErrorIfValueWrongType() throws Exception { @Test public void appropriateErrorForUnexpectedException() throws Exception { - FeatureStore badFeatureStore = featureStoreThatThrowsException(new RuntimeException("sorry")); + DataStore badDataStore = dataStoreThatThrowsException(new RuntimeException("sorry")); LDConfig badConfig = new LDConfig.Builder() - .featureStoreFactory(specificFeatureStore(badFeatureStore)) - .eventProcessorFactory(Components.nullEventProcessor()) - .updateProcessorFactory(Components.nullUpdateProcessor()) + .dataStore(specificDataStore(badDataStore)) + .eventProcessor(Components.nullEventProcessor()) + .dataSource(Components.nullDataSource()) .build(); try (LDClientInterface badClient = new LDClient("SDK_KEY", badConfig)) { EvaluationDetail expectedResult = EvaluationDetail.fromValue(false, null, @@ -280,8 +280,8 @@ public void appropriateErrorForUnexpectedException() throws Exception { @SuppressWarnings("deprecation") @Test public void allFlagsReturnsFlagValues() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key1", LDValue.of("value1"))); - featureStore.upsert(FEATURES, flagWithValue("key2", LDValue.of("value2"))); + dataStore.upsert(FEATURES, flagWithValue("key1", LDValue.of("value1"))); + dataStore.upsert(FEATURES, flagWithValue("key2", LDValue.of("value2"))); Map result = client.allFlags(user); assertEquals(ImmutableMap.of("key1", new JsonPrimitive("value1"), "key2", new JsonPrimitive("value2")), result); @@ -290,7 +290,7 @@ public void allFlagsReturnsFlagValues() throws Exception { @SuppressWarnings("deprecation") @Test public void allFlagsReturnsNullForNullUser() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of("value"))); + dataStore.upsert(FEATURES, flagWithValue("key", LDValue.of("value"))); assertNull(client.allFlags(null)); } @@ -298,7 +298,7 @@ public void allFlagsReturnsNullForNullUser() throws Exception { @SuppressWarnings("deprecation") @Test public void allFlagsReturnsNullForNullUserKey() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of("value"))); + dataStore.upsert(FEATURES, flagWithValue("key", LDValue.of("value"))); assertNull(client.allFlags(userWithNullKey)); } @@ -320,8 +320,8 @@ public void allFlagsStateReturnsState() throws Exception { .fallthrough(fallthroughVariation(1)) .variations(LDValue.of("off"), LDValue.of("value2")) .build(); - featureStore.upsert(FEATURES, flag1); - featureStore.upsert(FEATURES, flag2); + dataStore.upsert(FEATURES, flag1); + dataStore.upsert(FEATURES, flag2); FeatureFlagsState state = client.allFlagsState(user); assertTrue(state.isValid()); @@ -348,10 +348,10 @@ public void allFlagsStateCanFilterForOnlyClientSideFlags() { .variations(LDValue.of("value1")).offVariation(0).build(); DataModel.FeatureFlag flag4 = flagBuilder("client-side-2").clientSide(true) .variations(LDValue.of("value2")).offVariation(0).build(); - featureStore.upsert(FEATURES, flag1); - featureStore.upsert(FEATURES, flag2); - featureStore.upsert(FEATURES, flag3); - featureStore.upsert(FEATURES, flag4); + dataStore.upsert(FEATURES, flag1); + dataStore.upsert(FEATURES, flag2); + dataStore.upsert(FEATURES, flag3); + dataStore.upsert(FEATURES, flag4); FeatureFlagsState state = client.allFlagsState(user, FlagsStateOption.CLIENT_SIDE_ONLY); assertTrue(state.isValid()); @@ -377,8 +377,8 @@ public void allFlagsStateReturnsStateWithReasons() { .fallthrough(fallthroughVariation(1)) .variations(LDValue.of("off"), LDValue.of("value2")) .build(); - featureStore.upsert(FEATURES, flag1); - featureStore.upsert(FEATURES, flag2); + dataStore.upsert(FEATURES, flag1); + dataStore.upsert(FEATURES, flag2); FeatureFlagsState state = client.allFlagsState(user, FlagsStateOption.WITH_REASONS); assertTrue(state.isValid()); @@ -422,9 +422,9 @@ public void allFlagsStateCanOmitDetailsForUntrackedFlags() { .offVariation(0) .variations(LDValue.of("value3")) .build(); - featureStore.upsert(FEATURES, flag1); - featureStore.upsert(FEATURES, flag2); - featureStore.upsert(FEATURES, flag3); + dataStore.upsert(FEATURES, flag1); + dataStore.upsert(FEATURES, flag2); + dataStore.upsert(FEATURES, flag3); FeatureFlagsState state = client.allFlagsState(user, FlagsStateOption.WITH_REASONS, FlagsStateOption.DETAILS_ONLY_FOR_TRACKED_FLAGS); assertTrue(state.isValid()); @@ -447,7 +447,7 @@ public void allFlagsStateCanOmitDetailsForUntrackedFlags() { @Test public void allFlagsStateReturnsEmptyStateForNullUser() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of("value"))); + dataStore.upsert(FEATURES, flagWithValue("key", LDValue.of("value"))); FeatureFlagsState state = client.allFlagsState(null); assertFalse(state.isValid()); @@ -456,7 +456,7 @@ public void allFlagsStateReturnsEmptyStateForNullUser() throws Exception { @Test public void allFlagsStateReturnsEmptyStateForNullUserKey() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of("value"))); + dataStore.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 27ce87b3a..903e09da4 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEventTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEventTest.java @@ -4,7 +4,7 @@ import com.google.gson.JsonPrimitive; import com.launchdarkly.client.EvaluationReason.ErrorKind; import com.launchdarkly.client.events.Event; -import com.launchdarkly.client.interfaces.FeatureStore; +import com.launchdarkly.client.interfaces.DataStore; import com.launchdarkly.client.value.LDValue; import org.junit.Test; @@ -18,7 +18,7 @@ import static com.launchdarkly.client.ModelBuilders.prerequisite; import static com.launchdarkly.client.ModelBuilders.ruleBuilder; import static com.launchdarkly.client.TestUtil.specificEventProcessor; -import static com.launchdarkly.client.TestUtil.specificFeatureStore; +import static com.launchdarkly.client.TestUtil.specificDataStore; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; @@ -29,12 +29,12 @@ 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 DataStore dataStore = TestUtil.initedDataStore(); private TestUtil.TestEventProcessor eventSink = new TestUtil.TestEventProcessor(); private LDConfig config = new LDConfig.Builder() - .featureStoreFactory(specificFeatureStore(featureStore)) - .eventProcessorFactory(specificEventProcessor(eventSink)) - .updateProcessorFactory(Components.nullUpdateProcessor()) + .dataStore(specificDataStore(dataStore)) + .eventProcessor(specificEventProcessor(eventSink)) + .dataSource(Components.nullDataSource()) .build(); private LDClientInterface client = new LDClient("SDK_KEY", config); @@ -151,7 +151,7 @@ public void trackWithUserWithNoKeyDoesNotSendEvent() { @Test public void boolVariationSendsEvent() throws Exception { DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of(true)); - featureStore.upsert(FEATURES, flag); + dataStore.upsert(FEATURES, flag); client.boolVariation("key", user, false); assertEquals(1, eventSink.events.size()); @@ -168,7 +168,7 @@ public void boolVariationSendsEventForUnknownFlag() throws Exception { @Test public void boolVariationDetailSendsEvent() throws Exception { DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of(true)); - featureStore.upsert(FEATURES, flag); + dataStore.upsert(FEATURES, flag); client.boolVariationDetail("key", user, false); assertEquals(1, eventSink.events.size()); @@ -186,7 +186,7 @@ public void boolVariationDetailSendsEventForUnknownFlag() throws Exception { @Test public void intVariationSendsEvent() throws Exception { DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of(2)); - featureStore.upsert(FEATURES, flag); + dataStore.upsert(FEATURES, flag); client.intVariation("key", user, 1); assertEquals(1, eventSink.events.size()); @@ -203,7 +203,7 @@ public void intVariationSendsEventForUnknownFlag() throws Exception { @Test public void intVariationDetailSendsEvent() throws Exception { DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of(2)); - featureStore.upsert(FEATURES, flag); + dataStore.upsert(FEATURES, flag); client.intVariationDetail("key", user, 1); assertEquals(1, eventSink.events.size()); @@ -221,7 +221,7 @@ public void intVariationDetailSendsEventForUnknownFlag() throws Exception { @Test public void doubleVariationSendsEvent() throws Exception { DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of(2.5d)); - featureStore.upsert(FEATURES, flag); + dataStore.upsert(FEATURES, flag); client.doubleVariation("key", user, 1.0d); assertEquals(1, eventSink.events.size()); @@ -238,7 +238,7 @@ public void doubleVariationSendsEventForUnknownFlag() throws Exception { @Test public void doubleVariationDetailSendsEvent() throws Exception { DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of(2.5d)); - featureStore.upsert(FEATURES, flag); + dataStore.upsert(FEATURES, flag); client.doubleVariationDetail("key", user, 1.0d); assertEquals(1, eventSink.events.size()); @@ -256,7 +256,7 @@ public void doubleVariationDetailSendsEventForUnknownFlag() throws Exception { @Test public void stringVariationSendsEvent() throws Exception { DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of("b")); - featureStore.upsert(FEATURES, flag); + dataStore.upsert(FEATURES, flag); client.stringVariation("key", user, "a"); assertEquals(1, eventSink.events.size()); @@ -273,7 +273,7 @@ public void stringVariationSendsEventForUnknownFlag() throws Exception { @Test public void stringVariationDetailSendsEvent() throws Exception { DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of("b")); - featureStore.upsert(FEATURES, flag); + dataStore.upsert(FEATURES, flag); client.stringVariationDetail("key", user, "a"); assertEquals(1, eventSink.events.size()); @@ -293,7 +293,7 @@ public void stringVariationDetailSendsEventForUnknownFlag() throws Exception { public void jsonVariationSendsEvent() throws Exception { LDValue data = LDValue.buildObject().put("thing", LDValue.of("stuff")).build(); DataModel.FeatureFlag flag = flagWithValue("key", data); - featureStore.upsert(FEATURES, flag); + dataStore.upsert(FEATURES, flag); LDValue defaultVal = LDValue.of(42); client.jsonVariation("key", user, new JsonPrimitive(defaultVal.intValue())); @@ -316,7 +316,7 @@ public void jsonVariationSendsEventForUnknownFlag() throws Exception { public void jsonVariationDetailSendsEvent() throws Exception { LDValue data = LDValue.buildObject().put("thing", LDValue.of("stuff")).build(); DataModel.FeatureFlag flag = flagWithValue("key", data); - featureStore.upsert(FEATURES, flag); + dataStore.upsert(FEATURES, flag); LDValue defaultVal = LDValue.of(42); client.jsonVariationDetail("key", user, new JsonPrimitive(defaultVal.intValue())); @@ -339,7 +339,7 @@ public void jsonVariationDetailSendsEventForUnknownFlag() throws Exception { public void jsonValueVariationDetailSendsEvent() throws Exception { LDValue data = LDValue.buildObject().put("thing", LDValue.of("stuff")).build(); DataModel.FeatureFlag flag = flagWithValue("key", data); - featureStore.upsert(FEATURES, flag); + dataStore.upsert(FEATURES, flag); LDValue defaultVal = LDValue.of(42); client.jsonValueVariationDetail("key", user, defaultVal); @@ -367,7 +367,7 @@ public void eventTrackingAndReasonCanBeForcedForRule() throws Exception { .offVariation(0) .variations(LDValue.of("off"), LDValue.of("on")) .build(); - featureStore.upsert(FEATURES, flag); + dataStore.upsert(FEATURES, flag); client.stringVariation("flag", user, "default"); @@ -392,7 +392,7 @@ public void eventTrackingAndReasonAreNotForcedIfFlagIsNotSetForMatchingRule() th .offVariation(0) .variations(LDValue.of("off"), LDValue.of("on")) .build(); - featureStore.upsert(FEATURES, flag); + dataStore.upsert(FEATURES, flag); client.stringVariation("flag", user, "default"); @@ -412,7 +412,7 @@ public void eventTrackingAndReasonCanBeForcedForFallthrough() throws Exception { .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .trackEventsFallthrough(true) .build(); - featureStore.upsert(FEATURES, flag); + dataStore.upsert(FEATURES, flag); client.stringVariation("flag", user, "default"); @@ -433,7 +433,7 @@ public void eventTrackingAndReasonAreNotForcedForFallthroughIfFlagIsNotSet() thr .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .trackEventsFallthrough(false) .build(); - featureStore.upsert(FEATURES, flag); + dataStore.upsert(FEATURES, flag); client.stringVariation("flag", user, "default"); @@ -452,7 +452,7 @@ public void eventTrackingAndReasonAreNotForcedForFallthroughIfReasonIsNotFallthr .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .trackEventsFallthrough(true) .build(); - featureStore.upsert(FEATURES, flag); + dataStore.upsert(FEATURES, flag); client.stringVariation("flag", user, "default"); @@ -478,8 +478,8 @@ public void eventIsSentForExistingPrererequisiteFlag() throws Exception { .variations(LDValue.of("nogo"), LDValue.of("go")) .version(2) .build(); - featureStore.upsert(FEATURES, f0); - featureStore.upsert(FEATURES, f1); + dataStore.upsert(FEATURES, f0); + dataStore.upsert(FEATURES, f1); client.stringVariation("feature0", user, "default"); @@ -504,8 +504,8 @@ public void eventIsSentWithReasonForExistingPrererequisiteFlag() throws Exceptio .variations(LDValue.of("nogo"), LDValue.of("go")) .version(2) .build(); - featureStore.upsert(FEATURES, f0); - featureStore.upsert(FEATURES, f1); + dataStore.upsert(FEATURES, f0); + dataStore.upsert(FEATURES, f1); client.stringVariationDetail("feature0", user, "default"); @@ -524,7 +524,7 @@ public void eventIsNotSentForUnknownPrererequisiteFlag() throws Exception { .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .version(1) .build(); - featureStore.upsert(FEATURES, f0); + dataStore.upsert(FEATURES, f0); client.stringVariation("feature0", user, "default"); @@ -542,7 +542,7 @@ public void failureReasonIsGivenForUnknownPrererequisiteFlagIfDetailsWereRequest .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .version(1) .build(); - featureStore.upsert(FEATURES, f0); + dataStore.upsert(FEATURES, f0); client.stringVariationDetail("feature0", user, "default"); diff --git a/src/test/java/com/launchdarkly/client/LDClientLddModeTest.java b/src/test/java/com/launchdarkly/client/LDClientLddModeTest.java index edcfb81dc..228177691 100644 --- a/src/test/java/com/launchdarkly/client/LDClientLddModeTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientLddModeTest.java @@ -1,7 +1,6 @@ package com.launchdarkly.client; -import com.launchdarkly.client.interfaces.FeatureStore; -import com.launchdarkly.client.interfaces.UpdateProcessor; +import com.launchdarkly.client.interfaces.DataStore; import com.launchdarkly.client.value.LDValue; import org.junit.Test; @@ -10,20 +9,20 @@ import static com.launchdarkly.client.DataModel.DataKinds.FEATURES; import static com.launchdarkly.client.ModelBuilders.flagWithValue; -import static com.launchdarkly.client.TestUtil.initedFeatureStore; -import static com.launchdarkly.client.TestUtil.specificFeatureStore; +import static com.launchdarkly.client.TestUtil.initedDataStore; +import static com.launchdarkly.client.TestUtil.specificDataStore; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; @SuppressWarnings("javadoc") public class LDClientLddModeTest { @Test - public void lddModeClientHasNullUpdateProcessor() throws IOException { + public void lddModeClientHasNullDataSource() throws IOException { LDConfig config = new LDConfig.Builder() .useLdd(true) .build(); try (LDClient client = new LDClient("SDK_KEY", config)) { - assertEquals(UpdateProcessor.NullUpdateProcessor.class, client.updateProcessor.getClass()); + assertEquals(Components.NullDataSource.class, client.dataSource.getClass()); } } @@ -48,14 +47,14 @@ public void lddModeClientIsInitialized() throws IOException { } @Test - public void lddModeClientGetsFlagFromFeatureStore() throws IOException { - FeatureStore testFeatureStore = initedFeatureStore(); + public void lddModeClientGetsFlagFromDataStore() throws IOException { + DataStore testDataStore = initedDataStore(); LDConfig config = new LDConfig.Builder() .useLdd(true) - .featureStoreFactory(specificFeatureStore(testFeatureStore)) + .dataStore(specificDataStore(testDataStore)) .build(); DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of(true)); - testFeatureStore.upsert(FEATURES, flag); + testDataStore.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 091666708..eb8201761 100644 --- a/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java @@ -2,9 +2,7 @@ import com.google.common.collect.ImmutableMap; import com.google.gson.JsonElement; -import com.launchdarkly.client.interfaces.EventProcessor; -import com.launchdarkly.client.interfaces.FeatureStore; -import com.launchdarkly.client.interfaces.UpdateProcessor; +import com.launchdarkly.client.interfaces.DataStore; import com.launchdarkly.client.value.LDValue; import org.junit.Test; @@ -14,9 +12,9 @@ import static com.launchdarkly.client.DataModel.DataKinds.FEATURES; import static com.launchdarkly.client.ModelBuilders.flagWithValue; -import static com.launchdarkly.client.TestUtil.initedFeatureStore; +import static com.launchdarkly.client.TestUtil.initedDataStore; import static com.launchdarkly.client.TestUtil.jbool; -import static com.launchdarkly.client.TestUtil.specificFeatureStore; +import static com.launchdarkly.client.TestUtil.specificDataStore; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; @@ -25,12 +23,12 @@ public class LDClientOfflineTest { private static final LDUser user = new LDUser("user"); @Test - public void offlineClientHasNullUpdateProcessor() throws IOException { + public void offlineClientHasNullDataSource() 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.NullDataSource.class, client.dataSource.getClass()); } } @@ -40,7 +38,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()); } } @@ -65,13 +63,13 @@ public void offlineClientReturnsDefaultValue() throws IOException { } @Test - public void offlineClientGetsAllFlagsFromFeatureStore() throws IOException { - FeatureStore testFeatureStore = initedFeatureStore(); + public void offlineClientGetsAllFlagsFromDataStore() throws IOException { + DataStore testDataStore = initedDataStore(); LDConfig config = new LDConfig.Builder() .offline(true) - .featureStoreFactory(specificFeatureStore(testFeatureStore)) + .dataStore(specificDataStore(testDataStore)) .build(); - testFeatureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(true))); + testDataStore.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); @@ -79,13 +77,13 @@ public void offlineClientGetsAllFlagsFromFeatureStore() throws IOException { } @Test - public void offlineClientGetsFlagsStateFromFeatureStore() throws IOException { - FeatureStore testFeatureStore = initedFeatureStore(); + public void offlineClientGetsFlagsStateFromDataStore() throws IOException { + DataStore testDataStore = initedDataStore(); LDConfig config = new LDConfig.Builder() .offline(true) - .featureStoreFactory(specificFeatureStore(testFeatureStore)) + .dataStore(specificDataStore(testDataStore)) .build(); - testFeatureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(true))); + testDataStore.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 3794c6f8f..3bab9ffb1 100644 --- a/src/test/java/com/launchdarkly/client/LDClientTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientTest.java @@ -6,9 +6,9 @@ import com.google.common.collect.ImmutableMap; import com.google.common.collect.Iterables; import com.launchdarkly.client.events.Event; +import com.launchdarkly.client.interfaces.DataSource; +import com.launchdarkly.client.interfaces.DataStore; import com.launchdarkly.client.interfaces.EventProcessor; -import com.launchdarkly.client.interfaces.FeatureStore; -import com.launchdarkly.client.interfaces.UpdateProcessor; import com.launchdarkly.client.interfaces.VersionedData; import com.launchdarkly.client.interfaces.VersionedDataKind; import com.launchdarkly.client.value.LDValue; @@ -33,9 +33,9 @@ import static com.launchdarkly.client.ModelBuilders.flagWithValue; import static com.launchdarkly.client.ModelBuilders.prerequisite; import static com.launchdarkly.client.ModelBuilders.segmentBuilder; -import static com.launchdarkly.client.TestUtil.initedFeatureStore; -import static com.launchdarkly.client.TestUtil.specificFeatureStore; -import static com.launchdarkly.client.TestUtil.updateProcessorWithData; +import static com.launchdarkly.client.TestUtil.dataSourceWithData; +import static com.launchdarkly.client.TestUtil.initedDataStore; +import static com.launchdarkly.client.TestUtil.specificDataStore; import static org.easymock.EasyMock.anyObject; import static org.easymock.EasyMock.expect; import static org.easymock.EasyMock.expectLastCall; @@ -52,7 +52,7 @@ */ @SuppressWarnings("javadoc") public class LDClientTest extends EasyMockSupport { - private UpdateProcessor updateProcessor; + private DataSource dataSource; private EventProcessor eventProcessor; private Future initFuture; private LDClientInterface client; @@ -60,7 +60,7 @@ public class LDClientTest extends EasyMockSupport { @SuppressWarnings("unchecked") @Before public void before() { - updateProcessor = createStrictMock(UpdateProcessor.class); + dataSource = createStrictMock(DataSource.class); eventProcessor = createStrictMock(EventProcessor.class); initFuture = createStrictMock(Future.class); } @@ -114,7 +114,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()); } } @@ -126,7 +126,7 @@ public void streamingClientHasStreamProcessor() throws Exception { .startWaitMillis(0) .build(); try (LDClient client = new LDClient("SDK_KEY", config)) { - assertEquals(StreamProcessor.class, client.updateProcessor.getClass()); + assertEquals(StreamProcessor.class, client.dataSource.getClass()); } } @@ -138,17 +138,17 @@ public void pollingClientHasPollingProcessor() throws IOException { .startWaitMillis(0) .build(); try (LDClient client = new LDClient("SDK_KEY", config)) { - assertEquals(PollingProcessor.class, client.updateProcessor.getClass()); + assertEquals(PollingProcessor.class, client.dataSource.getClass()); } } @Test - public void noWaitForUpdateProcessorIfWaitMillisIsZero() throws Exception { + public void noWaitForDataSourceIfWaitMillisIsZero() throws Exception { LDConfig.Builder config = new LDConfig.Builder() .startWaitMillis(0L); - expect(updateProcessor.start()).andReturn(initFuture); - expect(updateProcessor.initialized()).andReturn(false); + expect(dataSource.start()).andReturn(initFuture); + expect(dataSource.initialized()).andReturn(false); replayAll(); client = createMockClient(config); @@ -158,13 +158,13 @@ public void noWaitForUpdateProcessorIfWaitMillisIsZero() throws Exception { } @Test - public void willWaitForUpdateProcessorIfWaitMillisIsNonZero() throws Exception { + public void willWaitForDataSourceIfWaitMillisIsNonZero() throws Exception { LDConfig.Builder config = new LDConfig.Builder() .startWaitMillis(10L); - expect(updateProcessor.start()).andReturn(initFuture); + expect(dataSource.start()).andReturn(initFuture); expect(initFuture.get(10L, TimeUnit.MILLISECONDS)).andReturn(null); - expect(updateProcessor.initialized()).andReturn(false).anyTimes(); + expect(dataSource.initialized()).andReturn(false).anyTimes(); replayAll(); client = createMockClient(config); @@ -174,13 +174,13 @@ public void willWaitForUpdateProcessorIfWaitMillisIsNonZero() throws Exception { } @Test - public void updateProcessorCanTimeOut() throws Exception { + public void dataSourceCanTimeOut() throws Exception { LDConfig.Builder config = new LDConfig.Builder() .startWaitMillis(10L); - expect(updateProcessor.start()).andReturn(initFuture); + expect(dataSource.start()).andReturn(initFuture); expect(initFuture.get(10L, TimeUnit.MILLISECONDS)).andThrow(new TimeoutException()); - expect(updateProcessor.initialized()).andReturn(false).anyTimes(); + expect(dataSource.initialized()).andReturn(false).anyTimes(); replayAll(); client = createMockClient(config); @@ -190,13 +190,13 @@ public void updateProcessorCanTimeOut() throws Exception { } @Test - public void clientCatchesRuntimeExceptionFromUpdateProcessor() throws Exception { + public void clientCatchesRuntimeExceptionFromDataSource() throws Exception { LDConfig.Builder config = new LDConfig.Builder() .startWaitMillis(10L); - expect(updateProcessor.start()).andReturn(initFuture); + expect(dataSource.start()).andReturn(initFuture); expect(initFuture.get(10L, TimeUnit.MILLISECONDS)).andThrow(new RuntimeException()); - expect(updateProcessor.initialized()).andReturn(false).anyTimes(); + expect(dataSource.initialized()).andReturn(false).anyTimes(); replayAll(); client = createMockClient(config); @@ -207,29 +207,29 @@ public void clientCatchesRuntimeExceptionFromUpdateProcessor() throws Exception @Test public void isFlagKnownReturnsTrueForExistingFlag() throws Exception { - FeatureStore testFeatureStore = initedFeatureStore(); + DataStore testDataStore = initedDataStore(); LDConfig.Builder config = new LDConfig.Builder() .startWaitMillis(0) - .featureStoreFactory(specificFeatureStore(testFeatureStore)); - expect(updateProcessor.start()).andReturn(initFuture); - expect(updateProcessor.initialized()).andReturn(true).times(1); + .dataStore(specificDataStore(testDataStore)); + expect(dataSource.start()).andReturn(initFuture); + expect(dataSource.initialized()).andReturn(true).times(1); replayAll(); client = createMockClient(config); - testFeatureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(1))); + testDataStore.upsert(FEATURES, flagWithValue("key", LDValue.of(1))); assertTrue(client.isFlagKnown("key")); verifyAll(); } @Test public void isFlagKnownReturnsFalseForUnknownFlag() throws Exception { - FeatureStore testFeatureStore = initedFeatureStore(); + DataStore testDataStore = initedDataStore(); LDConfig.Builder config = new LDConfig.Builder() .startWaitMillis(0) - .featureStoreFactory(specificFeatureStore(testFeatureStore)); - expect(updateProcessor.start()).andReturn(initFuture); - expect(updateProcessor.initialized()).andReturn(true).times(1); + .dataStore(specificDataStore(testDataStore)); + expect(dataSource.start()).andReturn(initFuture); + expect(dataSource.initialized()).andReturn(true).times(1); replayAll(); client = createMockClient(config); @@ -240,71 +240,71 @@ public void isFlagKnownReturnsFalseForUnknownFlag() throws Exception { @Test public void isFlagKnownReturnsFalseIfStoreAndClientAreNotInitialized() throws Exception { - FeatureStore testFeatureStore = new InMemoryFeatureStore(); + DataStore testDataStore = new InMemoryDataStore(); LDConfig.Builder config = new LDConfig.Builder() .startWaitMillis(0) - .featureStoreFactory(specificFeatureStore(testFeatureStore)); - expect(updateProcessor.start()).andReturn(initFuture); - expect(updateProcessor.initialized()).andReturn(false).times(1); + .dataStore(specificDataStore(testDataStore)); + expect(dataSource.start()).andReturn(initFuture); + expect(dataSource.initialized()).andReturn(false).times(1); replayAll(); client = createMockClient(config); - testFeatureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(1))); + testDataStore.upsert(FEATURES, flagWithValue("key", LDValue.of(1))); assertFalse(client.isFlagKnown("key")); verifyAll(); } @Test public void isFlagKnownUsesStoreIfStoreIsInitializedButClientIsNot() throws Exception { - FeatureStore testFeatureStore = initedFeatureStore(); + DataStore testDataStore = initedDataStore(); LDConfig.Builder config = new LDConfig.Builder() .startWaitMillis(0) - .featureStoreFactory(specificFeatureStore(testFeatureStore)); - expect(updateProcessor.start()).andReturn(initFuture); - expect(updateProcessor.initialized()).andReturn(false).times(1); + .dataStore(specificDataStore(testDataStore)); + expect(dataSource.start()).andReturn(initFuture); + expect(dataSource.initialized()).andReturn(false).times(1); replayAll(); client = createMockClient(config); - testFeatureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(1))); + testDataStore.upsert(FEATURES, flagWithValue("key", LDValue.of(1))); assertTrue(client.isFlagKnown("key")); verifyAll(); } @Test public void evaluationUsesStoreIfStoreIsInitializedButClientIsNot() throws Exception { - FeatureStore testFeatureStore = initedFeatureStore(); + DataStore testDataStore = initedDataStore(); LDConfig.Builder config = new LDConfig.Builder() - .featureStoreFactory(specificFeatureStore(testFeatureStore)) + .dataStore(specificDataStore(testDataStore)) .startWaitMillis(0L); - expect(updateProcessor.start()).andReturn(initFuture); - expect(updateProcessor.initialized()).andReturn(false); + expect(dataSource.start()).andReturn(initFuture); + expect(dataSource.initialized()).andReturn(false); expectEventsSent(1); replayAll(); client = createMockClient(config); - testFeatureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(1))); + testDataStore.upsert(FEATURES, flagWithValue("key", LDValue.of(1))); assertEquals(new Integer(1), client.intVariation("key", new LDUser("user"), 0)); verifyAll(); } @Test - public void dataSetIsPassedToFeatureStoreInCorrectOrder() throws Exception { - // This verifies that the client is using FeatureStoreClientWrapper and that it is applying the + public void dataSetIsPassedToDataStoreInCorrectOrder() throws Exception { + // This verifies that the client is using DataStoreClientWrapper and that it is applying the // correct ordering for flag prerequisites, etc. This should work regardless of what kind of - // UpdateProcessor we're using. + // DataSource we're using. Capture, Map>> captureData = Capture.newInstance(); - FeatureStore store = createStrictMock(FeatureStore.class); + DataStore store = createStrictMock(DataStore.class); store.init(EasyMock.capture(captureData)); replay(store); LDConfig.Builder config = new LDConfig.Builder() - .updateProcessorFactory(updateProcessorWithData(DEPENDENCY_ORDERING_TEST_DATA)) - .featureStoreFactory(specificFeatureStore(store)) + .dataSource(dataSourceWithData(DEPENDENCY_ORDERING_TEST_DATA)) + .dataStore(specificDataStore(store)) .sendEvents(false); client = new LDClient("SDK_KEY", config.build()); @@ -349,8 +349,8 @@ private void expectEventsSent(int count) { } private LDClientInterface createMockClient(LDConfig.Builder config) { - config.updateProcessorFactory(TestUtil.specificUpdateProcessor(updateProcessor)); - config.eventProcessorFactory(TestUtil.specificEventProcessor(eventProcessor)); + config.dataSource(TestUtil.specificDataSource(dataSource)); + config.eventProcessor(TestUtil.specificEventProcessor(eventProcessor)); return new LDClient("SDK_KEY", config.build()); } diff --git a/src/test/java/com/launchdarkly/client/PollingProcessorTest.java b/src/test/java/com/launchdarkly/client/PollingProcessorTest.java index e6a174131..7563c4f97 100644 --- a/src/test/java/com/launchdarkly/client/PollingProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/PollingProcessorTest.java @@ -1,6 +1,6 @@ package com.launchdarkly.client; -import com.launchdarkly.client.interfaces.FeatureStore; +import com.launchdarkly.client.interfaces.DataStore; import org.junit.Test; @@ -20,7 +20,7 @@ public class PollingProcessorTest { public void testConnectionOk() throws Exception { MockFeatureRequestor requestor = new MockFeatureRequestor(); requestor.allData = new FeatureRequestor.AllData(new HashMap(), new HashMap()); - FeatureStore store = new InMemoryFeatureStore(); + DataStore store = new InMemoryDataStore(); try (PollingProcessor pollingProcessor = new PollingProcessor(LDConfig.DEFAULT, requestor, store)) { Future initFuture = pollingProcessor.start(); @@ -34,7 +34,7 @@ public void testConnectionOk() throws Exception { public void testConnectionProblem() throws Exception { 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(); + DataStore store = new InMemoryDataStore(); try (PollingProcessor pollingProcessor = new PollingProcessor(LDConfig.DEFAULT, requestor, store)) { Future initFuture = pollingProcessor.start(); @@ -82,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(LDConfig.DEFAULT, requestor, new InMemoryDataStore())) { long startTime = System.currentTimeMillis(); Future initFuture = pollingProcessor.start(); try { @@ -99,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(LDConfig.DEFAULT, requestor, new InMemoryDataStore())) { Future initFuture = pollingProcessor.start(); try { initFuture.get(200, TimeUnit.MILLISECONDS); diff --git a/src/test/java/com/launchdarkly/client/RedisFeatureStoreBuilderTest.java b/src/test/java/com/launchdarkly/client/RedisDataStoreBuilderTest.java similarity index 56% rename from src/test/java/com/launchdarkly/client/RedisFeatureStoreBuilderTest.java rename to src/test/java/com/launchdarkly/client/RedisDataStoreBuilderTest.java index 64fb15068..5ba7026c0 100644 --- a/src/test/java/com/launchdarkly/client/RedisFeatureStoreBuilderTest.java +++ b/src/test/java/com/launchdarkly/client/RedisDataStoreBuilderTest.java @@ -12,87 +12,87 @@ import redis.clients.jedis.JedisPoolConfig; import redis.clients.jedis.Protocol; -public class RedisFeatureStoreBuilderTest { +public class RedisDataStoreBuilderTest { @Test public void testDefaultValues() { - RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder(); - assertEquals(RedisFeatureStoreBuilder.DEFAULT_URI, conf.uri); - assertEquals(FeatureStoreCacheConfig.DEFAULT, conf.caching); + RedisDataStoreBuilder conf = new RedisDataStoreBuilder(); + assertEquals(RedisDataStoreBuilder.DEFAULT_URI, conf.uri); + assertEquals(DataStoreCacheConfig.DEFAULT, conf.caching); assertEquals(Protocol.DEFAULT_TIMEOUT, conf.connectTimeout); assertEquals(Protocol.DEFAULT_TIMEOUT, conf.socketTimeout); - assertEquals(RedisFeatureStoreBuilder.DEFAULT_PREFIX, conf.prefix); + assertEquals(RedisDataStoreBuilder.DEFAULT_PREFIX, conf.prefix); assertNull(conf.poolConfig); } @Test public void testConstructorSpecifyingUri() { URI uri = URI.create("redis://host:1234"); - RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder(uri); + RedisDataStoreBuilder conf = new RedisDataStoreBuilder(uri); assertEquals(uri, conf.uri); - assertEquals(FeatureStoreCacheConfig.DEFAULT, conf.caching); + assertEquals(DataStoreCacheConfig.DEFAULT, conf.caching); assertEquals(Protocol.DEFAULT_TIMEOUT, conf.connectTimeout); assertEquals(Protocol.DEFAULT_TIMEOUT, conf.socketTimeout); - assertEquals(RedisFeatureStoreBuilder.DEFAULT_PREFIX, conf.prefix); + assertEquals(RedisDataStoreBuilder.DEFAULT_PREFIX, conf.prefix); assertNull(conf.poolConfig); } @SuppressWarnings("deprecation") @Test public void testDeprecatedUriBuildingConstructor() throws URISyntaxException { - RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder("badscheme", "example", 1234, 100); + RedisDataStoreBuilder conf = new RedisDataStoreBuilder("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); + assertEquals(DataStoreCacheConfig.StaleValuesPolicy.EVICT, conf.caching.getStaleValuesPolicy()); + assertEquals(RedisDataStoreBuilder.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()); + RedisDataStoreBuilder conf = new RedisDataStoreBuilder().refreshStaleValues(true); + assertEquals(DataStoreCacheConfig.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()); + RedisDataStoreBuilder conf = new RedisDataStoreBuilder().refreshStaleValues(true).asyncRefresh(true); + assertEquals(DataStoreCacheConfig.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()); + RedisDataStoreBuilder conf = new RedisDataStoreBuilder().asyncRefresh(true); + assertEquals(DataStoreCacheConfig.StaleValuesPolicy.EVICT, conf.caching.getStaleValuesPolicy()); } @Test public void testPrefixConfigured() throws URISyntaxException { - RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder().prefix("prefix"); + RedisDataStoreBuilder conf = new RedisDataStoreBuilder().prefix("prefix"); assertEquals("prefix", conf.prefix); } @Test public void testConnectTimeoutConfigured() throws URISyntaxException { - RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder().connectTimeout(1, TimeUnit.SECONDS); + RedisDataStoreBuilder conf = new RedisDataStoreBuilder().connectTimeout(1, TimeUnit.SECONDS); assertEquals(1000, conf.connectTimeout); } @Test public void testSocketTimeoutConfigured() throws URISyntaxException { - RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder().socketTimeout(1, TimeUnit.SECONDS); + RedisDataStoreBuilder conf = new RedisDataStoreBuilder().socketTimeout(1, TimeUnit.SECONDS); assertEquals(1000, conf.socketTimeout); } @SuppressWarnings("deprecation") @Test public void testCacheTimeWithUnit() throws URISyntaxException { - RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder().cacheTime(2000, TimeUnit.MILLISECONDS); + RedisDataStoreBuilder conf = new RedisDataStoreBuilder().cacheTime(2000, TimeUnit.MILLISECONDS); assertEquals(2000, conf.caching.getCacheTime()); assertEquals(TimeUnit.MILLISECONDS, conf.caching.getCacheTimeUnit()); } @@ -100,7 +100,7 @@ public void testCacheTimeWithUnit() throws URISyntaxException { @Test public void testPoolConfigConfigured() throws URISyntaxException { JedisPoolConfig poolConfig = new JedisPoolConfig(); - RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder().poolConfig(poolConfig); + RedisDataStoreBuilder conf = new RedisDataStoreBuilder().poolConfig(poolConfig); assertEquals(poolConfig, conf.poolConfig); } } diff --git a/src/test/java/com/launchdarkly/client/RedisFeatureStoreTest.java b/src/test/java/com/launchdarkly/client/RedisDataStoreTest.java similarity index 55% rename from src/test/java/com/launchdarkly/client/RedisFeatureStoreTest.java rename to src/test/java/com/launchdarkly/client/RedisDataStoreTest.java index e58d56388..6729179f0 100644 --- a/src/test/java/com/launchdarkly/client/RedisFeatureStoreTest.java +++ b/src/test/java/com/launchdarkly/client/RedisDataStoreTest.java @@ -1,6 +1,6 @@ package com.launchdarkly.client; -import com.launchdarkly.client.RedisFeatureStore.UpdateListener; +import com.launchdarkly.client.RedisDataStore.UpdateListener; import org.junit.Assume; import org.junit.BeforeClass; @@ -11,11 +11,11 @@ import redis.clients.jedis.Jedis; -public class RedisFeatureStoreTest extends FeatureStoreDatabaseTestBase { +public class RedisDataStoreTest extends DataStoreDatabaseTestBase { private static final URI REDIS_URI = URI.create("redis://localhost:6379"); - public RedisFeatureStoreTest(boolean cached) { + public RedisDataStoreTest(boolean cached) { super(cached); } @@ -26,15 +26,15 @@ public static void maybeSkipDatabaseTests() { } @Override - protected RedisFeatureStore makeStore() { - RedisFeatureStoreBuilder builder = new RedisFeatureStoreBuilder(REDIS_URI); - builder.caching(cached ? FeatureStoreCacheConfig.enabled().ttlSeconds(30) : FeatureStoreCacheConfig.disabled()); + protected RedisDataStore makeStore() { + RedisDataStoreBuilder builder = new RedisDataStoreBuilder(REDIS_URI); + builder.caching(cached ? DataStoreCacheConfig.enabled().ttlSeconds(30) : DataStoreCacheConfig.disabled()); return builder.build(); } @Override - protected RedisFeatureStore makeStoreWithPrefix(String prefix) { - return new RedisFeatureStoreBuilder(REDIS_URI).caching(FeatureStoreCacheConfig.disabled()).prefix(prefix).build(); + protected RedisDataStore makeStoreWithPrefix(String prefix) { + return new RedisDataStoreBuilder(REDIS_URI).caching(DataStoreCacheConfig.disabled()).prefix(prefix).build(); } @Override @@ -45,7 +45,7 @@ protected void clearAllData() { } @Override - protected boolean setUpdateHook(RedisFeatureStore storeUnderTest, final Runnable hook) { + protected boolean setUpdateHook(RedisDataStore storeUnderTest, final Runnable hook) { storeUnderTest.setUpdateListener(new UpdateListener() { @Override public void aboutToUpdate(String baseKey, String itemKey) { diff --git a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java index 58257747d..46a3bb4bd 100644 --- a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java @@ -27,7 +27,7 @@ import static com.launchdarkly.client.ModelBuilders.segmentBuilder; 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.TestUtil.specificDataStore; import static org.easymock.EasyMock.expect; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -55,7 +55,7 @@ public class StreamProcessorTest extends EasyMockSupport { "event: put\n" + "data: {\"data\":{\"flags\":{},\"segments\":{}}}\n\n"; - private InMemoryFeatureStore featureStore; + private InMemoryDataStore dataStore; private LDConfig.Builder configBuilder; private FeatureRequestor mockRequestor; private EventSource mockEventSource; @@ -66,8 +66,8 @@ public class StreamProcessorTest extends EasyMockSupport { @Before public void setup() { - featureStore = new InMemoryFeatureStore(); - configBuilder = new LDConfig.Builder().featureStoreFactory(specificFeatureStore(featureStore)); + dataStore = new InMemoryDataStore(); + configBuilder = new LDConfig.Builder().dataStore(specificDataStore(dataStore)); mockRequestor = createStrictMock(FeatureRequestor.class); mockEventSource = createStrictMock(EventSource.class); } @@ -121,14 +121,14 @@ public void putCausesSegmentToBeStored() throws Exception { @Test public void storeNotInitializedByDefault() throws Exception { createStreamProcessor(SDK_KEY, configBuilder.build()).start(); - assertFalse(featureStore.initialized()); + assertFalse(dataStore.initialized()); } @Test public void putCausesStoreToBeInitialized() throws Exception { createStreamProcessor(SDK_KEY, configBuilder.build()).start(); eventHandler.onMessage("put", emptyPutEvent()); - assertTrue(featureStore.initialized()); + assertTrue(dataStore.initialized()); } @Test @@ -191,28 +191,28 @@ public void patchUpdatesSegment() throws Exception { public void deleteDeletesFeature() throws Exception { createStreamProcessor(SDK_KEY, configBuilder.build()).start(); eventHandler.onMessage("put", emptyPutEvent()); - featureStore.upsert(FEATURES, FEATURE); + dataStore.upsert(FEATURES, FEATURE); String path = "/flags/" + FEATURE1_KEY; MessageEvent event = new MessageEvent("{\"path\":\"" + path + "\",\"version\":" + (FEATURE1_VERSION + 1) + "}"); eventHandler.onMessage("delete", event); - assertNull(featureStore.get(FEATURES, FEATURE1_KEY)); + assertNull(dataStore.get(FEATURES, FEATURE1_KEY)); } @Test public void deleteDeletesSegment() throws Exception { createStreamProcessor(SDK_KEY, configBuilder.build()).start(); eventHandler.onMessage("put", emptyPutEvent()); - featureStore.upsert(SEGMENTS, SEGMENT); + dataStore.upsert(SEGMENTS, SEGMENT); String path = "/segments/" + SEGMENT1_KEY; MessageEvent event = new MessageEvent("{\"path\":\"" + path + "\",\"version\":" + (SEGMENT1_VERSION + 1) + "}"); eventHandler.onMessage("delete", event); - assertNull(featureStore.get(SEGMENTS, SEGMENT1_KEY)); + assertNull(dataStore.get(SEGMENTS, SEGMENT1_KEY)); } @Test @@ -234,7 +234,7 @@ public void indirectPutInitializesStore() throws Exception { eventHandler.onMessage("indirect/put", new MessageEvent("")); - assertTrue(featureStore.initialized()); + assertTrue(dataStore.initialized()); } @Test @@ -246,7 +246,7 @@ public void indirectPutInitializesProcessor() throws Exception { eventHandler.onMessage("indirect/put", new MessageEvent("")); - assertTrue(featureStore.initialized()); + assertTrue(dataStore.initialized()); } @Test @@ -343,7 +343,7 @@ public void httpClientDoesNotAllowSelfSignedCertByDefault() throws Exception { .build(); try (StreamProcessor sp = new StreamProcessor("sdk-key", config, - mockRequestor, featureStore, null)) { + mockRequestor, dataStore, null)) { sp.connectionErrorHandler = errorSink; Future ready = sp.start(); ready.get(); @@ -367,7 +367,7 @@ public void httpClientCanUseCustomTlsConfig() throws Exception { .build(); try (StreamProcessor sp = new StreamProcessor("sdk-key", config, - mockRequestor, featureStore, null)) { + mockRequestor, dataStore, null)) { sp.connectionErrorHandler = errorSink; Future ready = sp.start(); ready.get(); @@ -390,7 +390,7 @@ public void httpClientCanUseProxyConfig() throws Exception { .build(); try (StreamProcessor sp = new StreamProcessor("sdk-key", config, - mockRequestor, featureStore, null)) { + mockRequestor, dataStore, null)) { sp.connectionErrorHandler = errorSink; Future ready = sp.start(); ready.get(); @@ -449,7 +449,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, dataStore, new StubEventSourceCreator()); } private String featureJson(String key, int version) { @@ -471,11 +471,11 @@ private void setupRequestorToReturnAllDataWithFlag(DataModel.FeatureFlag feature } private void assertFeatureInStore(DataModel.FeatureFlag feature) { - assertEquals(feature.getVersion(), featureStore.get(FEATURES, feature.getKey()).getVersion()); + assertEquals(feature.getVersion(), dataStore.get(FEATURES, feature.getKey()).getVersion()); } private void assertSegmentInStore(DataModel.Segment segment) { - assertEquals(segment.getVersion(), featureStore.get(SEGMENTS, segment.getKey()).getVersion()); + assertEquals(segment.getVersion(), dataStore.get(SEGMENTS, segment.getKey()).getVersion()); } private class StubEventSourceCreator implements StreamProcessor.EventSourceCreator { diff --git a/src/test/java/com/launchdarkly/client/TestUtil.java b/src/test/java/com/launchdarkly/client/TestUtil.java index 7ed58fbe1..c9e24b1c8 100644 --- a/src/test/java/com/launchdarkly/client/TestUtil.java +++ b/src/test/java/com/launchdarkly/client/TestUtil.java @@ -8,10 +8,10 @@ import com.launchdarkly.client.events.Event; import com.launchdarkly.client.interfaces.EventProcessor; import com.launchdarkly.client.interfaces.EventProcessorFactory; -import com.launchdarkly.client.interfaces.FeatureStore; -import com.launchdarkly.client.interfaces.FeatureStoreFactory; -import com.launchdarkly.client.interfaces.UpdateProcessor; -import com.launchdarkly.client.interfaces.UpdateProcessorFactory; +import com.launchdarkly.client.interfaces.DataStore; +import com.launchdarkly.client.interfaces.DataStoreFactory; +import com.launchdarkly.client.interfaces.DataSource; +import com.launchdarkly.client.interfaces.DataSourceFactory; import com.launchdarkly.client.interfaces.VersionedData; import com.launchdarkly.client.interfaces.VersionedDataKind; import com.launchdarkly.client.value.LDValue; @@ -33,16 +33,16 @@ @SuppressWarnings("javadoc") public class TestUtil { - public static FeatureStoreFactory specificFeatureStore(final FeatureStore store) { - return new FeatureStoreFactory() { - public FeatureStore createFeatureStore() { + public static DataStoreFactory specificDataStore(final DataStore store) { + return new DataStoreFactory() { + public DataStore createDataStore() { return store; } }; } - public static FeatureStore initedFeatureStore() { - FeatureStore store = new InMemoryFeatureStore(); + public static DataStore initedDataStore() { + DataStore store = new InMemoryDataStore(); store.init(Collections., Map>emptyMap()); return store; } @@ -55,20 +55,20 @@ public EventProcessor createEventProcessor(String sdkKey, LDConfig config) { }; } - public static UpdateProcessorFactory specificUpdateProcessor(final UpdateProcessor up) { - return new UpdateProcessorFactory() { - public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, FeatureStore featureStore) { + public static DataSourceFactory specificDataSource(final DataSource up) { + return new DataSourceFactory() { + public DataSource createDataSource(String sdkKey, LDConfig config, DataStore dataStore) { return up; } }; } - 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 static DataSourceFactory dataSourceWithData(final Map, Map> data) { + return new DataSourceFactory() { + public DataSource createDataSource(String sdkKey, LDConfig config, final DataStore dataStore) { + return new DataSource() { public Future start() { - featureStore.init(data); + dataStore.init(data); return Futures.immediateFuture(null); } @@ -83,8 +83,8 @@ public void close() throws IOException { }; } - public static FeatureStore featureStoreThatThrowsException(final RuntimeException e) { - return new FeatureStore() { + public static DataStore dataStoreThatThrowsException(final RuntimeException e) { + return new DataStore() { @Override public void close() throws IOException { } @@ -114,8 +114,8 @@ public boolean initialized() { }; } - public static UpdateProcessor failedUpdateProcessor() { - return new UpdateProcessor() { + public static DataSource failedDataSource() { + return new DataSource() { @Override public Future start() { return SettableFuture.create(); 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); diff --git a/src/test/java/com/launchdarkly/client/files/FileDataSourceTest.java b/src/test/java/com/launchdarkly/client/files/FileDataSourceTest.java index e6535f1d6..c3567491f 100644 --- a/src/test/java/com/launchdarkly/client/files/FileDataSourceTest.java +++ b/src/test/java/com/launchdarkly/client/files/FileDataSourceTest.java @@ -1,9 +1,9 @@ package com.launchdarkly.client.files; -import com.launchdarkly.client.InMemoryFeatureStore; +import com.launchdarkly.client.InMemoryDataStore; import com.launchdarkly.client.LDConfig; -import com.launchdarkly.client.interfaces.FeatureStore; -import com.launchdarkly.client.interfaces.UpdateProcessor; +import com.launchdarkly.client.interfaces.DataStore; +import com.launchdarkly.client.interfaces.DataSource; import org.junit.Test; @@ -28,7 +28,7 @@ public class FileDataSourceTest { private static final Path badFilePath = Paths.get("no-such-file.json"); - private final FeatureStore store = new InMemoryFeatureStore(); + private final DataStore store = new InMemoryDataStore(); private final LDConfig config = new LDConfig.Builder().build(); private final FileDataSourceFactory factory; @@ -42,7 +42,7 @@ private static FileDataSourceFactory makeFactoryWithFile(Path path) { @Test public void flagsAreNotLoadedUntilStart() throws Exception { - try (UpdateProcessor fp = factory.createUpdateProcessor("", config, store)) { + try (DataSource fp = factory.createDataSource("", config, store)) { assertThat(store.initialized(), equalTo(false)); assertThat(store.all(FEATURES).size(), equalTo(0)); assertThat(store.all(SEGMENTS).size(), equalTo(0)); @@ -51,7 +51,7 @@ public void flagsAreNotLoadedUntilStart() throws Exception { @Test public void flagsAreLoadedOnStart() throws Exception { - try (UpdateProcessor fp = factory.createUpdateProcessor("", config, store)) { + try (DataSource fp = factory.createDataSource("", config, store)) { fp.start(); assertThat(store.initialized(), equalTo(true)); assertThat(store.all(FEATURES).keySet(), equalTo(ALL_FLAG_KEYS)); @@ -61,7 +61,7 @@ public void flagsAreLoadedOnStart() throws Exception { @Test public void startFutureIsCompletedAfterSuccessfulLoad() throws Exception { - try (UpdateProcessor fp = factory.createUpdateProcessor("", config, store)) { + try (DataSource fp = factory.createDataSource("", config, store)) { Future future = fp.start(); assertThat(future.isDone(), equalTo(true)); } @@ -69,7 +69,7 @@ public void startFutureIsCompletedAfterSuccessfulLoad() throws Exception { @Test public void initializedIsTrueAfterSuccessfulLoad() throws Exception { - try (UpdateProcessor fp = factory.createUpdateProcessor("", config, store)) { + try (DataSource fp = factory.createDataSource("", config, store)) { fp.start(); assertThat(fp.initialized(), equalTo(true)); } @@ -78,7 +78,7 @@ public void initializedIsTrueAfterSuccessfulLoad() throws Exception { @Test public void startFutureIsCompletedAfterUnsuccessfulLoad() throws Exception { factory.filePaths(badFilePath); - try (UpdateProcessor fp = factory.createUpdateProcessor("", config, store)) { + try (DataSource fp = factory.createDataSource("", config, store)) { Future future = fp.start(); assertThat(future.isDone(), equalTo(true)); } @@ -87,7 +87,7 @@ public void startFutureIsCompletedAfterUnsuccessfulLoad() throws Exception { @Test public void initializedIsFalseAfterUnsuccessfulLoad() throws Exception { factory.filePaths(badFilePath); - try (UpdateProcessor fp = factory.createUpdateProcessor("", config, store)) { + try (DataSource fp = factory.createDataSource("", config, store)) { fp.start(); assertThat(fp.initialized(), equalTo(false)); } @@ -99,7 +99,7 @@ public void modifiedFileIsNotReloadedIfAutoUpdateIsOff() throws Exception { FileDataSourceFactory factory1 = makeFactoryWithFile(file.toPath()); try { setFileContents(file, getResourceContents("flag-only.json")); - try (UpdateProcessor fp = factory1.createUpdateProcessor("", config, store)) { + try (DataSource fp = factory1.createDataSource("", config, store)) { fp.start(); setFileContents(file, getResourceContents("segment-only.json")); Thread.sleep(400); @@ -121,7 +121,7 @@ public void modifiedFileIsReloadedIfAutoUpdateIsOn() throws Exception { long maxMsToWait = 10000; try { setFileContents(file, getResourceContents("flag-only.json")); // this file has 1 flag - try (UpdateProcessor fp = factory1.createUpdateProcessor("", config, store)) { + try (DataSource fp = factory1.createDataSource("", config, store)) { fp.start(); Thread.sleep(1000); setFileContents(file, getResourceContents("all-properties.json")); // this file has all the flags @@ -147,7 +147,7 @@ public void ifFilesAreBadAtStartTimeAutoUpdateCanStillLoadGoodDataLater() throws FileDataSourceFactory factory1 = makeFactoryWithFile(file.toPath()).autoUpdate(true); long maxMsToWait = 10000; try { - try (UpdateProcessor fp = factory1.createUpdateProcessor("", config, store)) { + try (DataSource fp = factory1.createDataSource("", config, store)) { fp.start(); Thread.sleep(1000); setFileContents(file, getResourceContents("flag-only.json")); // this file has 1 flag diff --git a/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java b/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java index f67d16fea..cfa8e599b 100644 --- a/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java +++ b/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java @@ -1,7 +1,8 @@ package com.launchdarkly.client.utils; import com.google.common.collect.ImmutableMap; -import com.launchdarkly.client.FeatureStoreCacheConfig; +import com.launchdarkly.client.DataStoreCacheConfig; +import com.launchdarkly.client.interfaces.DataStoreCore; import com.launchdarkly.client.interfaces.VersionedData; import com.launchdarkly.client.interfaces.VersionedDataKind; @@ -37,8 +38,8 @@ public static Iterable data() { public CachingStoreWrapperTest(boolean cached) { this.cached = cached; this.core = new MockCore(); - this.wrapper = new CachingStoreWrapper(core, cached ? FeatureStoreCacheConfig.enabled().ttlSeconds(30) : - FeatureStoreCacheConfig.disabled()); + this.wrapper = new CachingStoreWrapper(core, cached ? DataStoreCacheConfig.enabled().ttlSeconds(30) : + DataStoreCacheConfig.disabled()); } @Test @@ -271,7 +272,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, FeatureStoreCacheConfig.enabled().ttlMillis(500))) { + try (CachingStoreWrapper wrapper1 = new CachingStoreWrapper(core, DataStoreCacheConfig.enabled().ttlMillis(500))) { assertThat(wrapper1.initialized(), is(false)); assertThat(core.initedQueryCount, equalTo(1)); @@ -299,7 +300,7 @@ public void initializedCanCacheFalseResult() throws Exception { return outerMap; } - static class MockCore implements FeatureStoreCore { + static class MockCore implements DataStoreCore { Map, Map> data = new HashMap<>(); boolean inited; int initedQueryCount; From 5d57dc6372d70c7d1cfb8d6deb09d67a935d7953 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 17 Dec 2019 21:22:11 -0800 Subject: [PATCH 223/673] remove many deprecated symbols and usages --- .../client/DefaultEventProcessor.java | 18 +- .../client/FeatureFlagsState.java | 21 +- .../com/launchdarkly/client/LDClient.java | 45 +--- .../client/LDClientInterface.java | 78 ------- .../com/launchdarkly/client/LDConfig.java | 20 -- .../java/com/launchdarkly/client/LDUser.java | 176 +++------------ .../launchdarkly/client/RedisDataStore.java | 8 - .../client/RedisDataStoreBuilder.java | 105 +-------- .../launchdarkly/client/TestDataStore.java | 22 +- .../com/launchdarkly/client/events/Event.java | 59 ----- .../launchdarkly/client/files/DataLoader.java | 8 +- .../client/files/FlagFactory.java | 29 +-- .../client/files/FlagFileRep.java | 10 +- .../launchdarkly/client/value/LDValue.java | 75 +------ .../client/value/LDValueArray.java | 12 - .../client/value/LDValueBool.java | 9 - .../client/value/LDValueJsonElement.java | 206 ------------------ .../client/value/LDValueNull.java | 6 - .../client/value/LDValueNumber.java | 7 - .../client/value/LDValueObject.java | 12 - .../client/value/LDValueString.java | 7 - .../client/DefaultEventProcessorTest.java | 49 ++--- .../client/EvaluationReasonTest.java | 6 +- .../client/FeatureFlagsStateTest.java | 7 +- .../client/LDClientEvaluationTest.java | 48 +--- .../client/LDClientEventTest.java | 83 +------ .../client/LDClientOfflineTest.java | 19 +- .../com/launchdarkly/client/LDUserTest.java | 64 ------ .../client/RedisDataStoreBuilderTest.java | 44 +--- .../com/launchdarkly/client/TestUtil.java | 76 +++---- .../files/ClientWithFileDataSourceTest.java | 7 +- .../launchdarkly/client/files/TestData.java | 30 ++- .../client/value/LDValueTest.java | 138 ++---------- 33 files changed, 182 insertions(+), 1322 deletions(-) delete mode 100644 src/main/java/com/launchdarkly/client/value/LDValueJsonElement.java diff --git a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java index 94e746778..ea2a8d6f6 100644 --- a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java @@ -16,7 +16,6 @@ import java.util.ArrayList; import java.util.Date; import java.util.List; -import java.util.Random; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.concurrent.Executors; @@ -190,7 +189,6 @@ static final class EventDispatcher { private final OkHttpClient httpClient; 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); @@ -334,15 +332,13 @@ private void processEvent(Event e, SimpleLRUCache userKeys, Even Event debugEvent = null; if (e instanceof Event.FeatureRequest) { - if (shouldSampleEvent()) { - Event.FeatureRequest fe = (Event.FeatureRequest)e; - addFullEvent = fe.isTrackEvents(); - if (shouldDebugEvent(fe)) { - debugEvent = EventFactory.DEFAULT.newDebugEvent(fe); - } + Event.FeatureRequest fe = (Event.FeatureRequest)e; + addFullEvent = fe.isTrackEvents(); + if (shouldDebugEvent(fe)) { + debugEvent = EventFactory.DEFAULT.newDebugEvent(fe); } } else { - addFullEvent = shouldSampleEvent(); + addFullEvent = true; } // For each user we haven't seen before, we add an index event - unless this is already @@ -377,10 +373,6 @@ private boolean noticeUser(LDUser user, SimpleLRUCache userKeys) return userKeys.put(key, key) != null; } - private boolean shouldSampleEvent() { - return config.samplingInterval <= 0 || random.nextInt(config.samplingInterval) == 0; - } - private boolean shouldDebugEvent(Event.FeatureRequest fe) { Long debugEventsUntilDate = fe.getDebugEventsUntilDate(); if (debugEventsUntilDate != null) { diff --git a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java b/src/main/java/com/launchdarkly/client/FeatureFlagsState.java index 0334f4f66..d0b608d74 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlagsState.java @@ -2,7 +2,6 @@ import com.google.common.base.Objects; import com.google.gson.Gson; -import com.google.gson.JsonElement; import com.google.gson.TypeAdapter; import com.google.gson.annotations.JsonAdapter; import com.google.gson.stream.JsonReader; @@ -27,7 +26,7 @@ public class FeatureFlagsState { private static final Gson gson = new Gson(); - private final Map flagValues; + private final Map flagValues; private final Map flagMetadata; private final boolean valid; @@ -65,7 +64,7 @@ public int hashCode() { } } - private FeatureFlagsState(Map flagValues, + private FeatureFlagsState(Map flagValues, Map flagMetadata, boolean valid) { this.flagValues = Collections.unmodifiableMap(flagValues); this.flagMetadata = Collections.unmodifiableMap(flagMetadata); @@ -86,7 +85,7 @@ public boolean isValid() { * @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) { + public LDValue getFlagValue(String key) { return flagValues.get(key); } @@ -108,7 +107,7 @@ public EvaluationReason getFlagReason(String key) { * 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() { + public Map toValuesMap() { return flagValues; } @@ -129,7 +128,7 @@ public int hashCode() { } static class Builder { - private Map flagValues = new HashMap<>(); + private Map flagValues = new HashMap<>(); private Map flagMetadata = new HashMap<>(); private final boolean saveReasons; private final boolean detailsOnlyForTrackedFlags; @@ -147,7 +146,7 @@ Builder valid(boolean valid) { @SuppressWarnings("deprecation") Builder addFlag(DataModel.FeatureFlag flag, Evaluator.EvalResult eval) { - flagValues.put(flag.getKey(), eval.getValue().asUnsafeJsonElement()); + flagValues.put(flag.getKey(), eval.getValue()); final boolean flagIsTracked = flag.isTrackEvents() || (flag.getDebugEventsUntilDate() != null && flag.getDebugEventsUntilDate() > System.currentTimeMillis()); final boolean wantDetails = !detailsOnlyForTrackedFlags || flagIsTracked; @@ -169,9 +168,9 @@ static class JsonSerialization extends TypeAdapter { @Override public void write(JsonWriter out, FeatureFlagsState state) throws IOException { out.beginObject(); - for (Map.Entry entry: state.flagValues.entrySet()) { + for (Map.Entry entry: state.flagValues.entrySet()) { out.name(entry.getKey()); - gson.toJson(entry.getValue(), out); + gson.toJson(entry.getValue(), LDValue.class, out); } out.name("$flagsState"); gson.toJson(state.flagMetadata, Map.class, out); @@ -183,7 +182,7 @@ 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 { - Map flagValues = new HashMap<>(); + Map flagValues = new HashMap<>(); Map flagMetadata = new HashMap<>(); boolean valid = true; in.beginObject(); @@ -200,7 +199,7 @@ public FeatureFlagsState read(JsonReader in) throws IOException { } else if (name.equals("$valid")) { valid = in.nextBoolean(); } else { - JsonElement value = gson.fromJson(in, JsonElement.class); + LDValue value = gson.fromJson(in, LDValue.class); flagValues.put(name, value); } } diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index ea783ae7b..401364af4 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -1,13 +1,12 @@ package com.launchdarkly.client; -import com.google.gson.JsonElement; import com.launchdarkly.client.events.Event; -import com.launchdarkly.client.interfaces.EventProcessor; -import com.launchdarkly.client.interfaces.EventProcessorFactory; -import com.launchdarkly.client.interfaces.DataStore; -import com.launchdarkly.client.interfaces.DataStoreFactory; import com.launchdarkly.client.interfaces.DataSource; import com.launchdarkly.client.interfaces.DataSourceFactory; +import com.launchdarkly.client.interfaces.DataStore; +import com.launchdarkly.client.interfaces.DataStoreFactory; +import com.launchdarkly.client.interfaces.EventProcessor; +import com.launchdarkly.client.interfaces.EventProcessorFactory; import com.launchdarkly.client.value.LDValue; import org.apache.commons.codec.binary.Hex; @@ -130,18 +129,6 @@ public void trackData(String eventName, LDUser user, LDValue data) { } } - @SuppressWarnings("deprecation") - @Override - 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 trackMetric(String eventName, LDUser user, LDValue data, double metricValue) { if (user == null || user.getKeyAsString() == null) { @@ -165,15 +152,6 @@ private void sendFlagRequestEvent(Event.FeatureRequest event) { NewRelicReflector.annotateTransaction(event.getKey(), String.valueOf(event.getValue())); } - @Override - public Map allFlags(LDUser user) { - FeatureFlagsState state = allFlagsState(user); - if (!state.isValid()) { - return null; - } - return state.toValuesMap(); - } - @Override public FeatureFlagsState allFlagsState(LDUser user, FlagsStateOption... options) { FeatureFlagsState.Builder builder = new FeatureFlagsState.Builder(options); @@ -235,12 +213,6 @@ public String stringVariation(String featureKey, LDUser user, String defaultValu 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, 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); @@ -278,15 +250,6 @@ public EvaluationDetail stringVariationDetail(String featureKey, LDUser result.getVariationIndex(), result.getReason()); } - @SuppressWarnings("deprecation") - @Override - public EvaluationDetail jsonVariationDetail(String featureKey, LDUser user, JsonElement defaultValue) { - Evaluator.EvalResult result = evaluateInternal(featureKey, user, LDValue.unsafeFromJsonElement(defaultValue), false, - EventFactory.DEFAULT_WITH_REASONS); - return EvaluationDetail.fromValue(result.getValue().asUnsafeJsonElement(), - result.getVariationIndex(), result.getReason()); - } - @Override public EvaluationDetail jsonValueVariationDetail(String featureKey, LDUser user, LDValue defaultValue) { Evaluator.EvalResult result = evaluateInternal(featureKey, user, defaultValue == null ? LDValue.ofNull() : defaultValue, false, EventFactory.DEFAULT_WITH_REASONS); diff --git a/src/main/java/com/launchdarkly/client/LDClientInterface.java b/src/main/java/com/launchdarkly/client/LDClientInterface.java index 80db0168b..533b44b88 100644 --- a/src/main/java/com/launchdarkly/client/LDClientInterface.java +++ b/src/main/java/com/launchdarkly/client/LDClientInterface.java @@ -1,11 +1,9 @@ package com.launchdarkly.client; -import com.google.gson.JsonElement; import com.launchdarkly.client.value.LDValue; import java.io.Closeable; import java.io.IOException; -import java.util.Map; /** * This interface defines the public methods of {@link LDClient}. @@ -27,17 +25,6 @@ public interface LDClientInterface extends Closeable { */ 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 - * @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. * @@ -48,26 +35,6 @@ public interface LDClientInterface extends Closeable { */ void trackData(String eventName, LDUser user, LDValue data); - /** - * 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. - * - * @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)}. - */ - @Deprecated - 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. *

    @@ -94,23 +61,6 @@ public interface LDClientInterface extends Closeable { */ void identify(LDUser user); - /** - * Returns a map from feature flag keys to {@code JsonElement} feature flag values for a given user. - * If the result of a flag's evaluation would have returned the default variation, it will have a null entry - * in the map. If the client is offline, has not been initialized, or a null user or user with null/empty user key a {@code null} map will be returned. - * This method will 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. - * - * @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 also metadata that can be used on the front end. This method does not send analytics events @@ -168,19 +118,6 @@ public interface LDClientInterface extends Closeable { */ String stringVariation(String featureKey, LDUser user, String defaultValue); - /** - * Calculates the {@link JsonElement} 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 - * @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. * @@ -245,21 +182,6 @@ public interface LDClientInterface extends Closeable { */ 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 - * 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 - * @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. * diff --git a/src/main/java/com/launchdarkly/client/LDConfig.java b/src/main/java/com/launchdarkly/client/LDConfig.java index 2f42b49d4..7a3b9e4da 100644 --- a/src/main/java/com/launchdarkly/client/LDConfig.java +++ b/src/main/java/com/launchdarkly/client/LDConfig.java @@ -46,7 +46,6 @@ public final class LDConfig { private static final int DEFAULT_FLUSH_INTERVAL_SECONDS = 5; private static final long MIN_POLLING_INTERVAL_MILLIS = 30000L; 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; @@ -71,7 +70,6 @@ public final class LDConfig { final boolean sendEvents; final long pollingIntervalMillis; final long startWaitMillis; - final int samplingInterval; final long reconnectTimeMs; final int userKeysCapacity; final int userKeysFlushInterval; @@ -106,7 +104,6 @@ protected LDConfig(Builder builder) { this.pollingIntervalMillis = builder.pollingIntervalMillis; } this.startWaitMillis = builder.startWaitMillis; - this.samplingInterval = builder.samplingInterval; this.reconnectTimeMs = builder.reconnectTimeMillis; this.userKeysCapacity = builder.userKeysCapacity; this.userKeysFlushInterval = builder.userKeysFlushInterval; @@ -162,7 +159,6 @@ public static class Builder { private EventProcessorFactory eventProcessorFactory = Components.defaultEventProcessor(); private DataSourceFactory dataSourceFactory = Components.defaultDataSource(); 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 int userKeysCapacity = DEFAULT_USER_KEYS_CAPACITY; @@ -481,22 +477,6 @@ public Builder startWaitMillis(long startWaitMillis) { return this; } - /** - * Enable event sampling. When set to the default of zero, sampling is disabled and all events - * are sent back to LaunchDarkly. When set to greater than zero, there is a 1 in - * samplingInterval chance events will be will be sent. - *

    Example: if you want 5% sampling rate, set samplingInterval to 20. - * - * @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; - } - /** * 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 diff --git a/src/main/java/com/launchdarkly/client/LDUser.java b/src/main/java/com/launchdarkly/client/LDUser.java index 4458679b1..152475c0f 100644 --- a/src/main/java/com/launchdarkly/client/LDUser.java +++ b/src/main/java/com/launchdarkly/client/LDUser.java @@ -3,9 +3,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; import com.google.gson.TypeAdapter; import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonWriter; @@ -17,7 +14,6 @@ import java.io.IOException; import java.util.HashMap; import java.util.HashSet; -import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; @@ -557,11 +553,11 @@ public Builder privateEmail(String email) { * @return the builder */ public Builder custom(String k, String v) { - return custom(k, v == null ? null : new JsonPrimitive(v)); + return custom(k, LDValue.of(v)); } /** - * Adds a {@link java.lang.Number}-valued custom attribute. When set to one of the + * Adds an integer-valued custom attribute. When set to one of the * built-in * user attribute keys, this custom attribute will be ignored. * @@ -569,12 +565,25 @@ public Builder custom(String k, String v) { * @param n the value for the custom attribute * @return the builder */ - public Builder custom(String k, Number n) { - return custom(k, n == null ? null : new JsonPrimitive(n)); + public Builder custom(String k, int n) { + return custom(k, LDValue.of(n)); } /** - * Add a {@link java.lang.Boolean}-valued custom attribute. When set to one of the + * Adds a double-precision numeric custom attribute. 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 n the value for the custom attribute + * @return the builder + */ + public Builder custom(String k, double n) { + return custom(k, LDValue.of(n)); + } + + /** + * Add a boolean-valued custom attribute. When set to one of the * built-in * user attribute keys, this custom attribute will be ignored. * @@ -582,8 +591,8 @@ public Builder custom(String k, Number n) { * @param b the value for the custom attribute * @return the builder */ - public Builder custom(String k, Boolean b) { - return custom(k, b == null ? null : new JsonPrimitive(b)); + public Builder custom(String k, boolean b) { + return custom(k, LDValue.of(b)); } /** @@ -607,78 +616,6 @@ public Builder custom(String k, LDValue 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 - * user attribute keys, this custom attribute will be ignored. - * - * @param k the key for the list - * @param vs the values for the attribute - * @return the builder - */ - public Builder customString(String k, List vs) { - JsonArray array = new JsonArray(); - for (String v : vs) { - if (v != null) { - array.add(new JsonPrimitive(v)); - } - } - return custom(k, array); - } - - /** - * Add a list of {@link java.lang.Number}-valued custom attributes. When set to one of the - * built-in - * user attribute keys, this custom attribute will be ignored. - * - * @param k the key for the list - * @param vs the values for the attribute - * @return the builder - */ - public Builder customNumber(String k, List vs) { - JsonArray array = new JsonArray(); - for (Number v : vs) { - if (v != null) { - array.add(new JsonPrimitive(v)); - } - } - return custom(k, array); - } - - /** - * Add a custom attribute with a list of arbitrary JSON values. When set to one of the - * built-in - * user attribute keys, this custom attribute will be ignored. - * - * @param k the key for the list - * @param vs the values for the attribute - * @return the builder - */ - public Builder customValues(String k, List vs) { - JsonArray array = new JsonArray(); - for (JsonElement v : vs) { - if (v != null) { - array.add(v); - } - } - return custom(k, array); - } - /** * Add a {@link java.lang.String}-valued custom attribute that will not be sent back to LaunchDarkly. * When set to one of the @@ -695,7 +632,7 @@ public Builder privateCustom(String k, String v) { } /** - * Add a {@link java.lang.Number}-valued custom attribute that will not be sent back to LaunchDarkly. + * Add an int-valued custom attribute 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. @@ -704,42 +641,41 @@ public Builder privateCustom(String k, String v) { * @param n the value for the custom attribute * @return the builder */ - public Builder privateCustom(String k, Number n) { + public Builder privateCustom(String k, int n) { addPrivate(k); return custom(k, n); } /** - * Add a {@link java.lang.Boolean}-valued custom attribute that will not be sent back to LaunchDarkly. + * Add a double-precision numeric custom attribute 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 b the value for the custom attribute + * @param n the value for the custom attribute * @return the builder */ - public Builder privateCustom(String k, Boolean b) { + public Builder privateCustom(String k, double n) { addPrivate(k); - return custom(k, b); + return custom(k, n); } /** - * Add a custom attribute of any JSON type, that will not be sent back to LaunchDarkly. + * Add a boolean-valued custom attribute 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 + * @param b the value for the custom attribute * @return the builder - * @since 4.8.0 */ - public Builder privateCustom(String k, LDValue v) { + public Builder privateCustom(String k, boolean b) { addPrivate(k); - return custom(k, v); + return custom(k, b); } - + /** * Add a custom attribute of any JSON type, that will not be sent back to LaunchDarkly. * When set to one of the @@ -749,59 +685,13 @@ public Builder privateCustom(String k, LDValue v) { * @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)}. + * @since 4.8.0 */ - @Deprecated - public Builder privateCustom(String k, JsonElement v) { + public Builder privateCustom(String k, LDValue v) { addPrivate(k); return custom(k, v); } - /** - * Add a list of {@link java.lang.String}-valued custom attributes. When set to one of the - * - * built-in user attribute keys, this custom attribute will be ignored. The custom attribute value will not be sent - * back to LaunchDarkly in analytics events. - * - * @param k the key for the list. When set to one of the built-in user attribute keys, this custom attribute will be ignored. - * @param vs the values for the attribute - * @return the builder - */ - public Builder privateCustomString(String k, List vs) { - addPrivate(k); - return customString(k, vs); - } - - /** - * Add a list of {@link java.lang.Integer}-valued custom attributes. When set to one of the - * - * built-in user attribute keys, this custom attribute will be ignored. The custom attribute value will not be sent - * back to LaunchDarkly in analytics events. - * - * @param k the key for the list. When set to one of the built-in user attribute keys, this custom attribute will be ignored. - * @param vs the values for the attribute - * @return the builder - */ - public Builder privateCustomNumber(String k, List vs) { - addPrivate(k); - return customNumber(k, vs); - } - - /** - * Add a custom attribute with a list of arbitrary JSON values. When set to one of the - * - * built-in user attribute keys, this custom attribute will be ignored. The custom attribute value will not be sent - * back to LaunchDarkly in analytics events. - * - * @param k the key for the list - * @param vs the values for the attribute - * @return the builder - */ - public Builder privateCustomValues(String k, List vs) { - addPrivate(k); - return customValues(k, vs); - } - private void checkCustomAttribute(String key) { for (UserAttribute a : UserAttribute.values()) { if (a.name().equals(key)) { diff --git a/src/main/java/com/launchdarkly/client/RedisDataStore.java b/src/main/java/com/launchdarkly/client/RedisDataStore.java index a2497b379..47060d826 100644 --- a/src/main/java/com/launchdarkly/client/RedisDataStore.java +++ b/src/main/java/com/launchdarkly/client/RedisDataStore.java @@ -129,14 +129,6 @@ protected RedisDataStore(RedisDataStoreBuilder builder) { .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#redisDataStore()} instead. - */ - public RedisDataStore() { - this(new RedisDataStoreBuilder().caching(DataStoreCacheConfig.disabled())); - } - static class Core implements DataStoreCore { private final JedisPool pool; private final String prefix; diff --git a/src/main/java/com/launchdarkly/client/RedisDataStoreBuilder.java b/src/main/java/com/launchdarkly/client/RedisDataStoreBuilder.java index e09bdae70..a36b7b96d 100644 --- a/src/main/java/com/launchdarkly/client/RedisDataStoreBuilder.java +++ b/src/main/java/com/launchdarkly/client/RedisDataStoreBuilder.java @@ -1,14 +1,13 @@ package com.launchdarkly.client; -import redis.clients.jedis.JedisPoolConfig; -import redis.clients.jedis.Protocol; - import com.launchdarkly.client.interfaces.DataStoreFactory; import java.net.URI; -import java.net.URISyntaxException; import java.util.concurrent.TimeUnit; +import redis.clients.jedis.JedisPoolConfig; +import redis.clients.jedis.Protocol; + /** * A builder for configuring the Redis-based persistent data store. * @@ -35,13 +34,6 @@ public final class RedisDataStoreBuilder implements DataStoreFactory { */ public static final String DEFAULT_PREFIX = "launchdarkly"; - /** - * The default value for {@link #cacheTime(long, TimeUnit)} (in seconds). - * @deprecated Use {@link DataStoreCacheConfig#DEFAULT}. - * @since 4.0.0 - */ - public static final long DEFAULT_CACHE_TIME_SECONDS = DataStoreCacheConfig.DEFAULT_TIME_SECONDS; - final URI uri; String prefix = DEFAULT_PREFIX; int connectTimeout = Protocol.DEFAULT_TIMEOUT; @@ -50,11 +42,9 @@ public final class RedisDataStoreBuilder implements DataStoreFactory { String password = null; boolean tls = false; DataStoreCacheConfig caching = DataStoreCacheConfig.DEFAULT; - boolean refreshStaleValues = false; // this and asyncRefresh are redundant with DataStoreCacheConfig, but are used by deprecated setters - boolean asyncRefresh = false; JedisPoolConfig poolConfig = null; - // These constructors are called only from Implementations + // These constructors are called only from Components RedisDataStoreBuilder() { this.uri = DEFAULT_URI; } @@ -63,33 +53,6 @@ public final class RedisDataStoreBuilder implements DataStoreFactory { this.uri = uri; } - /** - * The constructor accepts the mandatory fields that must be specified at a minimum to construct a {@link com.launchdarkly.client.RedisDataStore}. - * - * @param uri the uri of the Redis resource to connect to. - * @param cacheTimeSecs the cache time in seconds. See {@link RedisDataStoreBuilder#cacheTime(long, TimeUnit)} for more information. - * @deprecated Please use {@link Components#redisDataStore(java.net.URI)}. - */ - public RedisDataStoreBuilder(URI uri, long cacheTimeSecs) { - this.uri = uri; - this.cacheTime(cacheTimeSecs, TimeUnit.SECONDS); - } - - /** - * The constructor accepts the mandatory fields that must be specified at a minimum to construct a {@link com.launchdarkly.client.RedisDataStore}. - * - * @param scheme the URI scheme to use - * @param host the hostname to connect to - * @param port the port to connect to - * @param cacheTimeSecs the cache time in seconds. See {@link RedisDataStoreBuilder#cacheTime(long, TimeUnit)} for more information. - * @throws URISyntaxException if the URI is not valid - * @deprecated Please use {@link Components#redisDataStore(java.net.URI)}. - */ - public RedisDataStoreBuilder(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); - } - /** * Specifies the database number to use. *

    @@ -154,50 +117,6 @@ public RedisDataStoreBuilder caching(DataStoreCacheConfig caching) { return this; } - /** - * Deprecated method for setting the cache expiration policy to {@link DataStoreCacheConfig.StaleValuesPolicy#REFRESH} - * or {@link DataStoreCacheConfig.StaleValuesPolicy#REFRESH_ASYNC}. - * - * @param enabled turns on lazy refresh of cached values - * @return the builder - * - * @deprecated Use {@link #caching(DataStoreCacheConfig)} and - * {@link DataStoreCacheConfig#staleValuesPolicy(com.launchdarkly.client.DataStoreCacheConfig.StaleValuesPolicy)}. - */ - public RedisDataStoreBuilder refreshStaleValues(boolean enabled) { - this.refreshStaleValues = enabled; - updateCachingStaleValuesPolicy(); - return this; - } - - /** - * Deprecated method for setting the cache expiration policy to {@link DataStoreCacheConfig.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(DataStoreCacheConfig)} and - * {@link DataStoreCacheConfig#staleValuesPolicy(com.launchdarkly.client.DataStoreCacheConfig.StaleValuesPolicy)}. - */ - public RedisDataStoreBuilder 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 ? - DataStoreCacheConfig.StaleValuesPolicy.REFRESH_ASYNC : - DataStoreCacheConfig.StaleValuesPolicy.REFRESH); - } else { - this.caching = this.caching.staleValuesPolicy(DataStoreCacheConfig.StaleValuesPolicy.EVICT); - } - } - /** * Optionally configures the namespace prefix for all keys stored in Redis. * @@ -209,22 +128,6 @@ public RedisDataStoreBuilder prefix(String prefix) { return this; } - /** - * Deprecated method for enabling local caching and setting the cache TTL. Local caching is enabled - * by default; see {@link DataStoreCacheConfig#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(DataStoreCacheConfig)} and {@link DataStoreCacheConfig#ttl(long, TimeUnit)}. - */ - public RedisDataStoreBuilder cacheTime(long cacheTime, TimeUnit timeUnit) { - this.caching = this.caching.ttl(cacheTime, timeUnit) - .staleValuesPolicy(this.caching.getStaleValuesPolicy()); - return this; - } - /** * Optional override if you wish to specify your own configuration to the underlying Jedis pool. * diff --git a/src/main/java/com/launchdarkly/client/TestDataStore.java b/src/main/java/com/launchdarkly/client/TestDataStore.java index 09e613ebf..1e220f747 100644 --- a/src/main/java/com/launchdarkly/client/TestDataStore.java +++ b/src/main/java/com/launchdarkly/client/TestDataStore.java @@ -1,7 +1,5 @@ package com.launchdarkly.client; -import com.google.gson.JsonElement; -import com.google.gson.JsonPrimitive; import com.launchdarkly.client.interfaces.VersionedData; import com.launchdarkly.client.interfaces.VersionedDataKind; import com.launchdarkly.client.value.LDValue; @@ -34,8 +32,8 @@ public class TestDataStore extends InMemoryDataStore { * @param value the new value of the feature flag * @return the feature flag */ - public DataModel.FeatureFlag setBooleanValue(String key, Boolean value) { - return setJsonValue(key, value == null ? null : new JsonPrimitive(value.booleanValue())); + public DataModel.FeatureFlag setBooleanValue(String key, boolean value) { + return setJsonValue(key, LDValue.of(value)); } /** @@ -66,8 +64,8 @@ public DataModel.FeatureFlag setFeatureFalse(String key) { * @param value the new value of the flag * @return the feature flag */ - public DataModel.FeatureFlag setIntegerValue(String key, Integer value) { - return setJsonValue(key, new JsonPrimitive(value)); + public DataModel.FeatureFlag setIntegerValue(String key, int value) { + return setJsonValue(key, LDValue.of(value)); } /** @@ -76,8 +74,8 @@ public DataModel.FeatureFlag setIntegerValue(String key, Integer value) { * @param value the new value of the flag * @return the feature flag */ - public DataModel.FeatureFlag setDoubleValue(String key, Double value) { - return setJsonValue(key, new JsonPrimitive(value)); + public DataModel.FeatureFlag setDoubleValue(String key, double value) { + return setJsonValue(key, LDValue.of(value)); } /** @@ -87,16 +85,16 @@ public DataModel.FeatureFlag setDoubleValue(String key, Double value) { * @return the feature flag */ public DataModel.FeatureFlag setStringValue(String key, String value) { - return setJsonValue(key, new JsonPrimitive(value)); + return setJsonValue(key, LDValue.of(value)); } /** - * Sets the value of a JsonElement multivariate feature flag, for all users. + * Sets the value of a 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 DataModel.FeatureFlag setJsonValue(String key, JsonElement value) { + public DataModel.FeatureFlag setJsonValue(String key, LDValue value) { DataModel.FeatureFlag newFeature = new DataModel.FeatureFlag(key, version.incrementAndGet(), false, @@ -106,7 +104,7 @@ public DataModel.FeatureFlag setJsonValue(String key, JsonElement value) { null, null, 0, - Arrays.asList(LDValue.fromJsonElement(value)), + Arrays.asList(value), false, false, false, diff --git a/src/main/java/com/launchdarkly/client/events/Event.java b/src/main/java/com/launchdarkly/client/events/Event.java index 921cb94f7..b36e1db48 100644 --- a/src/main/java/com/launchdarkly/client/events/Event.java +++ b/src/main/java/com/launchdarkly/client/events/Event.java @@ -1,6 +1,5 @@ package com.launchdarkly.client.events; -import com.google.gson.JsonElement; import com.launchdarkly.client.EvaluationReason; import com.launchdarkly.client.LDClientInterface; import com.launchdarkly.client.LDUser; @@ -63,19 +62,6 @@ 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); - } - /** * The custom event key. * @return the event key @@ -175,51 +161,6 @@ 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) { - this(timestamp, key, user, version, variation, LDValue.unsafeFromJsonElement(value), LDValue.unsafeFromJsonElement(defaultVal), - 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) { - this(timestamp, key, user, version, variation, LDValue.unsafeFromJsonElement(value), LDValue.unsafeFromJsonElement(defaultVal), - reason, prereqOf, trackEvents, debugEventsUntilDate, debug); - } - public String getKey() { return key; } diff --git a/src/main/java/com/launchdarkly/client/files/DataLoader.java b/src/main/java/com/launchdarkly/client/files/DataLoader.java index 2c1603e06..113df4934 100644 --- a/src/main/java/com/launchdarkly/client/files/DataLoader.java +++ b/src/main/java/com/launchdarkly/client/files/DataLoader.java @@ -1,7 +1,7 @@ package com.launchdarkly.client.files; -import com.google.gson.JsonElement; import com.launchdarkly.client.DataModel; +import com.launchdarkly.client.value.LDValue; import java.io.ByteArrayInputStream; import java.io.IOException; @@ -34,17 +34,17 @@ public void load(DataBuilder builder) throws DataLoaderException FlagFileParser parser = FlagFileParser.selectForContent(data); FlagFileRep fileContents = parser.parse(new ByteArrayInputStream(data)); if (fileContents.flags != null) { - for (Map.Entry e: fileContents.flags.entrySet()) { + for (Map.Entry e: fileContents.flags.entrySet()) { builder.add(DataModel.DataKinds.FEATURES, FlagFactory.flagFromJson(e.getValue())); } } if (fileContents.flagValues != null) { - for (Map.Entry e: fileContents.flagValues.entrySet()) { + for (Map.Entry e: fileContents.flagValues.entrySet()) { builder.add(DataModel.DataKinds.FEATURES, FlagFactory.flagWithValue(e.getKey(), e.getValue())); } } if (fileContents.segments != null) { - for (Map.Entry e: fileContents.segments.entrySet()) { + for (Map.Entry e: fileContents.segments.entrySet()) { builder.add(DataModel.DataKinds.SEGMENTS, FlagFactory.segmentFromJson(e.getValue())); } } diff --git a/src/main/java/com/launchdarkly/client/files/FlagFactory.java b/src/main/java/com/launchdarkly/client/files/FlagFactory.java index 225f29c2c..c7713eece 100644 --- a/src/main/java/com/launchdarkly/client/files/FlagFactory.java +++ b/src/main/java/com/launchdarkly/client/files/FlagFactory.java @@ -1,10 +1,8 @@ package com.launchdarkly.client.files; -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; import com.launchdarkly.client.DataModel; import com.launchdarkly.client.interfaces.VersionedData; +import com.launchdarkly.client.value.LDValue; /** * Creates flag or segment objects from raw JSON. @@ -19,26 +17,23 @@ public static VersionedData flagFromJson(String jsonString) { return DataModel.DataKinds.FEATURES.deserialize(jsonString); } - public static VersionedData flagFromJson(JsonElement jsonTree) { - return flagFromJson(jsonTree.toString()); + public static VersionedData flagFromJson(LDValue jsonTree) { + return flagFromJson(jsonTree.toJsonString()); } /** * 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 jsonValue) { - JsonObject o = new JsonObject(); - o.addProperty("key", key); - o.addProperty("on", true); - JsonArray vs = new JsonArray(); - vs.add(jsonValue); - o.add("variations", vs); + public static VersionedData flagWithValue(String key, LDValue jsonValue) { + LDValue o = LDValue.buildObject() + .put("key", key) + .put("on", true) + .put("variations", LDValue.buildArray().add(jsonValue).build()) + .put("fallthrough", LDValue.buildObject().put("variation", 0).build()) + .build(); // 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); } @@ -46,7 +41,7 @@ public static VersionedData segmentFromJson(String jsonString) { return DataModel.DataKinds.SEGMENTS.deserialize(jsonString); } - public static VersionedData segmentFromJson(JsonElement jsonTree) { - return segmentFromJson(jsonTree.toString()); + public static VersionedData segmentFromJson(LDValue jsonTree) { + return segmentFromJson(jsonTree.toJsonString()); } } diff --git a/src/main/java/com/launchdarkly/client/files/FlagFileRep.java b/src/main/java/com/launchdarkly/client/files/FlagFileRep.java index db04fb51b..a54e3b917 100644 --- a/src/main/java/com/launchdarkly/client/files/FlagFileRep.java +++ b/src/main/java/com/launchdarkly/client/files/FlagFileRep.java @@ -1,6 +1,6 @@ package com.launchdarkly.client.files; -import com.google.gson.JsonElement; +import com.launchdarkly.client.value.LDValue; import java.util.Map; @@ -9,13 +9,13 @@ * parse the flags or segments at this level; that will be done by {@link FlagFactory}. */ final class FlagFileRep { - Map flags; - Map flagValues; - Map segments; + Map flags; + Map flagValues; + Map segments; FlagFileRep() {} - FlagFileRep(Map flags, Map flagValues, Map segments) { + 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/value/LDValue.java b/src/main/java/com/launchdarkly/client/value/LDValue.java index 996e7b41d..e91e71c3a 100644 --- a/src/main/java/com/launchdarkly/client/value/LDValue.java +++ b/src/main/java/com/launchdarkly/client/value/LDValue.java @@ -4,7 +4,6 @@ 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; import com.google.gson.stream.JsonWriter; import com.launchdarkly.client.LDClientInterface; @@ -30,9 +29,6 @@ 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. * @@ -147,33 +143,12 @@ public static ObjectBuilder buildObject() { } /** - * 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)}. + * Parses an LDValue from a JSON representation. + * @param json a JSON string + * @return an LDValue */ - @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); + public static LDValue parse(String json) { + return gson.fromJson(json, LDValue.class); } /** @@ -366,43 +341,6 @@ 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()}. - */ - @Deprecated - 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) { @@ -414,9 +352,6 @@ 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) { diff --git a/src/main/java/com/launchdarkly/client/value/LDValueArray.java b/src/main/java/com/launchdarkly/client/value/LDValueArray.java index 250863121..cb798eff7 100644 --- a/src/main/java/com/launchdarkly/client/value/LDValueArray.java +++ b/src/main/java/com/launchdarkly/client/value/LDValueArray.java @@ -1,8 +1,6 @@ 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; @@ -51,14 +49,4 @@ void write(JsonWriter writer) throws IOException { } 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 index 321361353..32ed560d4 100644 --- a/src/main/java/com/launchdarkly/client/value/LDValueBool.java +++ b/src/main/java/com/launchdarkly/client/value/LDValueBool.java @@ -1,7 +1,5 @@ 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; @@ -11,8 +9,6 @@ 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; @@ -42,9 +38,4 @@ public String toJsonString() { 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 deleted file mode 100644 index 34f0cb8bb..000000000 --- a/src/main/java/com/launchdarkly/client/value/LDValueJsonElement.java +++ /dev/null @@ -1,206 +0,0 @@ -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 long longValue() { - return type == LDValueType.NUMBER ? (long)value.getAsDouble() : 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 boolean isString() { - return type == LDValueType.STRING; - } - - @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 index 00db72c34..21dd33e27 100644 --- a/src/main/java/com/launchdarkly/client/value/LDValueNull.java +++ b/src/main/java/com/launchdarkly/client/value/LDValueNull.java @@ -1,6 +1,5 @@ package com.launchdarkly.client.value; -import com.google.gson.JsonElement; import com.google.gson.annotations.JsonAdapter; import com.google.gson.stream.JsonWriter; @@ -27,9 +26,4 @@ public String toJsonString() { 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 index 6a601c3f0..bfefc6f53 100644 --- a/src/main/java/com/launchdarkly/client/value/LDValueNumber.java +++ b/src/main/java/com/launchdarkly/client/value/LDValueNumber.java @@ -1,7 +1,5 @@ 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; @@ -67,9 +65,4 @@ void write(JsonWriter writer) throws IOException { 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 index eaceb5a7a..30670720d 100644 --- a/src/main/java/com/launchdarkly/client/value/LDValueObject.java +++ b/src/main/java/com/launchdarkly/client/value/LDValueObject.java @@ -1,8 +1,6 @@ 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; @@ -56,14 +54,4 @@ void write(JsonWriter writer) throws IOException { } 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 index b2ad2c789..8f6be51b3 100644 --- a/src/main/java/com/launchdarkly/client/value/LDValueString.java +++ b/src/main/java/com/launchdarkly/client/value/LDValueString.java @@ -1,7 +1,5 @@ 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; @@ -38,9 +36,4 @@ public String stringValue() { 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/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java index 355988273..463ea67c1 100644 --- a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java @@ -1,8 +1,6 @@ package com.launchdarkly.client; import com.google.gson.Gson; -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; import com.launchdarkly.client.events.Event; import com.launchdarkly.client.value.LDValue; @@ -38,10 +36,9 @@ public class DefaultEventProcessorTest { private static final String SDK_KEY = "SDK_KEY"; private static final LDUser user = new LDUser.Builder("userkey").name("Red").build(); private static final Gson gson = new Gson(); - private static final JsonElement userJson = - gson.fromJson("{\"key\":\"userkey\",\"name\":\"Red\"}", JsonElement.class); - private static final JsonElement filteredUserJson = - gson.fromJson("{\"key\":\"userkey\",\"privateAttrs\":[\"name\"]}", JsonElement.class); + private static final LDValue userJson = LDValue.buildObject().put("key", "userkey").put("name", "Red").build(); + private static final LDValue filteredUserJson = LDValue.buildObject().put("key", "userkey") + .put("privateAttrs", LDValue.buildArray().add("name").build()).build(); 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 @@ -609,13 +606,13 @@ private MockResponse addDateHeader(MockResponse response, long timestamp) { return response.addHeader("Date", httpDateFormat.format(new Date(timestamp))); } - private JsonArray getEventsFromLastRequest(MockWebServer server) throws Exception { + private Iterable getEventsFromLastRequest(MockWebServer server) throws Exception { RecordedRequest req = server.takeRequest(0, TimeUnit.MILLISECONDS); assertNotNull(req); - return gson.fromJson(req.getBody().readUtf8(), JsonElement.class).getAsJsonArray(); + return gson.fromJson(req.getBody().readUtf8(), LDValue.class).values(); } - private Matcher isIdentifyEvent(Event sourceEvent, JsonElement user) { + private Matcher isIdentifyEvent(Event sourceEvent, LDValue user) { return allOf( hasJsonProperty("kind", "identify"), hasJsonProperty("creationDate", (double)sourceEvent.getCreationDate()), @@ -623,7 +620,7 @@ private Matcher isIdentifyEvent(Event sourceEvent, JsonElement user ); } - private Matcher isIndexEvent(Event sourceEvent, JsonElement user) { + private Matcher isIndexEvent(Event sourceEvent, LDValue user) { return allOf( hasJsonProperty("kind", "index"), hasJsonProperty("creationDate", (double)sourceEvent.getCreationDate()), @@ -631,12 +628,12 @@ private Matcher isIndexEvent(Event sourceEvent, JsonElement user) { ); } - private Matcher isFeatureEvent(Event.FeatureRequest sourceEvent, DataModel.FeatureFlag flag, boolean debug, JsonElement inlineUser) { + private Matcher isFeatureEvent(Event.FeatureRequest sourceEvent, DataModel.FeatureFlag flag, boolean debug, LDValue inlineUser) { return isFeatureEvent(sourceEvent, flag, debug, inlineUser, null); } @SuppressWarnings("unchecked") - private Matcher isFeatureEvent(Event.FeatureRequest sourceEvent, DataModel.FeatureFlag flag, boolean debug, JsonElement inlineUser, + private Matcher isFeatureEvent(Event.FeatureRequest sourceEvent, DataModel.FeatureFlag flag, boolean debug, LDValue inlineUser, EvaluationReason reason) { return allOf( hasJsonProperty("kind", debug ? "debug" : "feature"), @@ -645,36 +642,30 @@ private Matcher isFeatureEvent(Event.FeatureRequest sourceEvent, Da hasJsonProperty("version", (double)flag.getVersion()), hasJsonProperty("variation", sourceEvent.getVariation()), hasJsonProperty("value", sourceEvent.getValue()), - (inlineUser != null) ? hasJsonProperty("userKey", nullValue(JsonElement.class)) : - hasJsonProperty("userKey", sourceEvent.getUser().getKeyAsString()), - (inlineUser != null) ? hasJsonProperty("user", inlineUser) : - hasJsonProperty("user", nullValue(JsonElement.class)), - (reason == null) ? hasJsonProperty("reason", nullValue(JsonElement.class)) : - hasJsonProperty("reason", gson.toJsonTree(reason)) + hasJsonProperty("userKey", inlineUser == null ? LDValue.of(sourceEvent.getUser().getKeyAsString()) : LDValue.ofNull()), + hasJsonProperty("user", inlineUser == null ? LDValue.ofNull() : inlineUser), + hasJsonProperty("reason", reason == null ? LDValue.ofNull() : LDValue.parse(gson.toJson(reason))) ); } @SuppressWarnings("unchecked") - private Matcher isCustomEvent(Event.Custom sourceEvent, JsonElement inlineUser) { + private Matcher isCustomEvent(Event.Custom sourceEvent, LDValue inlineUser) { return allOf( hasJsonProperty("kind", "custom"), hasJsonProperty("creationDate", (double)sourceEvent.getCreationDate()), hasJsonProperty("key", "eventkey"), - (inlineUser != null) ? hasJsonProperty("userKey", nullValue(JsonElement.class)) : - hasJsonProperty("userKey", sourceEvent.getUser().getKeyAsString()), - (inlineUser != null) ? hasJsonProperty("user", inlineUser) : - hasJsonProperty("user", nullValue(JsonElement.class)), + hasJsonProperty("userKey", inlineUser == null ? LDValue.of(sourceEvent.getUser().getKeyAsString()) : LDValue.ofNull()), + hasJsonProperty("user", inlineUser == null ? LDValue.ofNull() : inlineUser), hasJsonProperty("data", sourceEvent.getData()), - (sourceEvent.getMetricValue() == null) ? hasJsonProperty("metricValue", nullValue(JsonElement.class)) : - hasJsonProperty("metricValue", sourceEvent.getMetricValue().doubleValue()) + hasJsonProperty("metricValue", sourceEvent.getMetricValue() == null ? LDValue.ofNull() : LDValue.of(sourceEvent.getMetricValue())) ); } - private Matcher isSummaryEvent() { + private Matcher isSummaryEvent() { return hasJsonProperty("kind", "summary"); } - private Matcher isSummaryEvent(long startDate, long endDate) { + private Matcher isSummaryEvent(long startDate, long endDate) { return allOf( hasJsonProperty("kind", "summary"), hasJsonProperty("startDate", (double)startDate), @@ -682,7 +673,7 @@ private Matcher isSummaryEvent(long startDate, long endDate) { ); } - private Matcher hasSummaryFlag(String key, LDValue defaultVal, Matcher> counters) { + private Matcher hasSummaryFlag(String key, LDValue defaultVal, Matcher> counters) { return hasJsonProperty("features", hasJsonProperty(key, allOf( hasJsonProperty("default", defaultVal), @@ -690,7 +681,7 @@ private Matcher hasSummaryFlag(String key, LDValue defaultVal, Matc ))); } - private Matcher isSummaryEventCounter(DataModel.FeatureFlag flag, Integer variation, LDValue value, int count) { + private Matcher isSummaryEventCounter(DataModel.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/EvaluationReasonTest.java b/src/test/java/com/launchdarkly/client/EvaluationReasonTest.java index 0723165b0..86ab5c045 100644 --- a/src/test/java/com/launchdarkly/client/EvaluationReasonTest.java +++ b/src/test/java/com/launchdarkly/client/EvaluationReasonTest.java @@ -1,7 +1,7 @@ package com.launchdarkly.client; import com.google.gson.Gson; -import com.google.gson.JsonElement; +import com.launchdarkly.client.value.LDValue; import org.junit.Test; @@ -71,8 +71,8 @@ public void errorInstancesAreReused() { } private void assertJsonEqual(String expectedString, String actualString) { - JsonElement expected = gson.fromJson(expectedString, JsonElement.class); - JsonElement actual = gson.fromJson(actualString, JsonElement.class); + LDValue expected = LDValue.parse(expectedString); + LDValue actual = LDValue.parse(actualString); assertEquals(expected, actual); } } diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java b/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java index 3649cf402..d2283c697 100644 --- a/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java @@ -8,7 +8,6 @@ import org.junit.Test; import static com.launchdarkly.client.ModelBuilders.flagBuilder; -import static com.launchdarkly.client.TestUtil.js; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; @@ -22,7 +21,7 @@ public void canGetFlagValue() { DataModel.FeatureFlag flag = flagBuilder("key").build(); FeatureFlagsState state = new FeatureFlagsState.Builder().addFlag(flag, eval).build(); - assertEquals(js("value"), state.getFlagValue("key")); + assertEquals(LDValue.of("value"), state.getFlagValue("key")); } @Test @@ -64,7 +63,7 @@ public void flagCanHaveNullValue() { DataModel.FeatureFlag flag = flagBuilder("key").build(); FeatureFlagsState state = new FeatureFlagsState.Builder().addFlag(flag, eval).build(); - assertNull(state.getFlagValue("key")); + assertEquals(LDValue.ofNull(), state.getFlagValue("key")); } @Test @@ -76,7 +75,7 @@ public void canConvertToValuesMap() { FeatureFlagsState state = new FeatureFlagsState.Builder() .addFlag(flag1, eval1).addFlag(flag2, eval2).build(); - ImmutableMap expected = ImmutableMap.of("key1", js("value1"), "key2", js("value2")); + ImmutableMap expected = ImmutableMap.of("key1", LDValue.of("value1"), "key2", LDValue.of("value2")); assertEquals(expected, state.toValuesMap()); } diff --git a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java index b0516a592..ccda846e7 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java @@ -3,7 +3,6 @@ import com.google.common.collect.ImmutableMap; import com.google.gson.Gson; import com.google.gson.JsonElement; -import com.google.gson.JsonPrimitive; import com.launchdarkly.client.interfaces.DataStore; import com.launchdarkly.client.value.LDValue; @@ -25,7 +24,6 @@ import static com.launchdarkly.client.TestUtil.specificDataStore; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; @SuppressWarnings("javadoc") @@ -146,22 +144,6 @@ public void stringVariationReturnsDefaultValueForWrongType() throws Exception { assertEquals("a", client.stringVariation("key", user, "a")); } - @SuppressWarnings("deprecation") - @Test - public void deprecatedJsonVariationReturnsFlagValue() throws Exception { - LDValue data = LDValue.buildObject().put("thing", LDValue.of("stuff")).build(); - dataStore.upsert(FEATURES, flagWithValue("key", data)); - - assertEquals(data.asJsonElement(), client.jsonVariation("key", user, new JsonPrimitive(42))); - } - - @SuppressWarnings("deprecation") - @Test - 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(); @@ -277,32 +259,6 @@ public void appropriateErrorForUnexpectedException() throws Exception { } } - @SuppressWarnings("deprecation") - @Test - public void allFlagsReturnsFlagValues() throws Exception { - dataStore.upsert(FEATURES, flagWithValue("key1", LDValue.of("value1"))); - dataStore.upsert(FEATURES, flagWithValue("key2", LDValue.of("value2"))); - - Map result = client.allFlags(user); - assertEquals(ImmutableMap.of("key1", new JsonPrimitive("value1"), "key2", new JsonPrimitive("value2")), result); - } - - @SuppressWarnings("deprecation") - @Test - public void allFlagsReturnsNullForNullUser() throws Exception { - dataStore.upsert(FEATURES, flagWithValue("key", LDValue.of("value"))); - - assertNull(client.allFlags(null)); - } - - @SuppressWarnings("deprecation") - @Test - public void allFlagsReturnsNullForNullUserKey() throws Exception { - dataStore.upsert(FEATURES, flagWithValue("key", LDValue.of("value"))); - - assertNull(client.allFlags(userWithNullKey)); - } - @Test public void allFlagsStateReturnsState() throws Exception { DataModel.FeatureFlag flag1 = flagBuilder("key1") @@ -356,8 +312,8 @@ public void allFlagsStateCanFilterForOnlyClientSideFlags() { FeatureFlagsState state = client.allFlagsState(user, FlagsStateOption.CLIENT_SIDE_ONLY); assertTrue(state.isValid()); - Map allValues = state.toValuesMap(); - assertEquals(ImmutableMap.of("client-side-1", new JsonPrimitive("value1"), "client-side-2", new JsonPrimitive("value2")), allValues); + Map allValues = state.toValuesMap(); + assertEquals(ImmutableMap.of("client-side-1", LDValue.of("value1"), "client-side-2", LDValue.of("value2")), allValues); } @Test diff --git a/src/test/java/com/launchdarkly/client/LDClientEventTest.java b/src/test/java/com/launchdarkly/client/LDClientEventTest.java index 903e09da4..53a824956 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEventTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEventTest.java @@ -1,7 +1,5 @@ package com.launchdarkly.client; -import com.google.gson.JsonElement; -import com.google.gson.JsonPrimitive; import com.launchdarkly.client.EvaluationReason.ErrorKind; import com.launchdarkly.client.events.Event; import com.launchdarkly.client.interfaces.DataStore; @@ -17,8 +15,8 @@ import static com.launchdarkly.client.ModelBuilders.flagWithValue; import static com.launchdarkly.client.ModelBuilders.prerequisite; import static com.launchdarkly.client.ModelBuilders.ruleBuilder; -import static com.launchdarkly.client.TestUtil.specificEventProcessor; import static com.launchdarkly.client.TestUtil.specificDataStore; +import static com.launchdarkly.client.TestUtil.specificEventProcessor; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; @@ -104,38 +102,6 @@ public void trackSendsEventWithDataAndMetricValue() throws Exception { assertEquals(new Double(metricValue), ce.getMetricValue()); } - @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.getUser().getKey()); - assertEquals("eventkey", ce.getKey()); - assertEquals(data, ce.getData().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.getUser().getKey()); - assertEquals("eventkey", ce.getKey()); - assertEquals(data, ce.getData().asJsonElement()); - assertEquals(new Double(metricValue), ce.getMetricValue()); - } - @Test public void trackWithNullUserDoesNotSendEvent() { client.track("eventkey", null); @@ -288,53 +254,6 @@ public void stringVariationDetailSendsEventForUnknownFlag() throws Exception { EvaluationReason.error(ErrorKind.FLAG_NOT_FOUND)); } - @SuppressWarnings("deprecation") - @Test - public void jsonVariationSendsEvent() throws Exception { - LDValue data = LDValue.buildObject().put("thing", LDValue.of("stuff")).build(); - DataModel.FeatureFlag flag = flagWithValue("key", data); - dataStore.upsert(FEATURES, flag); - LDValue defaultVal = LDValue.of(42); - - 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 { - LDValue defaultVal = LDValue.of(42); - - 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 { - LDValue data = LDValue.buildObject().put("thing", LDValue.of("stuff")).build(); - DataModel.FeatureFlag flag = flagWithValue("key", data); - dataStore.upsert(FEATURES, flag); - LDValue defaultVal = LDValue.of(42); - - 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 { - LDValue defaultVal = LDValue.of(42); - - 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(); diff --git a/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java b/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java index eb8201761..520d0c324 100644 --- a/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java @@ -1,19 +1,16 @@ package com.launchdarkly.client; import com.google.common.collect.ImmutableMap; -import com.google.gson.JsonElement; import com.launchdarkly.client.interfaces.DataStore; import com.launchdarkly.client.value.LDValue; import org.junit.Test; import java.io.IOException; -import java.util.Map; import static com.launchdarkly.client.DataModel.DataKinds.FEATURES; import static com.launchdarkly.client.ModelBuilders.flagWithValue; import static com.launchdarkly.client.TestUtil.initedDataStore; -import static com.launchdarkly.client.TestUtil.jbool; import static com.launchdarkly.client.TestUtil.specificDataStore; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; @@ -62,20 +59,6 @@ public void offlineClientReturnsDefaultValue() throws IOException { } } - @Test - public void offlineClientGetsAllFlagsFromDataStore() throws IOException { - DataStore testDataStore = initedDataStore(); - LDConfig config = new LDConfig.Builder() - .offline(true) - .dataStore(specificDataStore(testDataStore)) - .build(); - testDataStore.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); - } - } - @Test public void offlineClientGetsFlagsStateFromDataStore() throws IOException { DataStore testDataStore = initedDataStore(); @@ -87,7 +70,7 @@ public void offlineClientGetsFlagsStateFromDataStore() throws IOException { try (LDClient client = new LDClient("SDK_KEY", config)) { FeatureFlagsState state = client.allFlagsState(user); assertTrue(state.isValid()); - assertEquals(ImmutableMap.of("key", jbool(true)), state.toValuesMap()); + assertEquals(ImmutableMap.of("key", LDValue.of(true)), state.toValuesMap()); } } diff --git a/src/test/java/com/launchdarkly/client/LDUserTest.java b/src/test/java/com/launchdarkly/client/LDUserTest.java index 5487ff71f..8e4fa748f 100644 --- a/src/test/java/com/launchdarkly/client/LDUserTest.java +++ b/src/test/java/com/launchdarkly/client/LDUserTest.java @@ -1,10 +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; import com.google.gson.JsonObject; import com.google.gson.reflect.TypeToken; @@ -18,12 +16,7 @@ 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; @SuppressWarnings("javadoc") @@ -235,23 +228,6 @@ public void canSetPrivateCustomJsonValue() { 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()) { @@ -418,46 +394,6 @@ public void getValueReturnsNullForCustomAttrIfThereAreCustomAttrsButNotThisOne() assertEquals(LDValue.ofNull(), user.getValueForEvaluation("height")); } - @Test - public void canAddCustomAttrWithListOfStrings() { - LDUser user = new LDUser.Builder("key") - .customString("foo", ImmutableList.of("a", "b")) - .build(); - JsonElement expectedAttr = makeCustomAttrWithListOfValues("foo", js("a"), js("b")); - JsonObject jo = LDConfig.DEFAULT.gson.toJsonTree(user).getAsJsonObject(); - assertEquals(expectedAttr, jo.get("custom")); - } - - @Test - public void canAddCustomAttrWithListOfNumbers() { - LDUser user = new LDUser.Builder("key") - .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(); - assertEquals(expectedAttr, jo.get("custom")); - } - - @Test - public void canAddCustomAttrWithListOfMixedValues() { - LDUser user = new LDUser.Builder("key") - .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(); - assertEquals(expectedAttr, jo.get("custom")); - } - - private JsonElement makeCustomAttrWithListOfValues(String name, JsonElement... values) { - JsonObject ret = new JsonObject(); - JsonArray a = new JsonArray(); - for (JsonElement v: values) { - a.add(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)); diff --git a/src/test/java/com/launchdarkly/client/RedisDataStoreBuilderTest.java b/src/test/java/com/launchdarkly/client/RedisDataStoreBuilderTest.java index 5ba7026c0..f266f0ad0 100644 --- a/src/test/java/com/launchdarkly/client/RedisDataStoreBuilderTest.java +++ b/src/test/java/com/launchdarkly/client/RedisDataStoreBuilderTest.java @@ -12,6 +12,7 @@ import redis.clients.jedis.JedisPoolConfig; import redis.clients.jedis.Protocol; +@SuppressWarnings("javadoc") public class RedisDataStoreBuilderTest { @Test public void testDefaultValues() { @@ -36,41 +37,6 @@ public void testConstructorSpecifyingUri() { assertNull(conf.poolConfig); } - @SuppressWarnings("deprecation") - @Test - public void testDeprecatedUriBuildingConstructor() throws URISyntaxException { - RedisDataStoreBuilder conf = new RedisDataStoreBuilder("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(DataStoreCacheConfig.StaleValuesPolicy.EVICT, conf.caching.getStaleValuesPolicy()); - assertEquals(RedisDataStoreBuilder.DEFAULT_PREFIX, conf.prefix); - assertNull(conf.poolConfig); - } - - @SuppressWarnings("deprecation") - @Test - public void testRefreshStaleValues() throws URISyntaxException { - RedisDataStoreBuilder conf = new RedisDataStoreBuilder().refreshStaleValues(true); - assertEquals(DataStoreCacheConfig.StaleValuesPolicy.REFRESH, conf.caching.getStaleValuesPolicy()); - } - - @SuppressWarnings("deprecation") - @Test - public void testAsyncRefresh() throws URISyntaxException { - RedisDataStoreBuilder conf = new RedisDataStoreBuilder().refreshStaleValues(true).asyncRefresh(true); - assertEquals(DataStoreCacheConfig.StaleValuesPolicy.REFRESH_ASYNC, conf.caching.getStaleValuesPolicy()); - } - - @SuppressWarnings("deprecation") - @Test - public void testRefreshStaleValuesWithoutAsyncRefresh() throws URISyntaxException { - RedisDataStoreBuilder conf = new RedisDataStoreBuilder().asyncRefresh(true); - assertEquals(DataStoreCacheConfig.StaleValuesPolicy.EVICT, conf.caching.getStaleValuesPolicy()); - } - @Test public void testPrefixConfigured() throws URISyntaxException { RedisDataStoreBuilder conf = new RedisDataStoreBuilder().prefix("prefix"); @@ -89,14 +55,6 @@ public void testSocketTimeoutConfigured() throws URISyntaxException { assertEquals(1000, conf.socketTimeout); } - @SuppressWarnings("deprecation") - @Test - public void testCacheTimeWithUnit() throws URISyntaxException { - RedisDataStoreBuilder conf = new RedisDataStoreBuilder().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(); diff --git a/src/test/java/com/launchdarkly/client/TestUtil.java b/src/test/java/com/launchdarkly/client/TestUtil.java index c9e24b1c8..c9287ed9a 100644 --- a/src/test/java/com/launchdarkly/client/TestUtil.java +++ b/src/test/java/com/launchdarkly/client/TestUtil.java @@ -2,19 +2,17 @@ import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.SettableFuture; -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonPrimitive; import com.launchdarkly.client.events.Event; -import com.launchdarkly.client.interfaces.EventProcessor; -import com.launchdarkly.client.interfaces.EventProcessorFactory; -import com.launchdarkly.client.interfaces.DataStore; -import com.launchdarkly.client.interfaces.DataStoreFactory; import com.launchdarkly.client.interfaces.DataSource; import com.launchdarkly.client.interfaces.DataSourceFactory; +import com.launchdarkly.client.interfaces.DataStore; +import com.launchdarkly.client.interfaces.DataStoreFactory; +import com.launchdarkly.client.interfaces.EventProcessor; +import com.launchdarkly.client.interfaces.EventProcessorFactory; import com.launchdarkly.client.interfaces.VersionedData; import com.launchdarkly.client.interfaces.VersionedDataKind; import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.client.value.LDValueType; import org.hamcrest.Description; import org.hamcrest.Matcher; @@ -147,22 +145,6 @@ public void sendEvent(Event e) { public void flush() {} } - public static JsonPrimitive js(String s) { - return new JsonPrimitive(s); - } - - public static JsonPrimitive jint(int n) { - return new JsonPrimitive(n); - } - - public static JsonPrimitive jdouble(double d) { - return new JsonPrimitive(d); - } - - public static JsonPrimitive jbool(boolean b) { - return new JsonPrimitive(b); - } - public static class DataBuilder { private Map, Map> data = new HashMap<>(); @@ -188,33 +170,28 @@ public static Evaluator.EvalResult simpleEvaluation(int variation, LDValue value return new Evaluator.EvalResult(value, variation, EvaluationReason.fallthrough()); } - public static Matcher hasJsonProperty(final String name, JsonElement value) { + public static Matcher hasJsonProperty(final String name, LDValue 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)); + public static Matcher hasJsonProperty(final String name, String value) { + return hasJsonProperty(name, LDValue.of(value)); } - public static Matcher hasJsonProperty(final String name, int value) { - return hasJsonProperty(name, new JsonPrimitive(value)); + public static Matcher hasJsonProperty(final String name, int value) { + return hasJsonProperty(name, LDValue.of(value)); } - public static Matcher hasJsonProperty(final String name, double value) { - return hasJsonProperty(name, new JsonPrimitive(value)); + public static Matcher hasJsonProperty(final String name, double value) { + return hasJsonProperty(name, LDValue.of(value)); } - public static Matcher hasJsonProperty(final String name, boolean value) { - return hasJsonProperty(name, new JsonPrimitive(value)); + public static Matcher hasJsonProperty(final String name, boolean value) { + return hasJsonProperty(name, LDValue.of(value)); } - public static Matcher hasJsonProperty(final String name, final Matcher matcher) { - return new TypeSafeDiagnosingMatcher() { + public static Matcher hasJsonProperty(final String name, final Matcher matcher) { + return new TypeSafeDiagnosingMatcher() { @Override public void describeTo(Description description) { description.appendText(name + ": "); @@ -222,8 +199,8 @@ public void describeTo(Description description) { } @Override - protected boolean matchesSafely(JsonElement item, Description mismatchDescription) { - JsonElement value = item.getAsJsonObject().get(name); + protected boolean matchesSafely(LDValue item, Description mismatchDescription) { + LDValue value = item.get(name); if (!matcher.matches(value)) { matcher.describeMismatch(value, mismatchDescription); return false; @@ -233,8 +210,8 @@ protected boolean matchesSafely(JsonElement item, Description mismatchDescriptio }; } - public static Matcher isJsonArray(final Matcher> matcher) { - return new TypeSafeDiagnosingMatcher() { + public static Matcher isJsonArray(final Matcher> matcher) { + return new TypeSafeDiagnosingMatcher() { @Override public void describeTo(Description description) { description.appendText("array: "); @@ -242,11 +219,16 @@ public void describeTo(Description description) { } @Override - protected boolean matchesSafely(JsonElement item, Description mismatchDescription) { - JsonArray value = item.getAsJsonArray(); - if (!matcher.matches(value)) { - matcher.describeMismatch(value, mismatchDescription); + protected boolean matchesSafely(LDValue item, Description mismatchDescription) { + if (item.getType() != LDValueType.ARRAY) { + matcher.describeMismatch(item, mismatchDescription); return false; + } else { + Iterable values = item.values(); + if (!matcher.matches(values)) { + matcher.describeMismatch(values, mismatchDescription); + return false; + } } return true; } diff --git a/src/test/java/com/launchdarkly/client/files/ClientWithFileDataSourceTest.java b/src/test/java/com/launchdarkly/client/files/ClientWithFileDataSourceTest.java index cc8e344bd..8f4df2f41 100644 --- a/src/test/java/com/launchdarkly/client/files/ClientWithFileDataSourceTest.java +++ b/src/test/java/com/launchdarkly/client/files/ClientWithFileDataSourceTest.java @@ -1,9 +1,9 @@ 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 com.launchdarkly.client.value.LDValue; import org.junit.Test; @@ -15,6 +15,7 @@ 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(); @@ -31,7 +32,7 @@ private LDClient makeClient() throws Exception { @Test public void fullFlagDefinitionEvaluatesAsExpected() throws Exception { try (LDClient client = makeClient()) { - assertThat(client.jsonVariation(FULL_FLAG_1_KEY, user, new JsonPrimitive("default")), + assertThat(client.jsonValueVariation(FULL_FLAG_1_KEY, user, LDValue.of("default")), equalTo(FULL_FLAG_1_VALUE)); } } @@ -39,7 +40,7 @@ public void fullFlagDefinitionEvaluatesAsExpected() throws Exception { @Test public void simplifiedFlagEvaluatesAsExpected() throws Exception { try (LDClient client = makeClient()) { - assertThat(client.jsonVariation(FLAG_VALUE_1_KEY, user, new JsonPrimitive("default")), + assertThat(client.jsonValueVariation(FLAG_VALUE_1_KEY, user, LDValue.of("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 d1f098d7c..e8b49f9ef 100644 --- a/src/test/java/com/launchdarkly/client/files/TestData.java +++ b/src/test/java/com/launchdarkly/client/files/TestData.java @@ -2,9 +2,7 @@ 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 com.launchdarkly.client.value.LDValue; import java.net.URISyntaxException; import java.net.URL; @@ -14,27 +12,25 @@ import java.util.Map; import java.util.Set; +@SuppressWarnings("javadoc") 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,\"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); + public static final LDValue FULL_FLAG_1 = + LDValue.parse("{\"key\":\"flag1\",\"on\":true,\"fallthrough\":{\"variation\":2},\"variations\":[\"fall\",\"off\",\"on\"]}"); + public static final LDValue FULL_FLAG_1_VALUE = LDValue.of("on"); + 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 LDValue FLAG_VALUE_1 = LDValue.of("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 LDValue FULL_SEGMENT_1 = LDValue.parse("{\"key\":\"seg1\",\"include\":[\"user1\"]}"); + 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); diff --git a/src/test/java/com/launchdarkly/client/value/LDValueTest.java b/src/test/java/com/launchdarkly/client/value/LDValueTest.java index e4397a676..73ee44b44 100644 --- a/src/test/java/com/launchdarkly/client/value/LDValueTest.java +++ b/src/test/java/com/launchdarkly/client/value/LDValueTest.java @@ -3,9 +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; @@ -16,11 +13,10 @@ 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"}) +@SuppressWarnings("javadoc") public class LDValueTest { private static final Gson gson = new Gson(); @@ -40,33 +36,10 @@ public class LDValueTest { 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 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)); - private static final LDValue anArrayValueFromJsonElement = LDValue.fromJsonElement(anArrayValue.asJsonElement()); - private static final LDValue anObjectValueFromJsonElement = LDValue.fromJsonElement(anObjectValue.asJsonElement()); - - @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()); - } - @Test public void canGetValueAsBoolean() { assertEquals(LDValueType.BOOLEAN, aTrueBoolValue.getType()); assertTrue(aTrueBoolValue.booleanValue()); - assertEquals(LDValueType.BOOLEAN, aTrueBoolValueFromJsonElement.getType()); - assertTrue(aTrueBoolValueFromJsonElement.booleanValue()); } @Test @@ -74,19 +47,12 @@ public void nonBooleanValueAsBooleanIsFalse() { LDValue[] values = new LDValue[] { LDValue.ofNull(), aStringValue, - aStringValueFromJsonElement, anIntValue, - anIntValueFromJsonElement, aLongValue, - aLongValueFromJsonElement, aFloatValue, - aFloatValueFromJsonElement, aDoubleValue, - aDoubleValueFromJsonElement, anArrayValue, - anArrayValueFromJsonElement, anObjectValue, - anObjectValueFromJsonElement }; for (LDValue value: values) { assertNotEquals(value.toString(), LDValueType.BOOLEAN, value.getType()); @@ -98,8 +64,6 @@ public void nonBooleanValueAsBooleanIsFalse() { public void canGetValueAsString() { assertEquals(LDValueType.STRING, aStringValue.getType()); assertEquals(someString, aStringValue.stringValue()); - assertEquals(LDValueType.STRING, aStringValueFromJsonElement.getType()); - assertEquals(someString, aStringValueFromJsonElement.stringValue()); } @Test @@ -107,19 +71,12 @@ public void nonStringValueAsStringIsNull() { LDValue[] values = new LDValue[] { LDValue.ofNull(), aTrueBoolValue, - aTrueBoolValueFromJsonElement, anIntValue, - anIntValueFromJsonElement, aLongValue, - aLongValueFromJsonElement, aFloatValue, - aFloatValueFromJsonElement, aDoubleValue, - aDoubleValueFromJsonElement, anArrayValue, - anArrayValueFromJsonElement, - anObjectValue, - anObjectValueFromJsonElement + anObjectValue }; for (LDValue value: values) { assertNotEquals(value.toString(), LDValueType.STRING, value.getType()); @@ -184,14 +141,10 @@ public void nonNumericValueAsNumberIsZero() { LDValue[] values = new LDValue[] { LDValue.ofNull(), aTrueBoolValue, - aTrueBoolValueFromJsonElement, aStringValue, - aStringValueFromJsonElement, aNumericLookingStringValue, anArrayValue, - anArrayValueFromJsonElement, - anObjectValue, - anObjectValueFromJsonElement + anObjectValue }; for (LDValue value: values) { assertNotEquals(value.toString(), LDValueType.NUMBER, value.getType()); @@ -202,21 +155,16 @@ public void nonNumericValueAsNumberIsZero() { } @Test - public void canGetSizeOfArrayOrObject() { + public void canGetSizeOfArray() { 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 @@ -235,7 +183,6 @@ public void nonArrayValuesBehaveLikeEmptyArray() { LDValue[] values = new LDValue[] { LDValue.ofNull(), aTrueBoolValue, - aTrueBoolValueFromJsonElement, anIntValue, aLongValue, aFloatValue, @@ -256,19 +203,14 @@ public void nonArrayValuesBehaveLikeEmptyArray() { @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 @@ -296,7 +238,6 @@ public void nonObjectValuesBehaveLikeEmptyObject() { LDValue[] values = new LDValue[] { LDValue.ofNull(), aTrueBoolValue, - aTrueBoolValueFromJsonElement, anIntValue, aLongValue, aFloatValue, @@ -339,76 +280,50 @@ private void assertValueAndHashNotEqual(LDValue a, LDValue 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") + LDValue a1 = 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); + assertValueAndHashNotEqual(a1, a2); LDValue a3 = LDValue.buildArray().add("a").add("b").add("c").build(); - assertValueAndHashNotEqual(a0, a3); + assertValueAndHashNotEqual(a1, a3); LDValue a4 = LDValue.buildArray().add("a") .add(LDValue.buildArray().add("b").add("x").build()) .build(); - assertValueAndHashNotEqual(a0, a4); + assertValueAndHashNotEqual(a1, a4); } @Test public void equalsUsesDeepEqualityForObjects() { - LDValue o0 = LDValue.buildObject() + LDValue o1 = 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); + assertValueAndHashNotEqual(o1, o2); LDValue o3 = LDValue.buildObject() .put("a", "b") .put("c", LDValue.buildObject().put("d", "e").build()) .put("f", "g") .build(); - assertValueAndHashNotEqual(o0, o3); + assertValueAndHashNotEqual(o1, o3); LDValue o4 = LDValue.buildObject() .put("a", "b") .put("c", LDValue.buildObject().put("d", "f").build()) .build(); - assertValueAndHashNotEqual(o0, o4); + assertValueAndHashNotEqual(o1, o4); } @Test @@ -431,22 +346,14 @@ public void canUseDoubleTypeForNumberGreaterThanMaxFloat() { 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(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()); - 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 @@ -454,21 +361,13 @@ public void testDefaultGsonSerialization() { LDValue[] values = new LDValue[] { LDValue.ofNull(), aTrueBoolValue, - aTrueBoolValueFromJsonElement, anIntValue, - anIntValueFromJsonElement, aLongValue, - aLongValueFromJsonElement, aFloatValue, - aFloatValueFromJsonElement, aDoubleValue, - aDoubleValueFromJsonElement, aStringValue, - aStringValueFromJsonElement, anArrayValue, - anArrayValueFromJsonElement, - anObjectValue, - anObjectValueFromJsonElement + anObjectValue }; for (LDValue value: values) { assertEquals(value.toString(), value.toJsonString(), gson.toJson(value)); @@ -476,17 +375,6 @@ public void testDefaultGsonSerialization() { } } - @Test - 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()); - } - @Test public void testTypeConversions() { testTypeConversion(LDValue.Convert.Boolean, new Boolean[] { true, false }, LDValue.of(true), LDValue.of(false)); From bb234a7a944b011dd782ae3f3f55c18d2f733e86 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 18 Dec 2019 12:37:51 -0800 Subject: [PATCH 224/673] move Event into interfaces package --- .../java/com/launchdarkly/client/DefaultEventProcessor.java | 2 +- src/main/java/com/launchdarkly/client/Evaluator.java | 2 +- src/main/java/com/launchdarkly/client/EventFactory.java | 2 +- .../java/com/launchdarkly/client/EventOutputFormatter.java | 2 +- src/main/java/com/launchdarkly/client/EventSummarizer.java | 2 +- src/main/java/com/launchdarkly/client/LDClient.java | 2 +- .../launchdarkly/client/{events => interfaces}/Event.java | 6 +++++- .../com/launchdarkly/client/interfaces/EventProcessor.java | 2 -- .../com/launchdarkly/client/interfaces/package-info.java | 2 +- .../com/launchdarkly/client/DefaultEventProcessorTest.java | 2 +- src/test/java/com/launchdarkly/client/EvaluatorTest.java | 2 +- src/test/java/com/launchdarkly/client/EventOutputTest.java | 4 ++-- .../java/com/launchdarkly/client/EventSummarizerTest.java | 2 +- .../java/com/launchdarkly/client/LDClientEventTest.java | 2 +- src/test/java/com/launchdarkly/client/LDClientTest.java | 2 +- src/test/java/com/launchdarkly/client/TestUtil.java | 2 +- 16 files changed, 20 insertions(+), 18 deletions(-) rename src/main/java/com/launchdarkly/client/{events => interfaces}/Event.java (97%) diff --git a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java index 94e746778..1948abd7b 100644 --- a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java @@ -3,7 +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.events.Event; +import com.launchdarkly.client.interfaces.Event; import com.launchdarkly.client.interfaces.EventProcessor; import org.slf4j.Logger; diff --git a/src/main/java/com/launchdarkly/client/Evaluator.java b/src/main/java/com/launchdarkly/client/Evaluator.java index 2329f70f6..3624ab0af 100644 --- a/src/main/java/com/launchdarkly/client/Evaluator.java +++ b/src/main/java/com/launchdarkly/client/Evaluator.java @@ -2,7 +2,7 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.Iterables; -import com.launchdarkly.client.events.Event; +import com.launchdarkly.client.interfaces.Event; import com.launchdarkly.client.value.LDValue; import com.launchdarkly.client.value.LDValueType; diff --git a/src/main/java/com/launchdarkly/client/EventFactory.java b/src/main/java/com/launchdarkly/client/EventFactory.java index fa61d3f8e..05718398a 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.launchdarkly.client.events.Event; +import com.launchdarkly.client.interfaces.Event; import com.launchdarkly.client.value.LDValue; abstract class EventFactory { diff --git a/src/main/java/com/launchdarkly/client/EventOutputFormatter.java b/src/main/java/com/launchdarkly/client/EventOutputFormatter.java index 059259762..447d86747 100644 --- a/src/main/java/com/launchdarkly/client/EventOutputFormatter.java +++ b/src/main/java/com/launchdarkly/client/EventOutputFormatter.java @@ -3,7 +3,7 @@ import com.google.gson.stream.JsonWriter; import com.launchdarkly.client.EventSummarizer.CounterKey; import com.launchdarkly.client.EventSummarizer.CounterValue; -import com.launchdarkly.client.events.Event; +import com.launchdarkly.client.interfaces.Event; import com.launchdarkly.client.value.LDValue; import java.io.IOException; diff --git a/src/main/java/com/launchdarkly/client/EventSummarizer.java b/src/main/java/com/launchdarkly/client/EventSummarizer.java index fb49b494e..27679e9ad 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.launchdarkly.client.events.Event; +import com.launchdarkly.client.interfaces.Event; import com.launchdarkly.client.value.LDValue; import java.util.HashMap; diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index 64215f2d9..e525452a4 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.launchdarkly.client.events.Event; +import com.launchdarkly.client.interfaces.Event; import com.launchdarkly.client.interfaces.EventProcessor; import com.launchdarkly.client.interfaces.EventProcessorFactory; import com.launchdarkly.client.interfaces.FeatureStore; diff --git a/src/main/java/com/launchdarkly/client/events/Event.java b/src/main/java/com/launchdarkly/client/interfaces/Event.java similarity index 97% rename from src/main/java/com/launchdarkly/client/events/Event.java rename to src/main/java/com/launchdarkly/client/interfaces/Event.java index 921cb94f7..121ff63b8 100644 --- a/src/main/java/com/launchdarkly/client/events/Event.java +++ b/src/main/java/com/launchdarkly/client/interfaces/Event.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client.events; +package com.launchdarkly.client.interfaces; import com.google.gson.JsonElement; import com.launchdarkly.client.EvaluationReason; @@ -8,6 +8,10 @@ /** * Base class for all analytics events that are generated by the client. Also defines all of its own subclasses. + * + * Applications do not need to reference these types directly. They are used internally in analytics event + * processing, and are visible only to support writing a custom implementation of {@link EventProcessor} if + * desired. */ public class Event { final long creationDate; diff --git a/src/main/java/com/launchdarkly/client/interfaces/EventProcessor.java b/src/main/java/com/launchdarkly/client/interfaces/EventProcessor.java index 429a88aca..2f1d8b4a3 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/EventProcessor.java +++ b/src/main/java/com/launchdarkly/client/interfaces/EventProcessor.java @@ -1,7 +1,5 @@ package com.launchdarkly.client.interfaces; -import com.launchdarkly.client.events.Event; - import java.io.Closeable; /** diff --git a/src/main/java/com/launchdarkly/client/interfaces/package-info.java b/src/main/java/com/launchdarkly/client/interfaces/package-info.java index d798dc8f0..469d02b54 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/package-info.java +++ b/src/main/java/com/launchdarkly/client/interfaces/package-info.java @@ -1,5 +1,5 @@ /** - * The package for interfaces that allow customization of LaunchDarkly components. + * The package for interfaces and related types 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. diff --git a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java index 355988273..b9ec49716 100644 --- a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java @@ -3,7 +3,7 @@ import com.google.gson.Gson; import com.google.gson.JsonArray; import com.google.gson.JsonElement; -import com.launchdarkly.client.events.Event; +import com.launchdarkly.client.interfaces.Event; import com.launchdarkly.client.value.LDValue; import org.hamcrest.Matcher; diff --git a/src/test/java/com/launchdarkly/client/EvaluatorTest.java b/src/test/java/com/launchdarkly/client/EvaluatorTest.java index aa7bb2e6b..a98ff2a6e 100644 --- a/src/test/java/com/launchdarkly/client/EvaluatorTest.java +++ b/src/test/java/com/launchdarkly/client/EvaluatorTest.java @@ -1,7 +1,7 @@ package com.launchdarkly.client; import com.google.common.collect.Iterables; -import com.launchdarkly.client.events.Event; +import com.launchdarkly.client.interfaces.Event; import com.launchdarkly.client.value.LDValue; import org.junit.Test; diff --git a/src/test/java/com/launchdarkly/client/EventOutputTest.java b/src/test/java/com/launchdarkly/client/EventOutputTest.java index 8bf850c75..a119221cb 100644 --- a/src/test/java/com/launchdarkly/client/EventOutputTest.java +++ b/src/test/java/com/launchdarkly/client/EventOutputTest.java @@ -3,8 +3,8 @@ import com.google.common.collect.ImmutableSet; import com.google.gson.Gson; import com.launchdarkly.client.EventSummarizer.EventSummary; -import com.launchdarkly.client.events.Event; -import com.launchdarkly.client.events.Event.FeatureRequest; +import com.launchdarkly.client.interfaces.Event; +import com.launchdarkly.client.interfaces.Event.FeatureRequest; import com.launchdarkly.client.value.LDValue; import com.launchdarkly.client.value.ObjectBuilder; diff --git a/src/test/java/com/launchdarkly/client/EventSummarizerTest.java b/src/test/java/com/launchdarkly/client/EventSummarizerTest.java index e439c0187..297268b49 100644 --- a/src/test/java/com/launchdarkly/client/EventSummarizerTest.java +++ b/src/test/java/com/launchdarkly/client/EventSummarizerTest.java @@ -1,6 +1,6 @@ package com.launchdarkly.client; -import com.launchdarkly.client.events.Event; +import com.launchdarkly.client.interfaces.Event; import com.launchdarkly.client.value.LDValue; import org.junit.Test; diff --git a/src/test/java/com/launchdarkly/client/LDClientEventTest.java b/src/test/java/com/launchdarkly/client/LDClientEventTest.java index 27ce87b3a..aed295d05 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEventTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEventTest.java @@ -3,7 +3,7 @@ import com.google.gson.JsonElement; import com.google.gson.JsonPrimitive; import com.launchdarkly.client.EvaluationReason.ErrorKind; -import com.launchdarkly.client.events.Event; +import com.launchdarkly.client.interfaces.Event; import com.launchdarkly.client.interfaces.FeatureStore; import com.launchdarkly.client.value.LDValue; diff --git a/src/test/java/com/launchdarkly/client/LDClientTest.java b/src/test/java/com/launchdarkly/client/LDClientTest.java index 3794c6f8f..df2a081bf 100644 --- a/src/test/java/com/launchdarkly/client/LDClientTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientTest.java @@ -5,7 +5,7 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.Iterables; -import com.launchdarkly.client.events.Event; +import com.launchdarkly.client.interfaces.Event; import com.launchdarkly.client.interfaces.EventProcessor; import com.launchdarkly.client.interfaces.FeatureStore; import com.launchdarkly.client.interfaces.UpdateProcessor; diff --git a/src/test/java/com/launchdarkly/client/TestUtil.java b/src/test/java/com/launchdarkly/client/TestUtil.java index 7ed58fbe1..3070736ae 100644 --- a/src/test/java/com/launchdarkly/client/TestUtil.java +++ b/src/test/java/com/launchdarkly/client/TestUtil.java @@ -5,7 +5,7 @@ import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonPrimitive; -import com.launchdarkly.client.events.Event; +import com.launchdarkly.client.interfaces.Event; import com.launchdarkly.client.interfaces.EventProcessor; import com.launchdarkly.client.interfaces.EventProcessorFactory; import com.launchdarkly.client.interfaces.FeatureStore; From fc6feec2598160e1b1aea8fb48262468cb12481c Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 18 Dec 2019 12:41:56 -0800 Subject: [PATCH 225/673] add missing javadoc --- .../com/launchdarkly/client/DataModel.java | 3 ++ .../launchdarkly/client/interfaces/Event.java | 40 +++++++++++++++++++ .../client/interfaces/VersionedDataKind.java | 10 +++-- 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/DataModel.java b/src/main/java/com/launchdarkly/client/DataModel.java index d1f25efcf..00f77efc3 100644 --- a/src/main/java/com/launchdarkly/client/DataModel.java +++ b/src/main/java/com/launchdarkly/client/DataModel.java @@ -17,6 +17,9 @@ * the LaunchDarkly service. */ public abstract class DataModel { + /** + * Contains standard instances of {@link VersionedDataKind} representing the main data model types. + */ public static abstract class DataKinds { /** * The {@link VersionedDataKind} instance that describes feature flag data. diff --git a/src/main/java/com/launchdarkly/client/interfaces/Event.java b/src/main/java/com/launchdarkly/client/interfaces/Event.java index 121ff63b8..7a97f2835 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/Event.java +++ b/src/main/java/com/launchdarkly/client/interfaces/Event.java @@ -224,42 +224,82 @@ public FeatureRequest(long timestamp, String key, LDUser user, Integer version, reason, prereqOf, trackEvents, debugEventsUntilDate, debug); } + /** + * The key of the feature flag that was evaluated. + * @return the flag key + */ public String getKey() { return key; } + /** + * The index of the selected flag variation, or null if the application default value was used. + * @return zero-based index of the variation, or null + */ public Integer getVariation() { return variation; } + /** + * The value of the selected flag variation. + * @return the value + */ public LDValue getValue() { return value; } + /** + * The application default value used in the evaluation. + * @return the application default + */ public LDValue getDefaultVal() { return defaultVal; } + /** + * The version of the feature flag that was evaluated, or null if the flag was not found. + * @return the flag version or null + */ public Integer getVersion() { return version; } + /** + * If this flag was evaluated as a prerequisite for another flag, the key of the other flag. + * @return a flag key or null + */ public String getPrereqOf() { return prereqOf; } + /** + * True if full event tracking is enabled for this flag. + * @return true if full event tracking is on + */ public boolean isTrackEvents() { return trackEvents; } + /** + * If debugging is enabled for this flag, the Unix millisecond time at which to stop debugging. + * @return a timestamp or null + */ public Long getDebugEventsUntilDate() { return debugEventsUntilDate; } + /** + * The {@link EvaluationReason} for this evaluation, or null if the reason was not requested for this evaluation. + * @return a reason object or null + */ public EvaluationReason getReason() { return reason; } + /** + * True if this event was generated due to debugging being enabled. + * @return true if this is a debug event + */ public boolean isDebug() { return debug; } diff --git a/src/main/java/com/launchdarkly/client/interfaces/VersionedDataKind.java b/src/main/java/com/launchdarkly/client/interfaces/VersionedDataKind.java index 19b8220b9..09d7921fe 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/VersionedDataKind.java +++ b/src/main/java/com/launchdarkly/client/interfaces/VersionedDataKind.java @@ -6,9 +6,8 @@ * 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} * or writing your own {@code FeatureStore} implementation. If you are implementing a custom store, for - * 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. + * maximum forward compatibility you should only refer to {@link VersionedData} and {@link VersionedDataKind}, + * 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 */ @@ -40,6 +39,11 @@ public abstract class VersionedDataKind { */ public abstract T makeDeletedItem(String key, int version); + /** + * Deserialize an instance of this type from its string representation (normally JSON). + * @param serializedData the serialized data + * @return the deserialized object + */ public abstract T deserialize(String serializedData); /** From 304a7b4bf9d75881be60da410411dcd66053b15c Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 18 Dec 2019 12:43:43 -0800 Subject: [PATCH 226/673] comment --- src/main/java/com/launchdarkly/client/DataModel.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/com/launchdarkly/client/DataModel.java b/src/main/java/com/launchdarkly/client/DataModel.java index 00f77efc3..27db8b95e 100644 --- a/src/main/java/com/launchdarkly/client/DataModel.java +++ b/src/main/java/com/launchdarkly/client/DataModel.java @@ -15,6 +15,10 @@ /** * Defines the full data model for feature flags and user segments, in the format provided by the SDK endpoints of * the LaunchDarkly service. + * + * The details of the data model are not public to application code (although of course developers can easily + * look at the code or the data) so that changes to LaunchDarkly SDK implementation details will not be breaking + * changes to the application. */ public abstract class DataModel { /** From de464cf3b514413fbd02624679ee685afe8016af Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 18 Dec 2019 12:46:34 -0800 Subject: [PATCH 227/673] rm unused --- .../java/com/launchdarkly/client/events/package-info.java | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 src/main/java/com/launchdarkly/client/events/package-info.java diff --git a/src/main/java/com/launchdarkly/client/events/package-info.java b/src/main/java/com/launchdarkly/client/events/package-info.java deleted file mode 100644 index e09e8963f..000000000 --- a/src/main/java/com/launchdarkly/client/events/package-info.java +++ /dev/null @@ -1,7 +0,0 @@ -/** - * The LaunchDarkly analytics event model. - *

    - * You will not need to refer to these types in your code unless you are creating a - * custom implementation of {@link com.launchdarkly.client.interfaces.EventProcessor}. - */ -package com.launchdarkly.client.events; From ffe578da8af151b2e91a9211f2ddebe430a4abd4 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 18 Dec 2019 12:48:26 -0800 Subject: [PATCH 228/673] make fields private --- .../launchdarkly/client/interfaces/Event.java | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/interfaces/Event.java b/src/main/java/com/launchdarkly/client/interfaces/Event.java index 7a97f2835..a874f6d1b 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/Event.java +++ b/src/main/java/com/launchdarkly/client/interfaces/Event.java @@ -14,8 +14,8 @@ * desired. */ public class Event { - final long creationDate; - final LDUser user; + private final long creationDate; + private final LDUser user; /** * Base event constructor. @@ -47,9 +47,9 @@ public LDUser getUser() { * 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; + private final String key; + private final LDValue data; + private final Double metricValue; /** * Constructs a custom event. @@ -137,16 +137,16 @@ public Index(long timestamp, LDUser user) { * An event generated by a feature flag evaluation. */ public static final class FeatureRequest extends Event { - final String key; - final Integer variation; - final LDValue value; - final LDValue defaultVal; - final Integer version; - final String prereqOf; - final boolean trackEvents; - final Long debugEventsUntilDate; - final EvaluationReason reason; - final boolean debug; + private final String key; + private final Integer variation; + private final LDValue value; + private final LDValue defaultVal; + private final Integer version; + private final String prereqOf; + private final boolean trackEvents; + private final Long debugEventsUntilDate; + private final EvaluationReason reason; + private final boolean debug; /** * Constructs a feature request event. From a367b8fccfaa9344c9550391f1012867bd2325dc Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 18 Dec 2019 12:53:40 -0800 Subject: [PATCH 229/673] fix import --- src/main/java/com/launchdarkly/client/Components.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/Components.java b/src/main/java/com/launchdarkly/client/Components.java index 5f0eb4b9c..4e71532ab 100644 --- a/src/main/java/com/launchdarkly/client/Components.java +++ b/src/main/java/com/launchdarkly/client/Components.java @@ -1,12 +1,12 @@ package com.launchdarkly.client; -import com.launchdarkly.client.interfaces.EventProcessor; -import com.launchdarkly.client.interfaces.EventProcessorFactory; -import com.launchdarkly.client.interfaces.DataStore; -import com.launchdarkly.client.interfaces.DataStoreFactory; -import com.launchdarkly.client.events.Event; import com.launchdarkly.client.interfaces.DataSource; import com.launchdarkly.client.interfaces.DataSourceFactory; +import com.launchdarkly.client.interfaces.DataStore; +import com.launchdarkly.client.interfaces.DataStoreFactory; +import com.launchdarkly.client.interfaces.Event; +import com.launchdarkly.client.interfaces.EventProcessor; +import com.launchdarkly.client.interfaces.EventProcessorFactory; import org.slf4j.Logger; import org.slf4j.LoggerFactory; From 7e732d6c31c4d89d1ae9331ab1bb71a64b51df7e Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 30 Dec 2019 11:36:53 -0800 Subject: [PATCH 230/673] 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 231/673] 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 232/673] 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 233/673] 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 234/673] 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 235/673] 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 236/673] 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 237/673] 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 238/673] 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 8b3097294c1296622b09ad2e364eebbcb4535a00 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 7 Jan 2020 13:12:44 -0800 Subject: [PATCH 239/673] javadoc fix --- src/main/java/com/launchdarkly/client/DataStoreCacheConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/client/DataStoreCacheConfig.java b/src/main/java/com/launchdarkly/client/DataStoreCacheConfig.java index 0c2ac65ab..01dcf9ed8 100644 --- a/src/main/java/com/launchdarkly/client/DataStoreCacheConfig.java +++ b/src/main/java/com/launchdarkly/client/DataStoreCacheConfig.java @@ -24,7 +24,7 @@ * ) * * - * @see RedisDataStoreBuilder#caching(DataStoreCacheConfig) + * @see com.launchdarkly.client.integrations.RedisDataStoreBuilder#caching(DataStoreCacheConfig) * @since 4.6.0 */ public final class DataStoreCacheConfig { From 68193f84bfb944bb2c14c93f63d079a3992ea4fa Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 7 Jan 2020 14:25:31 -0800 Subject: [PATCH 240/673] 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 241/673] 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 242/673] 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 072545414031c8dc9511b64eaef33c673db6ef38 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 9 Jan 2020 15:23:38 -0800 Subject: [PATCH 243/673] use helper method for null guard --- 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 a6b298e12..b8ab7a7ef 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -248,7 +248,7 @@ public JsonElement jsonVariation(String featureKey, LDUser user, JsonElement def @Override public LDValue jsonValueVariation(String featureKey, LDUser user, LDValue defaultValue) { - return evaluate(featureKey, user, defaultValue == null ? LDValue.ofNull() : defaultValue, false); + return evaluate(featureKey, user, LDValue.normalize(defaultValue), false); } @Override @@ -294,7 +294,7 @@ public EvaluationDetail jsonVariationDetail(String featureKey, LDUs @Override public EvaluationDetail jsonValueVariationDetail(String featureKey, LDUser user, LDValue defaultValue) { - Evaluator.EvalResult result = evaluateInternal(featureKey, user, defaultValue == null ? LDValue.ofNull() : defaultValue, false, EventFactory.DEFAULT_WITH_REASONS); + Evaluator.EvalResult result = evaluateInternal(featureKey, user, LDValue.normalize(defaultValue), false, EventFactory.DEFAULT_WITH_REASONS); return EvaluationDetail.fromValue(result.getValue(), result.getVariationIndex(), result.getReason()); } From 35d7798359a23d80979f47f23bab4f00af24e5db Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 9 Jan 2020 15:35:04 -0800 Subject: [PATCH 244/673] fix type checking when default is null --- src/main/java/com/launchdarkly/client/LDClient.java | 4 ++-- .../launchdarkly/client/LDClientEvaluationTest.java | 12 ++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index b8ab7a7ef..1301b3303 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -367,8 +367,8 @@ private Evaluator.EvalResult evaluateInternal(String featureKey, LDUser user, LD if (evalResult.isDefault()) { evalResult.setValue(defaultValue); } else { - LDValue value = evalResult.getValue(); - if (checkType && value != null && !value.isNull() && defaultValue.getType() != value.getType()) { + LDValue value = evalResult.getValue(); // guaranteed not to be an actual Java null, but can be LDValue.ofNull() + if (checkType && !value.isNull() && !defaultValue.isNull() && defaultValue.getType() != value.getType()) { logger.error("Feature flag evaluation expected result as {}, but got {}", defaultValue.getType(), value.getType()); sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue, EvaluationReason.ErrorKind.WRONG_TYPE)); diff --git a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java index 13fb54997..9a8e14efd 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java @@ -133,11 +133,23 @@ public void stringVariationReturnsFlagValue() throws Exception { assertEquals("b", client.stringVariation("key", user, "a")); } + @Test + public void stringVariationWithNullDefaultReturnsFlagValue() throws Exception { + featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of("b"))); + + assertEquals("b", client.stringVariation("key", user, null)); + } + @Test public void stringVariationReturnsDefaultValueForUnknownFlag() throws Exception { assertEquals("a", client.stringVariation("key", user, "a")); } + @Test + public void stringVariationWithNullDefaultReturnsDefaultValueForUnknownFlag() throws Exception { + assertNull(client.stringVariation("key", user, null)); + } + @Test public void stringVariationReturnsDefaultValueForWrongType() throws Exception { featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(true))); From 314686b63ec1352f08832d5ec7735a7446e0a0c6 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 13 Jan 2020 15:45:51 -0800 Subject: [PATCH 245/673] 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 246/673] 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 247/673] 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 248/673] 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 249/673] 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 250/673] 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 251/673] 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 252/673] 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 253/673] 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 254/673] 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 255/673] 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 256/673] 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 257/673] 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 258/673] 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 259/673] 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 260/673] 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 261/673] 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 262/673] 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 263/673] 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 264/673] 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 265/673] 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 a8e7d41f95427cf8c76498c05520cb2f57954f22 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 17 Jan 2020 14:13:43 -0800 Subject: [PATCH 266/673] (5.0) increase the minimum Java version all the way to 8! --- README.md | 2 +- build.gradle | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index adbf2eeb7..1b01e0f55 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ LaunchDarkly overview Supported Java versions ----------------------- -This version of the LaunchDarkly SDK works with Java 7 and above. +This version of the LaunchDarkly SDK works with Java 8 and above. Distributions ------------- diff --git a/build.gradle b/build.gradle index f4c16c321..64a70ebbc 100644 --- a/build.gradle +++ b/build.gradle @@ -22,8 +22,8 @@ repositories { allprojects { group = 'com.launchdarkly' version = "${version}" - sourceCompatibility = 1.7 - targetCompatibility = 1.7 + sourceCompatibility = 1.8 + targetCompatibility = 1.8 } ext { From d6903424a0dfbff52c7cecd85595017486853aa7 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 17 Jan 2020 14:16:59 -0800 Subject: [PATCH 267/673] fix merge error --- .../java/com/launchdarkly/client/LDClientEvaluationTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java index 295668058..4eebe9ec7 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java @@ -24,6 +24,7 @@ import static com.launchdarkly.client.TestUtil.specificDataStore; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; @SuppressWarnings("javadoc") @@ -134,7 +135,7 @@ public void stringVariationReturnsFlagValue() throws Exception { @Test public void stringVariationWithNullDefaultReturnsFlagValue() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of("b"))); + dataStore.upsert(FEATURES, flagWithValue("key", LDValue.of("b"))); assertEquals("b", client.stringVariation("key", user, null)); } From e1eeff28967f0812a0e604f5839f626a903a7cec Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 17 Jan 2020 15:12:15 -0800 Subject: [PATCH 268/673] (5.0) update to latest Guava --- build.gradle | 2 +- src/main/java/com/launchdarkly/client/DataModel.java | 7 +------ src/main/java/com/launchdarkly/client/value/LDValue.java | 7 +------ src/test/java/com/launchdarkly/client/LDClientTest.java | 7 +------ 4 files changed, 4 insertions(+), 19 deletions(-) diff --git a/build.gradle b/build.gradle index 64a70ebbc..9ea2ff34f 100644 --- a/build.gradle +++ b/build.gradle @@ -42,7 +42,7 @@ ext.libraries = [:] // in the other two SDK jars. libraries.internal = [ "commons-codec:commons-codec:1.10", - "com.google.guava:guava:19.0", + "com.google.guava:guava:28.2-jre", "joda-time:joda-time:2.9.3", "com.launchdarkly:okhttp-eventsource:1.10.0", "org.yaml:snakeyaml:1.19", diff --git a/src/main/java/com/launchdarkly/client/DataModel.java b/src/main/java/com/launchdarkly/client/DataModel.java index 27db8b95e..a5a101a08 100644 --- a/src/main/java/com/launchdarkly/client/DataModel.java +++ b/src/main/java/com/launchdarkly/client/DataModel.java @@ -1,6 +1,5 @@ package com.launchdarkly.client; -import com.google.common.base.Function; import com.google.common.collect.ImmutableList; import com.google.gson.Gson; import com.google.gson.annotations.JsonAdapter; @@ -42,11 +41,7 @@ public Iterable getDependencyKeys(VersionedData item) { if (flag.getPrerequisites() == null || flag.getPrerequisites().isEmpty()) { return ImmutableList.of(); } - return transform(flag.getPrerequisites(), new Function() { - public String apply(DataModel.Prerequisite p) { - return p.getKey(); - } - }); + return transform(flag.getPrerequisites(), p -> p.getKey()); } }; diff --git a/src/main/java/com/launchdarkly/client/value/LDValue.java b/src/main/java/com/launchdarkly/client/value/LDValue.java index e91e71c3a..9a291cffb 100644 --- a/src/main/java/com/launchdarkly/client/value/LDValue.java +++ b/src/main/java/com/launchdarkly/client/value/LDValue.java @@ -1,6 +1,5 @@ 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; @@ -303,11 +302,7 @@ public Iterable values() { * @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); - } - }); + return Iterables.transform(values(), converter::toType); } /** diff --git a/src/test/java/com/launchdarkly/client/LDClientTest.java b/src/test/java/com/launchdarkly/client/LDClientTest.java index 940979c60..f94fd9bb6 100644 --- a/src/test/java/com/launchdarkly/client/LDClientTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientTest.java @@ -1,6 +1,5 @@ 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; @@ -326,11 +325,7 @@ public void dataSetIsPassedToDataStoreInCorrectOrder() throws Exception { DataModel.FeatureFlag depFlag = (DataModel.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(); - } - }); + Iterable allKeys = Iterables.transform(list1, d -> 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))); From 9a8fcbe2af3ae0a24f9947de91ee285d815c686e Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 17 Jan 2020 15:37:49 -0800 Subject: [PATCH 269/673] remove joda-time, use Java 8 Date API --- build.gradle | 1 - .../client/DefaultEventProcessor.java | 6 +- .../client/EvaluatorOperators.java | 16 +- .../com/launchdarkly/client/LDClient.java | 6 +- .../com/launchdarkly/client/LDConfig.java | 150 +++++++----------- .../launchdarkly/client/PollingProcessor.java | 4 +- .../launchdarkly/client/StreamProcessor.java | 2 +- .../java/com/launchdarkly/client/Util.java | 6 +- .../client/LDClientEvaluationTest.java | 3 +- .../com/launchdarkly/client/LDClientTest.java | 27 ++-- .../com/launchdarkly/client/LDConfigTest.java | 9 +- .../com/launchdarkly/client/UtilTest.java | 36 +---- 12 files changed, 103 insertions(+), 163 deletions(-) diff --git a/build.gradle b/build.gradle index 9ea2ff34f..2fde39af5 100644 --- a/build.gradle +++ b/build.gradle @@ -43,7 +43,6 @@ ext.libraries = [:] libraries.internal = [ "commons-codec:commons-codec:1.10", "com.google.guava:guava:28.2-jre", - "joda-time:joda-time:2.9.3", "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/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java index c8ba91bf0..b512d35e0 100644 --- a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java @@ -66,14 +66,14 @@ public void run() { postMessageAsync(MessageType.FLUSH, null); } }; - this.scheduler.scheduleAtFixedRate(flusher, config.flushInterval, config.flushInterval, TimeUnit.SECONDS); + this.scheduler.scheduleAtFixedRate(flusher, config.flushInterval.toMillis(), config.flushInterval.toMillis(), TimeUnit.MILLISECONDS); 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, config.userKeysFlushInterval.toMillis(), config.userKeysFlushInterval.toMillis(), + TimeUnit.MILLISECONDS); } @Override diff --git a/src/main/java/com/launchdarkly/client/EvaluatorOperators.java b/src/main/java/com/launchdarkly/client/EvaluatorOperators.java index fbb38c325..9250ec513 100644 --- a/src/main/java/com/launchdarkly/client/EvaluatorOperators.java +++ b/src/main/java/com/launchdarkly/client/EvaluatorOperators.java @@ -2,9 +2,9 @@ import com.launchdarkly.client.value.LDValue; -import org.joda.time.DateTime; -import org.joda.time.DateTimeZone; - +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; import java.util.regex.Pattern; /** @@ -99,8 +99,8 @@ private static boolean compareNumeric(ComparisonOp op, LDValue userValue, LDValu } private static boolean compareDate(ComparisonOp op, LDValue userValue, LDValue clauseValue) { - DateTime dt1 = valueToDateTime(userValue); - DateTime dt2 = valueToDateTime(clauseValue); + ZonedDateTime dt1 = valueToDateTime(userValue); + ZonedDateTime dt2 = valueToDateTime(clauseValue); if (dt1 == null || dt2 == null) { return false; } @@ -116,12 +116,12 @@ private static boolean compareSemVer(ComparisonOp op, LDValue userValue, LDValue return op.test(sv1.compareTo(sv2)); } - private static DateTime valueToDateTime(LDValue value) { + private static ZonedDateTime valueToDateTime(LDValue value) { if (value.isNumber()) { - return new DateTime(value.longValue()); + return ZonedDateTime.ofInstant(Instant.ofEpochMilli(value.longValue()), ZoneId.systemDefault()); } else if (value.isString()) { try { - return new DateTime(value.stringValue(), DateTimeZone.UTC); + return ZonedDateTime.parse(value.stringValue()); } catch (Throwable t) { return null; } diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index 6d1c4909a..c8050de92 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -92,12 +92,12 @@ public DataModel.Segment getSegment(String key) { Components.defaultDataSource() : config.dataSourceFactory; this.dataSource = dataSourceFactory.createDataSource(sdkKey, config, dataStore); Future startFuture = dataSource.start(); - if (config.startWaitMillis > 0L) { + if (!config.startWait.isZero() && !config.startWait.isNegative()) { if (!config.offline && !config.useLdd) { - logger.info("Waiting up to " + config.startWaitMillis + " milliseconds for LaunchDarkly client to start..."); + logger.info("Waiting up to " + config.startWait.toMillis() + " milliseconds for LaunchDarkly client to start..."); } try { - startFuture.get(config.startWaitMillis, TimeUnit.MILLISECONDS); + startFuture.get(config.startWait.toMillis(), 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 f686b5ded..9960939dc 100644 --- a/src/main/java/com/launchdarkly/client/LDConfig.java +++ b/src/main/java/com/launchdarkly/client/LDConfig.java @@ -14,10 +14,10 @@ import java.net.InetSocketAddress; import java.net.Proxy; import java.net.URI; +import java.time.Duration; import java.util.Arrays; import java.util.HashSet; import java.util.Set; -import java.util.concurrent.TimeUnit; import javax.net.ssl.SSLSocketFactory; import javax.net.ssl.X509TrustManager; @@ -39,14 +39,14 @@ public final class LDConfig { 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"); 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 = 30000L; - private static final long DEFAULT_START_WAIT_MILLIS = 5000L; + private static final Duration DEFAULT_CONNECT_TIMEOUT = Duration.ofSeconds(2); + private static final Duration DEFAULT_SOCKET_TIMEOUT = Duration.ofSeconds(10); + private static final Duration DEFAULT_FLUSH_INTERVAL = Duration.ofSeconds(5); + private static final Duration MIN_POLLING_INTERVAL = Duration.ofSeconds(30); + private static final Duration DEFAULT_START_WAIT = Duration.ofSeconds(5); 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 Duration DEFAULT_USER_KEYS_FLUSH_INTERVAL = Duration.ofMinutes(5); + private static final Duration DEFAULT_RECONNECT_TIME = Duration.ofSeconds(1); protected static final LDConfig DEFAULT = new Builder().build(); @@ -54,7 +54,7 @@ public final class LDConfig { final URI eventsURI; final URI streamURI; final int capacity; - final int flushInterval; + final Duration flushInterval; final Proxy proxy; final Authenticator proxyAuthenticator; final boolean stream; @@ -66,24 +66,22 @@ public final class LDConfig { final boolean allAttributesPrivate; final Set privateAttrNames; final boolean sendEvents; - final long pollingIntervalMillis; - final long startWaitMillis; - final long reconnectTimeMs; + final Duration pollingInterval; + final Duration startWait; + final Duration reconnectTime; final int userKeysCapacity; - final int userKeysFlushInterval; + final Duration userKeysFlushInterval; final boolean inlineUsersInEvents; final SSLSocketFactory sslSocketFactory; final X509TrustManager trustManager; - final int connectTimeout; - final TimeUnit connectTimeoutUnit; - final int socketTimeout; - final TimeUnit socketTimeoutUnit; + final Duration connectTimeout; + final Duration socketTimeout; protected LDConfig(Builder builder) { this.baseURI = builder.baseURI; this.eventsURI = builder.eventsURI; this.capacity = builder.capacity; - this.flushInterval = builder.flushIntervalSeconds; + this.flushInterval = builder.flushInterval; this.proxy = builder.proxy(); this.proxyAuthenticator = builder.proxyAuthenticator(); this.streamURI = builder.streamURI; @@ -96,22 +94,20 @@ protected LDConfig(Builder builder) { this.allAttributesPrivate = builder.allAttributesPrivate; this.privateAttrNames = new HashSet<>(builder.privateAttrNames); this.sendEvents = builder.sendEvents; - if (builder.pollingIntervalMillis < MIN_POLLING_INTERVAL_MILLIS) { - this.pollingIntervalMillis = MIN_POLLING_INTERVAL_MILLIS; + if (builder.pollingInterval.compareTo(MIN_POLLING_INTERVAL) < 0) { + this.pollingInterval = MIN_POLLING_INTERVAL; } else { - this.pollingIntervalMillis = builder.pollingIntervalMillis; + this.pollingInterval = builder.pollingInterval; } - this.startWaitMillis = builder.startWaitMillis; - this.reconnectTimeMs = builder.reconnectTimeMillis; + this.startWait = builder.startWait; + this.reconnectTime = builder.reconnectTime; 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; if (proxy != null) { if (proxyAuthenticator != null) { @@ -137,12 +133,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 connectTimeout = DEFAULT_CONNECT_TIMEOUT_MILLIS; - private TimeUnit connectTimeoutUnit = TimeUnit.MILLISECONDS; - private int socketTimeout = DEFAULT_SOCKET_TIMEOUT_MILLIS; - private TimeUnit socketTimeoutUnit = TimeUnit.MILLISECONDS; + private Duration connectTimeout = DEFAULT_CONNECT_TIMEOUT; + private Duration socketTimeout = DEFAULT_SOCKET_TIMEOUT; private int capacity = DEFAULT_CAPACITY; - private int flushIntervalSeconds = DEFAULT_FLUSH_INTERVAL_SECONDS; + private Duration flushInterval = DEFAULT_FLUSH_INTERVAL; private String proxyHost = "localhost"; private int proxyPort = -1; private String proxyUsername = null; @@ -152,15 +146,15 @@ public static class Builder { private boolean offline = false; private boolean allAttributesPrivate = false; private boolean sendEvents = true; - private long pollingIntervalMillis = MIN_POLLING_INTERVAL_MILLIS; + private Duration pollingInterval = MIN_POLLING_INTERVAL; private DataStoreFactory dataStoreFactory = Components.inMemoryDataStore(); private EventProcessorFactory eventProcessorFactory = Components.defaultEventProcessor(); private DataSourceFactory dataSourceFactory = Components.defaultDataSource(); - private long startWaitMillis = DEFAULT_START_WAIT_MILLIS; - private long reconnectTimeMillis = DEFAULT_RECONNECT_TIME_MILLIS; + private Duration startWait = DEFAULT_START_WAIT; + private Duration reconnectTime = DEFAULT_RECONNECT_TIME; private Set privateAttrNames = new HashSet<>(); private int userKeysCapacity = DEFAULT_USER_KEYS_CAPACITY; - private int userKeysFlushInterval = DEFAULT_USER_KEYS_FLUSH_INTERVAL_SECONDS; + private Duration userKeysFlushInterval = DEFAULT_USER_KEYS_FLUSH_INTERVAL; private boolean inlineUsersInEvents = false; private SSLSocketFactory sslSocketFactory = null; private X509TrustManager trustManager = null; @@ -259,70 +253,38 @@ 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 + * Set the connection timeout 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.

    * - * @param connectTimeout the connection timeout in seconds + * @param connectTimeout the connection timeout; null to use the default * @return the builder */ - public Builder connectTimeout(int connectTimeout) { - this.connectTimeout = connectTimeout; - this.connectTimeoutUnit = TimeUnit.SECONDS; + public Builder connectTimeout(Duration connectTimeout) { + this.connectTimeout = connectTimeout == null ? DEFAULT_CONNECT_TIMEOUT : connectTimeout; return this; } /** - * Set the socket timeout in seconds for the configuration. This is the number of seconds between successive packets that the + * Set the socket timeout 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.

    * - * @param socketTimeout the socket timeout in seconds + * @param socketTimeout the socket timeout; null to use the default * @return the builder */ - public Builder socketTimeout(int socketTimeout) { - this.socketTimeout = socketTimeout; - this.socketTimeoutUnit = TimeUnit.SECONDS; + public Builder socketTimeout(Duration socketTimeout) { + this.socketTimeout = socketTimeout == null ? DEFAULT_SOCKET_TIMEOUT : socketTimeout; return this; } /** - * 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.

    - * - * @param connectTimeoutMillis the connection timeout in milliseconds - * @return the builder - */ - public Builder connectTimeoutMillis(int connectTimeoutMillis) { - this.connectTimeout = connectTimeoutMillis; - this.connectTimeoutUnit = TimeUnit.MILLISECONDS; - 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.

    - * - * @param socketTimeoutMillis the socket timeout in milliseconds - * @return the builder - */ - public Builder socketTimeoutMillis(int socketTimeoutMillis) { - this.socketTimeout = socketTimeoutMillis; - this.socketTimeoutUnit = TimeUnit.MILLISECONDS; - return this; - } - - /** - * Set the number of seconds between flushes of the event buffer. Decreasing the flush interval means + * Set 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 5 seconds. * - * @param flushInterval the flush interval in seconds + * @param flushInterval the flush interval; null to use the default * @return the builder */ - public Builder flushInterval(int flushInterval) { - this.flushIntervalSeconds = flushInterval; + public Builder flushInterval(Duration flushInterval) { + this.flushInterval = flushInterval == null ? DEFAULT_FLUSH_INTERVAL : flushInterval; return this; } @@ -454,39 +416,39 @@ 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. + * 30 seconds will be set to the default. * - * @param pollingIntervalMillis rule update polling interval in milliseconds + * @param pollingInterval the new polling interval; null to use the default * @return the builder */ - public Builder pollingIntervalMillis(long pollingIntervalMillis) { - this.pollingIntervalMillis = pollingIntervalMillis; + public Builder pollingInterval(Duration pollingInterval) { + this.pollingInterval = pollingInterval == null ? MIN_POLLING_INTERVAL : pollingInterval; return this; } /** * Set how long the constructor will block awaiting a successful connection to LaunchDarkly. - * Setting this to 0 will not block and cause the constructor to return immediately. + * Setting this to a zero or negative duration will not block and cause the constructor to return immediately. * Default value: 5000 * - * @param startWaitMillis milliseconds to wait + * @param startWait maximum time to wait; null to use the default * @return the builder */ - public Builder startWaitMillis(long startWaitMillis) { - this.startWaitMillis = startWaitMillis; + public Builder startWait(Duration startWait) { + this.startWait = startWait == null ? DEFAULT_START_WAIT : startWait; return this; } /** - * The reconnect base time in milliseconds for the streaming connection. The streaming connection + * The reconnect base time 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. * - * @param reconnectTimeMs the reconnect time base value in milliseconds + * @param reconnectTime the initial reconnect time; null to use the default * @return the builder */ - public Builder reconnectTimeMs(long reconnectTimeMs) { - this.reconnectTimeMillis = reconnectTimeMs; + public Builder reconnectTime(Duration reconnectTime) { + this.reconnectTime = reconnectTime == null ? DEFAULT_RECONNECT_TIME : reconnectTime; return this; } @@ -515,14 +477,14 @@ public Builder userKeysCapacity(int capacity) { } /** - * Sets the interval in seconds at which the event processor will reset its set of known user keys. The + * Sets the interval at which the event processor will reset its set of known user keys. The * default value is five minutes. * - * @param flushInterval the flush interval in seconds + * @param userKeysFlushInterval the flush interval; null to use the default * @return the builder */ - public Builder userKeysFlushInterval(int flushInterval) { - this.userKeysFlushInterval = flushInterval; + public Builder userKeysFlushInterval(Duration userKeysFlushInterval) { + this.userKeysFlushInterval = userKeysFlushInterval == null ? DEFAULT_USER_KEYS_FLUSH_INTERVAL : userKeysFlushInterval; return this; } diff --git a/src/main/java/com/launchdarkly/client/PollingProcessor.java b/src/main/java/com/launchdarkly/client/PollingProcessor.java index 3e7eb4840..75d321ffb 100644 --- a/src/main/java/com/launchdarkly/client/PollingProcessor.java +++ b/src/main/java/com/launchdarkly/client/PollingProcessor.java @@ -49,7 +49,7 @@ public void close() throws IOException { @Override public Future start() { logger.info("Starting LaunchDarkly polling client with interval: " - + config.pollingIntervalMillis + " milliseconds"); + + config.pollingInterval.toMillis() + " milliseconds"); final SettableFuture initFuture = SettableFuture.create(); ThreadFactory threadFactory = new ThreadFactoryBuilder() .setNameFormat("LaunchDarkly-PollingProcessor-%d") @@ -77,7 +77,7 @@ public void run() { logger.debug(e.toString(), e); } } - }, 0L, config.pollingIntervalMillis, TimeUnit.MILLISECONDS); + }, 0L, config.pollingInterval.toMillis(), 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 95f35a7ab..5ffa6a99a 100644 --- a/src/main/java/com/launchdarkly/client/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/client/StreamProcessor.java @@ -258,7 +258,7 @@ public void configure(OkHttpClient.Builder builder) { }) .connectionErrorHandler(errorHandler) .headers(headers) - .reconnectTimeMs(config.reconnectTimeMs) + .reconnectTimeMs(config.reconnectTime.toMillis()) .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/Util.java b/src/main/java/com/launchdarkly/client/Util.java index 712c9ffd9..d5041988d 100644 --- a/src/main/java/com/launchdarkly/client/Util.java +++ b/src/main/java/com/launchdarkly/client/Util.java @@ -9,9 +9,9 @@ class Util { 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) + .connectTimeout(config.connectTimeout.toMillis(), TimeUnit.MILLISECONDS) + .readTimeout(config.socketTimeout.toMillis(), TimeUnit.MILLISECONDS) + .writeTimeout(config.socketTimeout.toMillis(), TimeUnit.MILLISECONDS) .retryOnConnectionFailure(false); // we will implement our own retry logic if (config.sslSocketFactory != null) { diff --git a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java index 4eebe9ec7..753d82f19 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java @@ -8,6 +8,7 @@ import org.junit.Test; +import java.time.Duration; import java.util.Map; import static com.launchdarkly.client.DataModel.DataKinds.FEATURES; @@ -223,7 +224,7 @@ public void appropriateErrorIfClientNotInitialized() throws Exception { .dataStore(specificDataStore(badDataStore)) .eventProcessor(Components.nullEventProcessor()) .dataSource(specificDataSource(failedDataSource())) - .startWaitMillis(0) + .startWait(Duration.ZERO) .build(); try (LDClientInterface badClient = new LDClient("SDK_KEY", badConfig)) { EvaluationDetail expectedResult = EvaluationDetail.fromValue(false, null, diff --git a/src/test/java/com/launchdarkly/client/LDClientTest.java b/src/test/java/com/launchdarkly/client/LDClientTest.java index f94fd9bb6..f7c5e2c49 100644 --- a/src/test/java/com/launchdarkly/client/LDClientTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientTest.java @@ -20,6 +20,7 @@ import java.io.IOException; import java.net.URI; +import java.time.Duration; import java.util.List; import java.util.Map; import java.util.concurrent.Future; @@ -96,7 +97,7 @@ public void clientHasDefaultEventProcessorIfSendEventsIsTrue() throws Exception LDConfig config = new LDConfig.Builder() .stream(false) .baseURI(URI.create("/fake")) - .startWaitMillis(0) + .startWait(Duration.ZERO) .sendEvents(true) .build(); try (LDClient client = new LDClient("SDK_KEY", config)) { @@ -109,7 +110,7 @@ public void clientHasNullEventProcessorIfSendEventsIsFalse() throws IOException LDConfig config = new LDConfig.Builder() .stream(false) .baseURI(URI.create("/fake")) - .startWaitMillis(0) + .startWait(Duration.ZERO) .sendEvents(false) .build(); try (LDClient client = new LDClient("SDK_KEY", config)) { @@ -122,7 +123,7 @@ public void streamingClientHasStreamProcessor() throws Exception { LDConfig config = new LDConfig.Builder() .stream(true) .streamURI(URI.create("http://fake")) - .startWaitMillis(0) + .startWait(Duration.ZERO) .build(); try (LDClient client = new LDClient("SDK_KEY", config)) { assertEquals(StreamProcessor.class, client.dataSource.getClass()); @@ -134,7 +135,7 @@ public void pollingClientHasPollingProcessor() throws IOException { LDConfig config = new LDConfig.Builder() .stream(false) .baseURI(URI.create("http://fake")) - .startWaitMillis(0) + .startWait(Duration.ZERO) .build(); try (LDClient client = new LDClient("SDK_KEY", config)) { assertEquals(PollingProcessor.class, client.dataSource.getClass()); @@ -144,7 +145,7 @@ public void pollingClientHasPollingProcessor() throws IOException { @Test public void noWaitForDataSourceIfWaitMillisIsZero() throws Exception { LDConfig.Builder config = new LDConfig.Builder() - .startWaitMillis(0L); + .startWait(Duration.ZERO); expect(dataSource.start()).andReturn(initFuture); expect(dataSource.initialized()).andReturn(false); @@ -159,7 +160,7 @@ public void noWaitForDataSourceIfWaitMillisIsZero() throws Exception { @Test public void willWaitForDataSourceIfWaitMillisIsNonZero() throws Exception { LDConfig.Builder config = new LDConfig.Builder() - .startWaitMillis(10L); + .startWait(Duration.ofMillis(10)); expect(dataSource.start()).andReturn(initFuture); expect(initFuture.get(10L, TimeUnit.MILLISECONDS)).andReturn(null); @@ -175,7 +176,7 @@ public void willWaitForDataSourceIfWaitMillisIsNonZero() throws Exception { @Test public void dataSourceCanTimeOut() throws Exception { LDConfig.Builder config = new LDConfig.Builder() - .startWaitMillis(10L); + .startWait(Duration.ofMillis(10)); expect(dataSource.start()).andReturn(initFuture); expect(initFuture.get(10L, TimeUnit.MILLISECONDS)).andThrow(new TimeoutException()); @@ -191,7 +192,7 @@ public void dataSourceCanTimeOut() throws Exception { @Test public void clientCatchesRuntimeExceptionFromDataSource() throws Exception { LDConfig.Builder config = new LDConfig.Builder() - .startWaitMillis(10L); + .startWait(Duration.ofMillis(10)); expect(dataSource.start()).andReturn(initFuture); expect(initFuture.get(10L, TimeUnit.MILLISECONDS)).andThrow(new RuntimeException()); @@ -208,7 +209,7 @@ public void clientCatchesRuntimeExceptionFromDataSource() throws Exception { public void isFlagKnownReturnsTrueForExistingFlag() throws Exception { DataStore testDataStore = initedDataStore(); LDConfig.Builder config = new LDConfig.Builder() - .startWaitMillis(0) + .startWait(Duration.ZERO) .dataStore(specificDataStore(testDataStore)); expect(dataSource.start()).andReturn(initFuture); expect(dataSource.initialized()).andReturn(true).times(1); @@ -225,7 +226,7 @@ public void isFlagKnownReturnsTrueForExistingFlag() throws Exception { public void isFlagKnownReturnsFalseForUnknownFlag() throws Exception { DataStore testDataStore = initedDataStore(); LDConfig.Builder config = new LDConfig.Builder() - .startWaitMillis(0) + .startWait(Duration.ZERO) .dataStore(specificDataStore(testDataStore)); expect(dataSource.start()).andReturn(initFuture); expect(dataSource.initialized()).andReturn(true).times(1); @@ -241,7 +242,7 @@ public void isFlagKnownReturnsFalseForUnknownFlag() throws Exception { public void isFlagKnownReturnsFalseIfStoreAndClientAreNotInitialized() throws Exception { DataStore testDataStore = new InMemoryDataStore(); LDConfig.Builder config = new LDConfig.Builder() - .startWaitMillis(0) + .startWait(Duration.ZERO) .dataStore(specificDataStore(testDataStore)); expect(dataSource.start()).andReturn(initFuture); expect(dataSource.initialized()).andReturn(false).times(1); @@ -258,7 +259,7 @@ public void isFlagKnownReturnsFalseIfStoreAndClientAreNotInitialized() throws Ex public void isFlagKnownUsesStoreIfStoreIsInitializedButClientIsNot() throws Exception { DataStore testDataStore = initedDataStore(); LDConfig.Builder config = new LDConfig.Builder() - .startWaitMillis(0) + .startWait(Duration.ZERO) .dataStore(specificDataStore(testDataStore)); expect(dataSource.start()).andReturn(initFuture); expect(dataSource.initialized()).andReturn(false).times(1); @@ -276,7 +277,7 @@ public void evaluationUsesStoreIfStoreIsInitializedButClientIsNot() throws Excep DataStore testDataStore = initedDataStore(); LDConfig.Builder config = new LDConfig.Builder() .dataStore(specificDataStore(testDataStore)) - .startWaitMillis(0L); + .startWait(Duration.ZERO); expect(dataSource.start()).andReturn(initFuture); expect(dataSource.initialized()).andReturn(false); expectEventsSent(1); diff --git a/src/test/java/com/launchdarkly/client/LDConfigTest.java b/src/test/java/com/launchdarkly/client/LDConfigTest.java index f7bfb689a..f083baf05 100644 --- a/src/test/java/com/launchdarkly/client/LDConfigTest.java +++ b/src/test/java/com/launchdarkly/client/LDConfigTest.java @@ -4,6 +4,7 @@ import java.net.InetSocketAddress; import java.net.Proxy; +import java.time.Duration; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; @@ -71,14 +72,14 @@ public void testProxyAuthPartialConfig() { @Test public void testMinimumPollingIntervalIsEnforcedProperly(){ - LDConfig config = new LDConfig.Builder().pollingIntervalMillis(10L).build(); - assertEquals(30000L, config.pollingIntervalMillis); + LDConfig config = new LDConfig.Builder().pollingInterval(Duration.ofSeconds(10)).build(); + assertEquals(Duration.ofSeconds(30), config.pollingInterval); } @Test public void testPollingIntervalIsEnforcedProperly(){ - LDConfig config = new LDConfig.Builder().pollingIntervalMillis(30001L).build(); - assertEquals(30001L, config.pollingIntervalMillis); + LDConfig config = new LDConfig.Builder().pollingInterval(Duration.ofMillis(30001)).build(); + assertEquals(Duration.ofMillis(30001), config.pollingInterval); } @Test diff --git a/src/test/java/com/launchdarkly/client/UtilTest.java b/src/test/java/com/launchdarkly/client/UtilTest.java index b607536de..1f7cccdde 100644 --- a/src/test/java/com/launchdarkly/client/UtilTest.java +++ b/src/test/java/com/launchdarkly/client/UtilTest.java @@ -2,6 +2,8 @@ import org.junit.Test; +import java.time.Duration; + import static com.launchdarkly.client.Util.configureHttpClientBuilder; import static com.launchdarkly.client.Util.shutdownHttpClient; import static org.junit.Assert.assertEquals; @@ -11,21 +13,8 @@ @SuppressWarnings("javadoc") public class UtilTest { @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(); + public void testConnectTimeout() { + LDConfig config = new LDConfig.Builder().connectTimeout(Duration.ofSeconds(3)).build(); OkHttpClient.Builder httpBuilder = new OkHttpClient.Builder(); configureHttpClientBuilder(config, httpBuilder); OkHttpClient httpClient = httpBuilder.build(); @@ -35,23 +24,10 @@ public void testConnectTimeoutSpecifiedInMilliseconds() { 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(); + public void testSocketTimeout() { + LDConfig config = new LDConfig.Builder().socketTimeout(Duration.ofSeconds(3)).build(); OkHttpClient.Builder httpBuilder = new OkHttpClient.Builder(); configureHttpClientBuilder(config, httpBuilder); OkHttpClient httpClient = httpBuilder.build(); From 0b9d7c6ec43db976fccc36de8f3fc511d45fcfb0 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 17 Jan 2020 15:57:20 -0800 Subject: [PATCH 270/673] rm some more uses of TimeUnit --- .../client/DataStoreCacheConfig.java | 66 +++++++------------ .../integrations/RedisDataStoreBuilder.java | 20 +++--- .../integrations/RedisDataStoreImpl.java | 4 +- .../client/utils/CachingStoreWrapper.java | 10 +-- .../client/DataStoreCachingTest.java | 37 ++++------- .../RedisDataStoreBuilderTest.java | 14 ++-- 6 files changed, 57 insertions(+), 94 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/DataStoreCacheConfig.java b/src/main/java/com/launchdarkly/client/DataStoreCacheConfig.java index 01dcf9ed8..03cd89c47 100644 --- a/src/main/java/com/launchdarkly/client/DataStoreCacheConfig.java +++ b/src/main/java/com/launchdarkly/client/DataStoreCacheConfig.java @@ -3,6 +3,7 @@ import com.google.common.cache.CacheBuilder; import com.launchdarkly.client.interfaces.DataStore; +import java.time.Duration; import java.util.Objects; import java.util.concurrent.TimeUnit; @@ -29,22 +30,21 @@ */ public final class DataStoreCacheConfig { /** - * The default TTL, in seconds, used by {@link #DEFAULT}. + * The default TTL used by {@link #DEFAULT}. */ - public static final long DEFAULT_TIME_SECONDS = 15; + public static final Duration DEFAULT_TIME = Duration.ofSeconds(15); /** * The caching parameters that the data store should use by default. Caching is enabled, with a - * TTL of {@link #DEFAULT_TIME_SECONDS} and the {@link StaleValuesPolicy#EVICT} policy. + * TTL of {@link #DEFAULT_TIME} and the {@link StaleValuesPolicy#EVICT} policy. */ public static final DataStoreCacheConfig DEFAULT = - new DataStoreCacheConfig(DEFAULT_TIME_SECONDS, TimeUnit.SECONDS, StaleValuesPolicy.EVICT); + new DataStoreCacheConfig(DEFAULT_TIME, StaleValuesPolicy.EVICT); private static final DataStoreCacheConfig DISABLED = - new DataStoreCacheConfig(0, TimeUnit.MILLISECONDS, StaleValuesPolicy.EVICT); + new DataStoreCacheConfig(Duration.ZERO, StaleValuesPolicy.EVICT); - private final long cacheTime; - private final TimeUnit cacheTimeUnit; + private final Duration cacheTime; private final StaleValuesPolicy staleValuesPolicy; /** @@ -103,7 +103,7 @@ public static DataStoreCacheConfig 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 + * {@link #DEFAULT_TIME}. You can further modify the cache properties using the other * methods of this class. * @return a {@link DataStoreCacheConfig} instance */ @@ -111,9 +111,8 @@ public static DataStoreCacheConfig enabled() { return DEFAULT; } - private DataStoreCacheConfig(long cacheTime, TimeUnit cacheTimeUnit, StaleValuesPolicy staleValuesPolicy) { - this.cacheTime = cacheTime; - this.cacheTimeUnit = cacheTimeUnit; + private DataStoreCacheConfig(Duration cacheTime, StaleValuesPolicy staleValuesPolicy) { + this.cacheTime = cacheTime == null ? DEFAULT_TIME : cacheTime; this.staleValuesPolicy = staleValuesPolicy; } @@ -122,34 +121,17 @@ private DataStoreCacheConfig(long cacheTime, TimeUnit cacheTimeUnit, StaleValues * @return true if the cache TTL is greater than 0 */ public boolean isEnabled() { - return getCacheTime() > 0; + return !cacheTime.isZero() && !cacheTime.isNegative(); } /** * Returns the cache TTL. Caching is enabled if this is greater than zero. - * @return the cache TTL in whatever units were specified - * @see #getCacheTimeUnit() + * @return the cache TTL */ - public long getCacheTime() { + public Duration 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 @@ -163,32 +145,31 @@ public StaleValuesPolicy getStaleValuesPolicy() { * 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 + * @param cacheTime the cache TTL * @return an updated parameters object */ - public DataStoreCacheConfig ttl(long cacheTime, TimeUnit timeUnit) { - return new DataStoreCacheConfig(cacheTime, timeUnit, staleValuesPolicy); + public DataStoreCacheConfig ttl(Duration cacheTime) { + return new DataStoreCacheConfig(cacheTime, staleValuesPolicy); } /** - * Shortcut for calling {@link #ttl(long, TimeUnit)} with {@link TimeUnit#MILLISECONDS}. + * Shortcut for calling {@link #ttl(Duration)} with a duration in milliseconds. * * @param millis the cache TTL in milliseconds * @return an updated parameters object */ public DataStoreCacheConfig ttlMillis(long millis) { - return ttl(millis, TimeUnit.MILLISECONDS); + return ttl(Duration.ofMillis(millis)); } /** - * Shortcut for calling {@link #ttl(long, TimeUnit)} with {@link TimeUnit#SECONDS}. + * Shortcut for calling {@link #ttl(Duration)} with a duration in seconds. * * @param seconds the cache TTL in seconds * @return an updated parameters object */ public DataStoreCacheConfig ttlSeconds(long seconds) { - return ttl(seconds, TimeUnit.SECONDS); + return ttl(Duration.ofSeconds(seconds)); } /** @@ -199,21 +180,20 @@ public DataStoreCacheConfig ttlSeconds(long seconds) { * @return an updated parameters object */ public DataStoreCacheConfig staleValuesPolicy(StaleValuesPolicy policy) { - return new DataStoreCacheConfig(cacheTime, cacheTimeUnit, policy); + return new DataStoreCacheConfig(cacheTime, policy); } @Override public boolean equals(Object other) { if (other instanceof DataStoreCacheConfig) { DataStoreCacheConfig o = (DataStoreCacheConfig) other; - return o.cacheTime == this.cacheTime && o.cacheTimeUnit == this.cacheTimeUnit && - o.staleValuesPolicy == this.staleValuesPolicy; + return o.cacheTime.equals(this.cacheTime) && o.staleValuesPolicy == this.staleValuesPolicy; } return false; } @Override public int hashCode() { - return Objects.hash(cacheTime, cacheTimeUnit, staleValuesPolicy); + return Objects.hash(cacheTime, staleValuesPolicy); } } diff --git a/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java b/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java index 96f72884e..27b2d86b0 100644 --- a/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java +++ b/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java @@ -6,7 +6,7 @@ import com.launchdarkly.client.utils.CachingStoreWrapper; import java.net.URI; -import java.util.concurrent.TimeUnit; +import java.time.Duration; import static com.google.common.base.Preconditions.checkNotNull; @@ -49,8 +49,8 @@ public final class RedisDataStoreBuilder implements DataStoreFactory { URI uri = DEFAULT_URI; String prefix = DEFAULT_PREFIX; - int connectTimeout = Protocol.DEFAULT_TIMEOUT; - int socketTimeout = Protocol.DEFAULT_TIMEOUT; + Duration connectTimeout = Duration.ofMillis(Protocol.DEFAULT_TIMEOUT); + Duration socketTimeout = Duration.ofMillis(Protocol.DEFAULT_TIMEOUT); Integer database = null; String password = null; boolean tls = false; @@ -152,27 +152,25 @@ public RedisDataStoreBuilder poolConfig(JedisPoolConfig poolConfig) { /** * Optional override which sets the connection timeout for the underlying Jedis pool which otherwise defaults to - * {@link redis.clients.jedis.Protocol#DEFAULT_TIMEOUT} + * {@link redis.clients.jedis.Protocol#DEFAULT_TIMEOUT} milliseconds. * * @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); + public RedisDataStoreBuilder connectTimeout(Duration connectTimeout) { + this.connectTimeout = connectTimeout == null ? Duration.ofMillis(Protocol.DEFAULT_TIMEOUT) : 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} + * {@link redis.clients.jedis.Protocol#DEFAULT_TIMEOUT} milliseconds. * * @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); + public RedisDataStoreBuilder socketTimeout(Duration socketTimeout) { + this.socketTimeout = socketTimeout == null ? Duration.ofMillis(Protocol.DEFAULT_TIMEOUT) : socketTimeout; return this; } diff --git a/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreImpl.java b/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreImpl.java index fc025885b..bdabe2b7f 100644 --- a/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreImpl.java +++ b/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreImpl.java @@ -49,8 +49,8 @@ class RedisDataStoreImpl implements DataStoreCore { JedisPool pool = new JedisPool(poolConfig, host, port, - builder.connectTimeout, - builder.socketTimeout, + (int)builder.connectTimeout.toMillis(), + (int)builder.socketTimeout.toMillis(), password, database, null, // clientName diff --git a/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java b/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java index 9149ea395..a96a578e1 100644 --- a/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java +++ b/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java @@ -84,8 +84,8 @@ public Boolean load(String key) throws Exception { // 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); + itemCache = CacheBuilder.newBuilder().expireAfterWrite(caching.getCacheTime()).build(itemLoader); + allCache = CacheBuilder.newBuilder().expireAfterWrite(caching.getCacheTime()).build(allLoader); executorService = null; break; @@ -102,11 +102,11 @@ public Boolean load(String key) throws Exception { if (caching.getStaleValuesPolicy() == DataStoreCacheConfig.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); + itemCache = CacheBuilder.newBuilder().refreshAfterWrite(caching.getCacheTime()).build(itemLoader); + allCache = CacheBuilder.newBuilder().refreshAfterWrite(caching.getCacheTime()).build(allLoader); } - initCache = CacheBuilder.newBuilder().expireAfterWrite(caching.getCacheTime(), caching.getCacheTimeUnit()).build(initLoader); + initCache = CacheBuilder.newBuilder().expireAfterWrite(caching.getCacheTime()).build(initLoader); } } diff --git a/src/test/java/com/launchdarkly/client/DataStoreCachingTest.java b/src/test/java/com/launchdarkly/client/DataStoreCachingTest.java index a5910d2a2..c51acf7d5 100644 --- a/src/test/java/com/launchdarkly/client/DataStoreCachingTest.java +++ b/src/test/java/com/launchdarkly/client/DataStoreCachingTest.java @@ -2,7 +2,7 @@ import org.junit.Test; -import java.util.concurrent.TimeUnit; +import java.time.Duration; import static com.launchdarkly.client.DataStoreCacheConfig.StaleValuesPolicy.EVICT; import static com.launchdarkly.client.DataStoreCacheConfig.StaleValuesPolicy.REFRESH; @@ -15,7 +15,7 @@ public class DataStoreCachingTest { @Test public void disabledHasExpectedProperties() { DataStoreCacheConfig fsc = DataStoreCacheConfig.disabled(); - assertThat(fsc.getCacheTime(), equalTo(0L)); + assertThat(fsc.getCacheTime(), equalTo(Duration.ZERO)); assertThat(fsc.isEnabled(), equalTo(false)); assertThat(fsc.getStaleValuesPolicy(), equalTo(EVICT)); } @@ -23,8 +23,7 @@ public void disabledHasExpectedProperties() { @Test public void enabledHasExpectedProperties() { DataStoreCacheConfig fsc = DataStoreCacheConfig.enabled(); - assertThat(fsc.getCacheTime(), equalTo(DataStoreCacheConfig.DEFAULT_TIME_SECONDS)); - assertThat(fsc.getCacheTimeUnit(), equalTo(TimeUnit.SECONDS)); + assertThat(fsc.getCacheTime(), equalTo(DataStoreCacheConfig.DEFAULT_TIME)); assertThat(fsc.isEnabled(), equalTo(true)); assertThat(fsc.getStaleValuesPolicy(), equalTo(EVICT)); } @@ -32,8 +31,7 @@ public void enabledHasExpectedProperties() { @Test public void defaultIsEnabled() { DataStoreCacheConfig fsc = DataStoreCacheConfig.DEFAULT; - assertThat(fsc.getCacheTime(), equalTo(DataStoreCacheConfig.DEFAULT_TIME_SECONDS)); - assertThat(fsc.getCacheTimeUnit(), equalTo(TimeUnit.SECONDS)); + assertThat(fsc.getCacheTime(), equalTo(DataStoreCacheConfig.DEFAULT_TIME)); assertThat(fsc.isEnabled(), equalTo(true)); assertThat(fsc.getStaleValuesPolicy(), equalTo(EVICT)); } @@ -42,9 +40,8 @@ public void defaultIsEnabled() { public void canSetTtl() { DataStoreCacheConfig fsc = DataStoreCacheConfig.enabled() .staleValuesPolicy(REFRESH) - .ttl(3, TimeUnit.DAYS); - assertThat(fsc.getCacheTime(), equalTo(3L)); - assertThat(fsc.getCacheTimeUnit(), equalTo(TimeUnit.DAYS)); + .ttl(Duration.ofDays(3)); + assertThat(fsc.getCacheTime(), equalTo(Duration.ofDays(3))); assertThat(fsc.getStaleValuesPolicy(), equalTo(REFRESH)); } @@ -53,8 +50,7 @@ public void canSetTtlInMillis() { DataStoreCacheConfig fsc = DataStoreCacheConfig.enabled() .staleValuesPolicy(REFRESH) .ttlMillis(3); - assertThat(fsc.getCacheTime(), equalTo(3L)); - assertThat(fsc.getCacheTimeUnit(), equalTo(TimeUnit.MILLISECONDS)); + assertThat(fsc.getCacheTime(), equalTo(Duration.ofMillis(3))); assertThat(fsc.getStaleValuesPolicy(), equalTo(REFRESH)); } @@ -63,22 +59,21 @@ public void canSetTtlInSeconds() { DataStoreCacheConfig fsc = DataStoreCacheConfig.enabled() .staleValuesPolicy(REFRESH) .ttlSeconds(3); - assertThat(fsc.getCacheTime(), equalTo(3L)); - assertThat(fsc.getCacheTimeUnit(), equalTo(TimeUnit.SECONDS)); + assertThat(fsc.getCacheTime(), equalTo(Duration.ofSeconds(3))); assertThat(fsc.getStaleValuesPolicy(), equalTo(REFRESH)); } @Test public void zeroTtlMeansDisabled() { DataStoreCacheConfig fsc = DataStoreCacheConfig.enabled() - .ttl(0, TimeUnit.SECONDS); + .ttl(Duration.ZERO); assertThat(fsc.isEnabled(), equalTo(false)); } @Test public void negativeTtlMeansDisabled() { DataStoreCacheConfig fsc = DataStoreCacheConfig.enabled() - .ttl(-1, TimeUnit.SECONDS); + .ttl(Duration.ofSeconds(-1)); assertThat(fsc.isEnabled(), equalTo(false)); } @@ -88,8 +83,7 @@ public void canSetStaleValuesPolicy() { .ttlMillis(3) .staleValuesPolicy(REFRESH_ASYNC); assertThat(fsc.getStaleValuesPolicy(), equalTo(REFRESH_ASYNC)); - assertThat(fsc.getCacheTime(), equalTo(3L)); - assertThat(fsc.getCacheTimeUnit(), equalTo(TimeUnit.MILLISECONDS)); + assertThat(fsc.getCacheTime(), equalTo(Duration.ofMillis(3))); } @Test @@ -101,15 +95,6 @@ public void equalityUsesTime() { assertThat(fsc1.equals(fsc3), equalTo(false)); } - @Test - public void equalityUsesTimeUnit() { - DataStoreCacheConfig fsc1 = DataStoreCacheConfig.enabled().ttlMillis(3); - DataStoreCacheConfig fsc2 = DataStoreCacheConfig.enabled().ttlMillis(3); - DataStoreCacheConfig fsc3 = DataStoreCacheConfig.enabled().ttlSeconds(3); - assertThat(fsc1.equals(fsc2), equalTo(true)); - assertThat(fsc1.equals(fsc3), equalTo(false)); - } - @Test public void equalityUsesStaleValuesPolicy() { DataStoreCacheConfig fsc1 = DataStoreCacheConfig.enabled().staleValuesPolicy(EVICT); diff --git a/src/test/java/com/launchdarkly/client/integrations/RedisDataStoreBuilderTest.java b/src/test/java/com/launchdarkly/client/integrations/RedisDataStoreBuilderTest.java index d9ce97115..e88589d13 100644 --- a/src/test/java/com/launchdarkly/client/integrations/RedisDataStoreBuilderTest.java +++ b/src/test/java/com/launchdarkly/client/integrations/RedisDataStoreBuilderTest.java @@ -5,7 +5,7 @@ import org.junit.Test; import java.net.URISyntaxException; -import java.util.concurrent.TimeUnit; +import java.time.Duration; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; @@ -20,8 +20,8 @@ public void testDefaultValues() { RedisDataStoreBuilder conf = Redis.dataStore(); assertEquals(RedisDataStoreBuilder.DEFAULT_URI, conf.uri); assertEquals(DataStoreCacheConfig.DEFAULT, conf.caching); - assertEquals(Protocol.DEFAULT_TIMEOUT, conf.connectTimeout); - assertEquals(Protocol.DEFAULT_TIMEOUT, conf.socketTimeout); + assertEquals(Duration.ofMillis(Protocol.DEFAULT_TIMEOUT), conf.connectTimeout); + assertEquals(Duration.ofMillis(Protocol.DEFAULT_TIMEOUT), conf.socketTimeout); assertEquals(RedisDataStoreBuilder.DEFAULT_PREFIX, conf.prefix); assertNull(conf.poolConfig); } @@ -34,14 +34,14 @@ public void testPrefixConfigured() throws URISyntaxException { @Test public void testConnectTimeoutConfigured() throws URISyntaxException { - RedisDataStoreBuilder conf = Redis.dataStore().connectTimeout(1, TimeUnit.SECONDS); - assertEquals(1000, conf.connectTimeout); + RedisDataStoreBuilder conf = Redis.dataStore().connectTimeout(Duration.ofSeconds(1)); + assertEquals(Duration.ofSeconds(1), conf.connectTimeout); } @Test public void testSocketTimeoutConfigured() throws URISyntaxException { - RedisDataStoreBuilder conf = Redis.dataStore().socketTimeout(1, TimeUnit.SECONDS); - assertEquals(1000, conf.socketTimeout); + RedisDataStoreBuilder conf = Redis.dataStore().socketTimeout(Duration.ofSeconds(1)); + assertEquals(Duration.ofSeconds(1), conf.socketTimeout); } @Test From dd24de234250323e10c38bc6ca8c4acc3fcc8aff Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 17 Jan 2020 16:01:38 -0800 Subject: [PATCH 271/673] fix conversion of epoch millis to date/time --- src/main/java/com/launchdarkly/client/EvaluatorOperators.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/EvaluatorOperators.java b/src/main/java/com/launchdarkly/client/EvaluatorOperators.java index 9250ec513..39009ee61 100644 --- a/src/main/java/com/launchdarkly/client/EvaluatorOperators.java +++ b/src/main/java/com/launchdarkly/client/EvaluatorOperators.java @@ -3,7 +3,7 @@ import com.launchdarkly.client.value.LDValue; import java.time.Instant; -import java.time.ZoneId; +import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.util.regex.Pattern; @@ -118,7 +118,7 @@ private static boolean compareSemVer(ComparisonOp op, LDValue userValue, LDValue private static ZonedDateTime valueToDateTime(LDValue value) { if (value.isNumber()) { - return ZonedDateTime.ofInstant(Instant.ofEpochMilli(value.longValue()), ZoneId.systemDefault()); + return ZonedDateTime.ofInstant(Instant.ofEpochMilli(value.longValue()), ZoneOffset.UTC); } else if (value.isString()) { try { return ZonedDateTime.parse(value.stringValue()); From 36b7ab3f787647ecd83dbbf4558348c8c0a972db Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 17 Jan 2020 16:18:46 -0800 Subject: [PATCH 272/673] use lambdas instead of anonymous classes for simple interfaces --- .../com/launchdarkly/client/Components.java | 53 ++++--------------- .../com/launchdarkly/client/DataModel.java | 7 --- .../client/DefaultEventProcessor.java | 24 +++------ .../com/launchdarkly/client/LDConfig.java | 20 +++---- .../launchdarkly/client/PollingProcessor.java | 35 ++++++------ .../launchdarkly/client/StreamProcessor.java | 31 +++++------ .../integrations/FileDataSourceImpl.java | 6 +-- .../client/DataStoreDatabaseTestBase.java | 8 ++- .../com/launchdarkly/client/TestUtil.java | 44 ++++++--------- 9 files changed, 73 insertions(+), 155 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/Components.java b/src/main/java/com/launchdarkly/client/Components.java index 94d3e00be..92a800a4d 100644 --- a/src/main/java/com/launchdarkly/client/Components.java +++ b/src/main/java/com/launchdarkly/client/Components.java @@ -29,7 +29,7 @@ public abstract class Components { * @see LDConfig.Builder#dataStore(DataStoreFactory) */ public static DataStoreFactory inMemoryDataStore() { - return InMemoryDataStoreFactory.INSTANCE; + return IN_MEMORY_DATA_STORE_FACTORY; } /** @@ -40,7 +40,7 @@ public static DataStoreFactory inMemoryDataStore() { * @see LDConfig.Builder#eventProcessor(EventProcessorFactory) */ public static EventProcessorFactory defaultEventProcessor() { - return DefaultEventProcessorFactory.INSTANCE; + return DEFAULT_EVENT_PROCESSOR_FACTORY; } /** @@ -50,7 +50,7 @@ public static EventProcessorFactory defaultEventProcessor() { * @see LDConfig.Builder#eventProcessor(EventProcessorFactory) */ public static EventProcessorFactory nullEventProcessor() { - return NullEventProcessorFactory.INSTANCE; + return NULL_EVENT_PROCESSOR_FACTORY; } /** @@ -75,44 +75,20 @@ public static DataSourceFactory defaultDataSource() { * @see LDConfig.Builder#dataSource(DataSourceFactory) */ public static DataSourceFactory nullDataSource() { - return NullDataSourceFactory.INSTANCE; + return NULL_DATA_SOURCE_FACTORY; } - private static final class InMemoryDataStoreFactory implements DataStoreFactory { - static final InMemoryDataStoreFactory INSTANCE = new InMemoryDataStoreFactory(); - - private InMemoryDataStoreFactory() {} - - @Override - public DataStore createDataStore() { - return new InMemoryDataStore(); - } - } + private static final DataStoreFactory IN_MEMORY_DATA_STORE_FACTORY = () -> new InMemoryDataStore(); - private static final class DefaultEventProcessorFactory implements EventProcessorFactory { - static final DefaultEventProcessorFactory INSTANCE = new DefaultEventProcessorFactory(); - - private DefaultEventProcessorFactory() {} - - @Override - public EventProcessor createEventProcessor(String sdkKey, LDConfig config) { + private static final EventProcessorFactory DEFAULT_EVENT_PROCESSOR_FACTORY = (sdkKey, config) -> { if (config.offline || !config.sendEvents) { return new NullEventProcessor(); } else { return new DefaultEventProcessor(sdkKey, config); - } - } - } + } + }; - private static final class NullEventProcessorFactory implements EventProcessorFactory { - static final NullEventProcessorFactory INSTANCE = new NullEventProcessorFactory(); - - private NullEventProcessorFactory() {} - - public EventProcessor createEventProcessor(String sdkKey, LDConfig config) { - return NullEventProcessor.INSTANCE; - } - } + private static final EventProcessorFactory NULL_EVENT_PROCESSOR_FACTORY = (sdkKey, config) -> NullEventProcessor.INSTANCE; /** * Stub implementation of {@link EventProcessor} for when we don't want to send any events. @@ -164,16 +140,7 @@ public DataSource createDataSource(String sdkKey, LDConfig config, DataStore dat } } - private static final class NullDataSourceFactory implements DataSourceFactory { - static final NullDataSourceFactory INSTANCE = new NullDataSourceFactory(); - - private NullDataSourceFactory() {} - - @Override - public DataSource createDataSource(String sdkKey, LDConfig config, DataStore dataStore) { - return NullDataSource.INSTANCE; - } - } + private static final DataSourceFactory NULL_DATA_SOURCE_FACTORY = (sdkKey, config, dataStore) -> NullDataSource.INSTANCE; // exposed as package-private for testing static final class NullDataSource implements DataSource { diff --git a/src/main/java/com/launchdarkly/client/DataModel.java b/src/main/java/com/launchdarkly/client/DataModel.java index a5a101a08..5a7efc54a 100644 --- a/src/main/java/com/launchdarkly/client/DataModel.java +++ b/src/main/java/com/launchdarkly/client/DataModel.java @@ -89,13 +89,6 @@ public int getPriority() { public T deserialize(String serializedData) { return gson.fromJson(serializedData, itemClass); } - - /** - * Used internally to match data URLs in the streaming API. - * @param path path from an API message - * @return the parsed key if the path refers to an object of this kind, otherwise null - */ - } } diff --git a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java index b512d35e0..82fdfa142 100644 --- a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java @@ -61,16 +61,12 @@ final class DefaultEventProcessor implements EventProcessor { new EventDispatcher(sdkKey, config, inbox, threadFactory, closed); - Runnable flusher = new Runnable() { - public void run() { - postMessageAsync(MessageType.FLUSH, null); - } + Runnable flusher = () -> { + postMessageAsync(MessageType.FLUSH, null); }; this.scheduler.scheduleAtFixedRate(flusher, config.flushInterval.toMillis(), config.flushInterval.toMillis(), TimeUnit.MILLISECONDS); - Runnable userKeysFlusher = new Runnable() { - public void run() { - postMessageAsync(MessageType.FLUSH_USERS, null); - } + Runnable userKeysFlusher = () -> { + postMessageAsync(MessageType.FLUSH_USERS, null); }; this.scheduler.scheduleAtFixedRate(userKeysFlusher, config.userKeysFlushInterval.toMillis(), config.userKeysFlushInterval.toMillis(), TimeUnit.MILLISECONDS); @@ -211,10 +207,8 @@ private EventDispatcher(String sdkKey, LDConfig config, 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); - } + Thread mainThread = threadFactory.newThread(() -> { + runMainLoop(inbox, outbox, userKeys, payloadQueue); }); mainThread.setDaemon(true); @@ -239,11 +233,7 @@ public void uncaughtException(Thread t, Throwable e) { mainThread.start(); flushWorkers = new ArrayList<>(); - EventResponseListener listener = new EventResponseListener() { - public void handleResponse(Response response, Date responseDate) { - EventDispatcher.this.handleResponse(response, responseDate); - } - }; + EventResponseListener listener = this::handleResponse; for (int i = 0; i < MAX_FLUSH_THREADS; i++) { SendEventsTask task = new SendEventsTask(sdkKey, config, httpClient, listener, payloadQueue, busyFlushWorkersCount, threadFactory); diff --git a/src/main/java/com/launchdarkly/client/LDConfig.java b/src/main/java/com/launchdarkly/client/LDConfig.java index 9960939dc..aee138b4e 100644 --- a/src/main/java/com/launchdarkly/client/LDConfig.java +++ b/src/main/java/com/launchdarkly/client/LDConfig.java @@ -10,7 +10,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.IOException; import java.net.InetSocketAddress; import java.net.Proxy; import java.net.URI; @@ -24,9 +23,6 @@ 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}. @@ -512,15 +508,13 @@ Proxy proxy() { 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 (route, response) -> { + 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(); } }; } diff --git a/src/main/java/com/launchdarkly/client/PollingProcessor.java b/src/main/java/com/launchdarkly/client/PollingProcessor.java index 75d321ffb..4c89820d8 100644 --- a/src/main/java/com/launchdarkly/client/PollingProcessor.java +++ b/src/main/java/com/launchdarkly/client/PollingProcessor.java @@ -56,26 +56,23 @@ public Future start() { .build(); scheduler = Executors.newScheduledThreadPool(1, threadFactory); - scheduler.scheduleAtFixedRate(new Runnable() { - @Override - public void run() { - try { - FeatureRequestor.AllData allData = requestor.getAllData(); - store.init(DefaultFeatureRequestor.toVersionedDataMap(allData)); - if (!initialized.getAndSet(true)) { - logger.info("Initialized LaunchDarkly client."); - initFuture.set(null); - } - } catch (HttpErrorException e) { - logger.error(httpErrorMessage(e.getStatus(), "polling request", "will retry")); - if (!isHttpErrorRecoverable(e.getStatus())) { - scheduler.shutdown(); - 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.toString()); - logger.debug(e.toString(), e); + scheduler.scheduleAtFixedRate(() -> { + try { + FeatureRequestor.AllData allData = requestor.getAllData(); + store.init(DefaultFeatureRequestor.toVersionedDataMap(allData)); + if (!initialized.getAndSet(true)) { + logger.info("Initialized LaunchDarkly client."); + initFuture.set(null); } + } catch (HttpErrorException e) { + logger.error(httpErrorMessage(e.getStatus(), "polling request", "will retry")); + if (!isHttpErrorRecoverable(e.getStatus())) { + scheduler.shutdown(); + 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.toString()); + logger.debug(e.toString(), e); } }, 0L, config.pollingInterval.toMillis(), TimeUnit.MILLISECONDS); diff --git a/src/main/java/com/launchdarkly/client/StreamProcessor.java b/src/main/java/com/launchdarkly/client/StreamProcessor.java index 5ffa6a99a..bc613b77b 100644 --- a/src/main/java/com/launchdarkly/client/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/client/StreamProcessor.java @@ -7,6 +7,7 @@ import com.launchdarkly.client.interfaces.DataSource; import com.launchdarkly.client.interfaces.VersionedDataKind; import com.launchdarkly.eventsource.ConnectionErrorHandler; +import com.launchdarkly.eventsource.ConnectionErrorHandler.Action; import com.launchdarkly.eventsource.EventHandler; import com.launchdarkly.eventsource.EventSource; import com.launchdarkly.eventsource.MessageEvent; @@ -62,18 +63,15 @@ public static interface EventSourceCreator { } 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 (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; } + return Action.PROCEED; }; } @@ -87,15 +85,12 @@ public Future start() { .add("Accept", "text/event-stream") .build(); - ConnectionErrorHandler wrappedConnectionErrorHandler = new ConnectionErrorHandler() { - @Override - public Action onConnectionError(Throwable t) { - 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 result; + ConnectionErrorHandler wrappedConnectionErrorHandler = (Throwable t) -> { + 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 result; }; EventHandler handler = new EventHandler() { diff --git a/src/main/java/com/launchdarkly/client/integrations/FileDataSourceImpl.java b/src/main/java/com/launchdarkly/client/integrations/FileDataSourceImpl.java index c97dfc5f1..40ca74de3 100644 --- a/src/main/java/com/launchdarkly/client/integrations/FileDataSourceImpl.java +++ b/src/main/java/com/launchdarkly/client/integrations/FileDataSourceImpl.java @@ -77,10 +77,8 @@ public Future start() { // if we are told to reload by the file watcher. if (fileWatcher != null) { - fileWatcher.start(new Runnable() { - public void run() { - FileDataSourceImpl.this.reload(); - } + fileWatcher.start(() -> { + FileDataSourceImpl.this.reload(); }); } diff --git a/src/test/java/com/launchdarkly/client/DataStoreDatabaseTestBase.java b/src/test/java/com/launchdarkly/client/DataStoreDatabaseTestBase.java index 17036cdc2..86bde75ac 100644 --- a/src/test/java/com/launchdarkly/client/DataStoreDatabaseTestBase.java +++ b/src/test/java/com/launchdarkly/client/DataStoreDatabaseTestBase.java @@ -174,11 +174,9 @@ public void handlesUpsertRaceConditionAgainstExternalClientWithHigherVersion() t final DataModel.FeatureFlag flag1 = flagBuilder("foo").version(startVersion).build(); - Runnable concurrentModifier = new Runnable() { - public void run() { - DataModel.FeatureFlag f = flagBuilder(flag1).version(store2Version).build(); - store2.upsert(FEATURES, f); - } + Runnable concurrentModifier = () -> { + DataModel.FeatureFlag f = flagBuilder(flag1).version(store2Version).build(); + store2.upsert(FEATURES, f); }; try { diff --git a/src/test/java/com/launchdarkly/client/TestUtil.java b/src/test/java/com/launchdarkly/client/TestUtil.java index 9f3256567..905ea222f 100644 --- a/src/test/java/com/launchdarkly/client/TestUtil.java +++ b/src/test/java/com/launchdarkly/client/TestUtil.java @@ -32,11 +32,7 @@ public class TestUtil { public static DataStoreFactory specificDataStore(final DataStore store) { - return new DataStoreFactory() { - public DataStore createDataStore() { - return store; - } - }; + return () -> store; } public static DataStore initedDataStore() { @@ -46,38 +42,28 @@ public static DataStore initedDataStore() { } public static EventProcessorFactory specificEventProcessor(final EventProcessor ep) { - return new EventProcessorFactory() { - public EventProcessor createEventProcessor(String sdkKey, LDConfig config) { - return ep; - } - }; + return (String sdkKey, LDConfig config) -> ep; } public static DataSourceFactory specificDataSource(final DataSource up) { - return new DataSourceFactory() { - public DataSource createDataSource(String sdkKey, LDConfig config, DataStore dataStore) { - return up; - } - }; + return (String sdkKey, LDConfig config, DataStore dataStore) -> up; } public static DataSourceFactory dataSourceWithData(final Map, Map> data) { - return new DataSourceFactory() { - public DataSource createDataSource(String sdkKey, LDConfig config, final DataStore dataStore) { - return new DataSource() { - public Future start() { - dataStore.init(data); - return Futures.immediateFuture(null); - } + return (String sdkKey, LDConfig config, final DataStore dataStore) -> { + return new DataSource() { + public Future start() { + dataStore.init(data); + return Futures.immediateFuture(null); + } - public boolean initialized() { - return true; - } + public boolean initialized() { + return true; + } - public void close() throws IOException { - } - }; - } + public void close() throws IOException { + } + }; }; } From c0c6a33392cc94e3d7270d4b91b26f7e4045ae6d Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 17 Jan 2020 17:38:28 -0800 Subject: [PATCH 273/673] 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 274/673] (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 275/673] 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 276/673] 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 277/673] 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 278/673] 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 279/673] 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 280/673] 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 281/673] 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 282/673] 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 283/673] 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 284/673] 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 285/673] (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 286/673] 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 287/673] 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 288/673] 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 289/673] 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 290/673] 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 291/673] 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 292/673] 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 293/673] @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 294/673] 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 295/673] 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 296/673] 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 297/673] 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 298/673] 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 299/673] 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 300/673] 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 301/673] 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 302/673] 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 303/673] 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 304/673] 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 400408a46b0bcbabbfcfaea1da39eb8326b0f193 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 30 Jan 2020 18:17:57 -0800 Subject: [PATCH 305/673] using okhttp-eventsource 2.0 --- .../java/com/launchdarkly/client/StreamProcessor.java | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/StreamProcessor.java b/src/main/java/com/launchdarkly/client/StreamProcessor.java index e971f64e0..b60b30dd0 100644 --- a/src/main/java/com/launchdarkly/client/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/client/StreamProcessor.java @@ -40,7 +40,7 @@ final class StreamProcessor implements DataSource { private static final String INDIRECT_PUT = "indirect/put"; private static final String INDIRECT_PATCH = "indirect/patch"; private static final Logger logger = LoggerFactory.getLogger(StreamProcessor.class); - private static final int DEAD_CONNECTION_INTERVAL_MS = 300 * 1000; + private static final Duration DEAD_CONNECTION_INTERVAL = Duration.ofSeconds(300); private final DataStore store; private final HttpConfiguration httpConfig; @@ -281,10 +281,8 @@ public void configure(OkHttpClient.Builder builder) { }) .connectionErrorHandler(errorHandler) .headers(headers) - .reconnectTimeMs(initialReconnectDelay.toMillis()) - .readTimeoutMs(DEAD_CONNECTION_INTERVAL_MS) - .connectTimeoutMs(EventSource.DEFAULT_CONNECT_TIMEOUT_MS) - .writeTimeoutMs(EventSource.DEFAULT_WRITE_TIMEOUT_MS); + .reconnectTime(initialReconnectDelay) + .readTimeout(DEAD_CONNECTION_INTERVAL); // Note that this is not the same read timeout that can be set in LDConfig. We default to a smaller one // there because we don't expect long delays within any *non*-streaming response that the LD client gets. // A read timeout on the stream will result in the connection being cycled, so we set this to be slightly From 2025af6a8312614596c18b1364cd1a89dae7beb3 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 30 Jan 2020 18:46:20 -0800 Subject: [PATCH 306/673] 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 fd7f51f060a0d656d4662e1881d22f4631420381 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 31 Jan 2020 00:35:49 -0800 Subject: [PATCH 307/673] update okhttp version in test code --- build.gradle | 3 ++- .../client/DefaultEventProcessorTest.java | 2 +- .../client/FeatureRequestorTest.java | 2 +- .../client/LDClientEndToEndTest.java | 4 +-- .../client/StreamProcessorTest.java | 2 +- .../com/launchdarkly/client/TestHttpUtil.java | 27 +++++++++++-------- 6 files changed, 23 insertions(+), 17 deletions(-) diff --git a/build.gradle b/build.gradle index 346d61183..0753ffaee 100644 --- a/build.gradle +++ b/build.gradle @@ -57,7 +57,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:4.3.1", + "com.squareup.okhttp3:okhttp-tls:4.3.1", "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 3973ea2e5..f9024f918 100644 --- a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java @@ -846,7 +846,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 db5e1e112..2940c8c30 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 42fd663ad..e8c870337 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java @@ -71,7 +71,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)) { @@ -127,7 +127,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 7745647a5..e0bdb67f5 100644 --- a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java @@ -439,7 +439,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..ca440cb4e 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 017a4c40056af7db522ea49f0a9233b3d096850c Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 31 Jan 2020 15:02:54 -0800 Subject: [PATCH 308/673] update okhttp-eventsource version to fix transitive okhttp dependency --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 31be3bb17..c7891f267 100644 --- a/build.gradle +++ b/build.gradle @@ -59,7 +59,7 @@ ext.libraries = [:] libraries.internal = [ "commons-codec:commons-codec:1.10", "com.google.guava:guava:28.2-jre", - "com.launchdarkly:okhttp-eventsource:2.0.0", + "com.launchdarkly:okhttp-eventsource:2.0.1", "org.yaml:snakeyaml:1.19", "redis.clients:jedis:2.9.0" ] From b6aebb77cd522bfac6d7fadaac96d875343b5206 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 31 Jan 2020 15:26:23 -0800 Subject: [PATCH 309/673] fix packaging test --- packaging-test/test-app/src/main/java/testapp/TestApp.java | 4 ---- 1 file changed, 4 deletions(-) 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 bfec1bfdb..4c762db05 100644 --- a/packaging-test/test-app/src/main/java/testapp/TestApp.java +++ b/packaging-test/test-app/src/main/java/testapp/TestApp.java @@ -14,10 +14,6 @@ public static void main(String[] args) throws Exception { 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) From 19fbd733750e0ae6aa0b426378e056860031a3c2 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 31 Jan 2020 16:17:12 -0800 Subject: [PATCH 310/673] use a context object to pass the DiagnosticAccumulator to components --- .../client/ClientContextImpl.java | 38 +++++++++++ .../com/launchdarkly/client/Components.java | 67 ++++++++----------- .../DataSourceFactoryWithDiagnostics.java | 10 --- .../client/DefaultEventProcessor.java | 2 +- .../EventProcessorFactoryWithDiagnostics.java | 9 --- .../com/launchdarkly/client/LDClient.java | 38 ++++------- .../integrations/FileDataSourceBuilder.java | 4 +- .../integrations/RedisDataStoreBuilder.java | 5 +- .../client/interfaces/ClientContext.java | 27 ++++++++ .../client/interfaces/DataSourceFactory.java | 9 ++- .../client/interfaces/DataStoreFactory.java | 6 +- .../interfaces/EventProcessorFactory.java | 7 +- .../PersistentDataStoreFactory.java | 3 +- .../client/DefaultEventProcessorTest.java | 13 ++-- .../com/launchdarkly/client/LDClientTest.java | 44 +++++++----- .../client/PollingProcessorTest.java | 5 +- .../client/StreamProcessorTest.java | 5 +- .../com/launchdarkly/client/TestUtil.java | 16 +++-- .../integrations/FileDataSourceTest.java | 25 ++++--- .../integrations/RedisDataStoreImplTest.java | 4 +- .../integrations/RedisDataStoreTest.java | 4 +- 21 files changed, 197 insertions(+), 144 deletions(-) create mode 100644 src/main/java/com/launchdarkly/client/ClientContextImpl.java delete mode 100644 src/main/java/com/launchdarkly/client/DataSourceFactoryWithDiagnostics.java delete mode 100644 src/main/java/com/launchdarkly/client/EventProcessorFactoryWithDiagnostics.java create mode 100644 src/main/java/com/launchdarkly/client/interfaces/ClientContext.java diff --git a/src/main/java/com/launchdarkly/client/ClientContextImpl.java b/src/main/java/com/launchdarkly/client/ClientContextImpl.java new file mode 100644 index 000000000..66f8a5373 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/ClientContextImpl.java @@ -0,0 +1,38 @@ +package com.launchdarkly.client; + +import com.launchdarkly.client.interfaces.ClientContext; + +final class ClientContextImpl implements ClientContext { + private final String sdkKey; + private final LDConfig configuration; + private final DiagnosticAccumulator diagnosticAccumulator; + + ClientContextImpl(String sdkKey, LDConfig configuration, DiagnosticAccumulator diagnosticAccumulator) { + this.sdkKey = sdkKey; + this.configuration = configuration; + this.diagnosticAccumulator = diagnosticAccumulator; + } + + @Override + public String getSdkKey() { + return sdkKey; + } + + @Override + public LDConfig getConfiguration() { + return configuration; + } + + // Note that this property is package-private - it is only used by SDK internal components, not any + // custom components implemented by an application. + DiagnosticAccumulator getDiagnosticAccumulator() { + return diagnosticAccumulator; + } + + static DiagnosticAccumulator getDiagnosticAccumulator(ClientContext context) { + if (context instanceof ClientContextImpl) { + return ((ClientContextImpl)context).getDiagnosticAccumulator(); + } + return null; + } +} diff --git a/src/main/java/com/launchdarkly/client/Components.java b/src/main/java/com/launchdarkly/client/Components.java index 19934db84..4e672b8ee 100644 --- a/src/main/java/com/launchdarkly/client/Components.java +++ b/src/main/java/com/launchdarkly/client/Components.java @@ -5,6 +5,7 @@ import com.launchdarkly.client.integrations.PersistentDataStoreBuilder; import com.launchdarkly.client.integrations.PollingDataSourceBuilder; import com.launchdarkly.client.integrations.StreamingDataSourceBuilder; +import com.launchdarkly.client.interfaces.ClientContext; import com.launchdarkly.client.interfaces.DataSource; import com.launchdarkly.client.interfaces.DataSourceFactory; import com.launchdarkly.client.interfaces.DataStore; @@ -210,7 +211,7 @@ public static DataSourceFactory externalUpdatesOnly() { private static final class InMemoryDataStoreFactory implements DataStoreFactory, DiagnosticDescription { static final DataStoreFactory INSTANCE = new InMemoryDataStoreFactory(); @Override - public DataStore createDataStore() { + public DataStore createDataStore(ClientContext context) { return new InMemoryDataStore(); } @@ -220,7 +221,7 @@ public LDValue describeConfiguration(LDConfig config) { } } - private static final EventProcessorFactory NULL_EVENT_PROCESSOR_FACTORY = (sdkKey, config) -> NullEventProcessor.INSTANCE; + private static final EventProcessorFactory NULL_EVENT_PROCESSOR_FACTORY = context -> NullEventProcessor.INSTANCE; /** * Stub implementation of {@link EventProcessor} for when we don't want to send any events. @@ -247,8 +248,8 @@ private static final class NullDataSourceFactory implements DataSourceFactory, D static final NullDataSourceFactory INSTANCE = new NullDataSourceFactory(); @Override - public DataSource createDataSource(String sdkKey, LDConfig config, DataStore dataStore) { - if (config.offline) { + public DataSource createDataSource(ClientContext context, DataStore dataStore) { + if (context.getConfiguration().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"); @@ -290,19 +291,13 @@ public void close() throws IOException {} } private static final class StreamingDataSourceBuilderImpl extends StreamingDataSourceBuilder - implements DataSourceFactoryWithDiagnostics, DiagnosticDescription { + implements DiagnosticDescription { @Override - public DataSource createDataSource(String sdkKey, LDConfig config, DataStore dataStore) { - return createDataSource(sdkKey, config, dataStore, null); - } - - @Override - public DataSource createDataSource(String sdkKey, LDConfig config, DataStore dataStore, - DiagnosticAccumulator diagnosticAccumulator) { + public DataSource createDataSource(ClientContext context, DataStore dataStore) { // Note, we log startup messages under the LDClient class to keep logs more readable - if (config.offline) { - return Components.externalUpdatesOnly().createDataSource(sdkKey, config, dataStore); + if (context.getConfiguration().offline) { + return Components.externalUpdatesOnly().createDataSource(context, dataStore); } LDClient.logger.info("Enabling streaming API"); @@ -318,19 +313,19 @@ public DataSource createDataSource(String sdkKey, LDConfig config, DataStore dat } DefaultFeatureRequestor requestor = new DefaultFeatureRequestor( - sdkKey, - config.httpConfig, + context.getSdkKey(), + context.getConfiguration().httpConfig, pollUri, false ); return new StreamProcessor( - sdkKey, - config.httpConfig, + context.getSdkKey(), + context.getConfiguration().httpConfig, requestor, dataStore, null, - diagnosticAccumulator, + ClientContextImpl.getDiagnosticAccumulator(context), streamUri, initialReconnectDelay ); @@ -356,19 +351,19 @@ public LDValue describeConfiguration(LDConfig config) { private static final class PollingDataSourceBuilderImpl extends PollingDataSourceBuilder implements DiagnosticDescription { @Override - public DataSource createDataSource(String sdkKey, LDConfig config, DataStore dataStore) { + public DataSource createDataSource(ClientContext context, DataStore dataStore) { // Note, we log startup messages under the LDClient class to keep logs more readable - if (config.offline) { - return Components.externalUpdatesOnly().createDataSource(sdkKey, config, dataStore); + if (context.getConfiguration().offline) { + return Components.externalUpdatesOnly().createDataSource(context, dataStore); } 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.httpConfig, + context.getSdkKey(), + context.getConfiguration().httpConfig, baseURI == null ? LDConfig.DEFAULT_BASE_URI : baseURI, true ); @@ -392,19 +387,15 @@ public LDValue describeConfiguration(LDConfig config) { } private static final class EventProcessorBuilderImpl extends EventProcessorBuilder - implements EventProcessorFactoryWithDiagnostics, DiagnosticDescription { + implements DiagnosticDescription { @Override - public EventProcessor createEventProcessor(String sdkKey, LDConfig config) { - return createEventProcessor(sdkKey, config, null); - } - - @Override - public EventProcessor createEventProcessor(String sdkKey, LDConfig config, DiagnosticAccumulator diagnosticAccumulator) { - if (config.offline) { + public EventProcessor createEventProcessor(ClientContext context) { + if (context.getConfiguration().offline) { return new NullEventProcessor(); } - return new DefaultEventProcessor(sdkKey, - config, + return new DefaultEventProcessor( + context.getSdkKey(), + context.getConfiguration(), new EventsConfiguration( allAttributesPrivate, capacity, @@ -417,8 +408,8 @@ public EventProcessor createEventProcessor(String sdkKey, LDConfig config, Diagn userKeysFlushInterval, diagnosticRecordingInterval ), - config.httpConfig, - diagnosticAccumulator + context.getConfiguration().httpConfig, + ClientContextImpl.getDiagnosticAccumulator(context) ); } @@ -444,8 +435,8 @@ public PersistentDataStoreBuilderImpl(PersistentDataStoreFactory persistentDataS } @Override - public DataStore createDataStore() { - PersistentDataStore core = persistentDataStoreFactory.createPersistentDataStore(); + public DataStore createDataStore(ClientContext context) { + PersistentDataStore core = persistentDataStoreFactory.createPersistentDataStore(context); DataStoreCacheConfig caching = DataStoreCacheConfig.DEFAULT.ttl(cacheTime) .staleValuesPolicy(DataStoreCacheConfig.StaleValuesPolicy.fromNewEnum(staleValuesPolicy)); return CachingStoreWrapper.builder(core) diff --git a/src/main/java/com/launchdarkly/client/DataSourceFactoryWithDiagnostics.java b/src/main/java/com/launchdarkly/client/DataSourceFactoryWithDiagnostics.java deleted file mode 100644 index f790a4480..000000000 --- a/src/main/java/com/launchdarkly/client/DataSourceFactoryWithDiagnostics.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.launchdarkly.client; - -import com.launchdarkly.client.interfaces.DataSource; -import com.launchdarkly.client.interfaces.DataSourceFactory; -import com.launchdarkly.client.interfaces.DataStore; - -interface DataSourceFactoryWithDiagnostics extends DataSourceFactory { - DataSource createDataSource(String sdkKey, LDConfig config, DataStore featureStore, - DiagnosticAccumulator diagnosticAccumulator); -} diff --git a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java index fa12bf5e4..d08f2579d 100644 --- a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java @@ -207,7 +207,7 @@ static final class EventDispatcher { private final AtomicInteger busyFlushWorkersCount; private final AtomicLong lastKnownPastTime = new AtomicLong(0); private final AtomicBoolean disabled = new AtomicBoolean(false); - private final DiagnosticAccumulator diagnosticAccumulator; + @VisibleForTesting final DiagnosticAccumulator diagnosticAccumulator; private final ExecutorService diagnosticExecutor; private final SendDiagnosticTaskFactory sendDiagnosticTaskFactory; diff --git a/src/main/java/com/launchdarkly/client/EventProcessorFactoryWithDiagnostics.java b/src/main/java/com/launchdarkly/client/EventProcessorFactoryWithDiagnostics.java deleted file mode 100644 index 180a8fcd7..000000000 --- a/src/main/java/com/launchdarkly/client/EventProcessorFactoryWithDiagnostics.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.launchdarkly.client; - -import com.launchdarkly.client.interfaces.EventProcessor; -import com.launchdarkly.client.interfaces.EventProcessorFactory; - -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 72f567e7a..61001e0c1 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -1,5 +1,6 @@ package com.launchdarkly.client; +import com.launchdarkly.client.integrations.EventProcessorBuilder; import com.launchdarkly.client.interfaces.DataSource; import com.launchdarkly.client.interfaces.DataSourceFactory; import com.launchdarkly.client.interfaces.DataStore; @@ -71,9 +72,20 @@ 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"); + final EventProcessorFactory epFactory = this.config.eventProcessorFactory == null ? + Components.sendEvents() : this.config.eventProcessorFactory; + + // Do not create diagnostic accumulator if config has specified is opted out, or if we're not using the + // standard event processor + final boolean useDiagnostics = !this.config.diagnosticOptOut && epFactory instanceof EventProcessorBuilder; + final ClientContextImpl context = new ClientContextImpl(sdkKey, config, + useDiagnostics ? new DiagnosticAccumulator(new DiagnosticId(sdkKey)) : null); + + this.eventProcessor = epFactory.createEventProcessor(context); + DataStoreFactory factory = config.dataStoreFactory == null ? Components.inMemoryDataStore() : config.dataStoreFactory; - DataStore store = factory.createDataStore(); + DataStore store = factory.createDataStore(context); this.dataStore = new DataStoreClientWrapper(store); this.evaluator = new Evaluator(new Evaluator.Getters() { @@ -86,31 +98,9 @@ public DataModel.Segment getSegment(String key) { } }); - EventProcessorFactory epFactory = this.config.eventProcessorFactory == null ? - Components.sendEvents() : this.config.eventProcessorFactory; - - 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); - } - DataSourceFactory dataSourceFactory = this.config.dataSourceFactory == null ? Components.streamingDataSource() : this.config.dataSourceFactory; - - if (dataSourceFactory instanceof DataSourceFactoryWithDiagnostics) { - DataSourceFactoryWithDiagnostics dswdFactory = ((DataSourceFactoryWithDiagnostics) dataSourceFactory); - this.dataSource = dswdFactory.createDataSource(sdkKey, this.config, dataStore, diagnosticAccumulator); - } else { - this.dataSource = dataSourceFactory.createDataSource(sdkKey, this.config, dataStore); - } + this.dataSource = dataSourceFactory.createDataSource(context, dataStore); Future startFuture = dataSource.start(); if (!this.config.startWait.isZero() && !this.config.startWait.isNegative()) { diff --git a/src/main/java/com/launchdarkly/client/integrations/FileDataSourceBuilder.java b/src/main/java/com/launchdarkly/client/integrations/FileDataSourceBuilder.java index 9d54eb0b3..97b7ae205 100644 --- a/src/main/java/com/launchdarkly/client/integrations/FileDataSourceBuilder.java +++ b/src/main/java/com/launchdarkly/client/integrations/FileDataSourceBuilder.java @@ -1,6 +1,6 @@ package com.launchdarkly.client.integrations; -import com.launchdarkly.client.LDConfig; +import com.launchdarkly.client.interfaces.ClientContext; import com.launchdarkly.client.interfaces.DataSource; import com.launchdarkly.client.interfaces.DataSourceFactory; import com.launchdarkly.client.interfaces.DataStore; @@ -77,7 +77,7 @@ public FileDataSourceBuilder autoUpdate(boolean autoUpdate) { * Used internally by the LaunchDarkly client. */ @Override - public DataSource createDataSource(String sdkKey, LDConfig config, DataStore featureStore) { + public DataSource createDataSource(ClientContext context, DataStore featureStore) { return new FileDataSourceImpl(featureStore, sources, autoUpdate); } } \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java b/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java index aebaa2d34..f100ae9fd 100644 --- a/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java +++ b/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java @@ -2,6 +2,7 @@ import com.google.common.base.Joiner; import com.launchdarkly.client.LDConfig; +import com.launchdarkly.client.interfaces.ClientContext; import com.launchdarkly.client.interfaces.DiagnosticDescription; import com.launchdarkly.client.interfaces.PersistentDataStore; import com.launchdarkly.client.interfaces.PersistentDataStoreFactory; @@ -21,7 +22,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.interfaces.DataStoreFactory)}. - * You do not need to call {@link #createPersistentDataStore()} yourself to build the actual data store; that + * You do not need to call {@link #createPersistentDataStore(ClientContext)} yourself to build the actual data store; that * will be done by the SDK. *

    * Builder calls can be chained, for example: @@ -176,7 +177,7 @@ public RedisDataStoreBuilder socketTimeout(Duration socketTimeout) { * @return the data store configured by this builder */ @Override - public PersistentDataStore createPersistentDataStore() { + public PersistentDataStore createPersistentDataStore(ClientContext context) { return new RedisDataStoreImpl(this); } diff --git a/src/main/java/com/launchdarkly/client/interfaces/ClientContext.java b/src/main/java/com/launchdarkly/client/interfaces/ClientContext.java new file mode 100644 index 000000000..2b20d3c87 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/interfaces/ClientContext.java @@ -0,0 +1,27 @@ +package com.launchdarkly.client.interfaces; + +import com.launchdarkly.client.LDConfig; + +/** + * Context information provided by the {@link com.launchdarkly.client.LDClient} when creating components. + *

    + * This is passed as a parameter to {@link DataStoreFactory#createDataStore(ClientContext)}, etc. The + * actual implementation class may contain other properties that are only relevant to the built-in SDK + * components and are therefore not part of the public interface; this allows the SDK to add its own + * context information as needed without disturbing the public API. + * + * @since 5.0.0 + */ +public interface ClientContext { + /** + * The current {@link com.launchdarkly.client.LDClient} instance's SDK key. + * @return the SDK key + */ + public String getSdkKey(); + + /** + * The client configuration. + * @return the configuration + */ + public LDConfig getConfiguration(); +} diff --git a/src/main/java/com/launchdarkly/client/interfaces/DataSourceFactory.java b/src/main/java/com/launchdarkly/client/interfaces/DataSourceFactory.java index 77569a540..bf9defe87 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/DataSourceFactory.java +++ b/src/main/java/com/launchdarkly/client/interfaces/DataSourceFactory.java @@ -1,20 +1,19 @@ package com.launchdarkly.client.interfaces; import com.launchdarkly.client.Components; -import com.launchdarkly.client.LDConfig; /** * Interface for a factory that creates some implementation of {@link DataSource}. * @see Components - * @since 5.0.0 + * @since 4.11.0 */ public interface DataSourceFactory { /** * Creates an implementation instance. - * @param sdkKey the SDK key for your LaunchDarkly environment - * @param config the LaunchDarkly configuration + * + * @param context allows access to the client configuration * @param dataStore the {@link DataStore} to use for storing the latest flag state * @return an {@link DataSource} */ - public DataSource createDataSource(String sdkKey, LDConfig config, DataStore dataStore); + public DataSource createDataSource(ClientContext context, DataStore dataStore); } diff --git a/src/main/java/com/launchdarkly/client/interfaces/DataStoreFactory.java b/src/main/java/com/launchdarkly/client/interfaces/DataStoreFactory.java index 5159f43e3..ef4a3cf35 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/DataStoreFactory.java +++ b/src/main/java/com/launchdarkly/client/interfaces/DataStoreFactory.java @@ -5,12 +5,14 @@ /** * Interface for a factory that creates some implementation of {@link DataStore}. * @see Components - * @since 5.0.0 + * @since 4.11.0 */ public interface DataStoreFactory { /** * Creates an implementation instance. + * + * @param context allows access to the client configuration * @return a {@link DataStore} */ - DataStore createDataStore(); + DataStore createDataStore(ClientContext context); } diff --git a/src/main/java/com/launchdarkly/client/interfaces/EventProcessorFactory.java b/src/main/java/com/launchdarkly/client/interfaces/EventProcessorFactory.java index 147460fad..9d5ea1254 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/EventProcessorFactory.java +++ b/src/main/java/com/launchdarkly/client/interfaces/EventProcessorFactory.java @@ -1,7 +1,6 @@ package com.launchdarkly.client.interfaces; import com.launchdarkly.client.Components; -import com.launchdarkly.client.LDConfig; /** * Interface for a factory that creates some implementation of {@link EventProcessor}. @@ -11,9 +10,9 @@ public interface EventProcessorFactory { /** * Creates an implementation instance. - * @param sdkKey the SDK key for your LaunchDarkly environment - * @param config the LaunchDarkly configuration + * + * @param context allows access to the client configuration * @return an {@link EventProcessor} */ - EventProcessor createEventProcessor(String sdkKey, LDConfig config); + EventProcessor createEventProcessor(ClientContext context); } diff --git a/src/main/java/com/launchdarkly/client/interfaces/PersistentDataStoreFactory.java b/src/main/java/com/launchdarkly/client/interfaces/PersistentDataStoreFactory.java index 198ed2dff..6b94d03ad 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/PersistentDataStoreFactory.java +++ b/src/main/java/com/launchdarkly/client/interfaces/PersistentDataStoreFactory.java @@ -16,7 +16,8 @@ public interface PersistentDataStoreFactory { * Called internally from {@link PersistentDataStoreBuilder} to create the implementation object * for the specific type of data store. * + * @param context allows access to the client configuration * @return the implementation object */ - PersistentDataStore createPersistentDataStore(); + PersistentDataStore createPersistentDataStore(ClientContext context); } diff --git a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java index f9024f918..d275c9b30 100644 --- a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java @@ -22,6 +22,7 @@ import static com.launchdarkly.client.ModelBuilders.flagBuilder; import static com.launchdarkly.client.TestHttpUtil.httpsServerWithSelfSignedCert; import static com.launchdarkly.client.TestHttpUtil.makeStartedServer; +import static com.launchdarkly.client.TestUtil.clientContext; import static com.launchdarkly.client.TestUtil.hasJsonProperty; import static com.launchdarkly.client.TestUtil.isJsonArray; import static com.launchdarkly.client.TestUtil.simpleEvaluation; @@ -66,18 +67,18 @@ private DefaultEventProcessor makeEventProcessor(EventProcessorBuilder ec) { } private DefaultEventProcessor makeEventProcessor(EventProcessorBuilder ec, LDConfig config) { - return (DefaultEventProcessor)ec.createEventProcessor(SDK_KEY, config); + return (DefaultEventProcessor)ec.createEventProcessor(clientContext(SDK_KEY, config)); } private DefaultEventProcessor makeEventProcessor(EventProcessorBuilder ec, DiagnosticAccumulator diagnosticAccumulator) { - return (DefaultEventProcessor)((EventProcessorFactoryWithDiagnostics)ec).createEventProcessor(SDK_KEY, - diagLDConfig, diagnosticAccumulator); + return (DefaultEventProcessor)ec.createEventProcessor( + clientContext(SDK_KEY, diagLDConfig, diagnosticAccumulator)); } @Test public void builderHasDefaultConfiguration() throws Exception { EventProcessorFactory epf = Components.sendEvents(); - try (DefaultEventProcessor ep = (DefaultEventProcessor)epf.createEventProcessor(SDK_KEY, LDConfig.DEFAULT)) { + try (DefaultEventProcessor ep = (DefaultEventProcessor)epf.createEventProcessor(clientContext(SDK_KEY, LDConfig.DEFAULT))) { EventsConfiguration ec = ep.dispatcher.eventsConfig; assertThat(ec.allAttributesPrivate, is(false)); assertThat(ec.capacity, equalTo(EventProcessorBuilder.DEFAULT_CAPACITY)); @@ -104,7 +105,7 @@ public void builderCanSpecifyConfiguration() throws Exception { .privateAttributeNames("cats", "dogs") .userKeysCapacity(555) .userKeysFlushInterval(Duration.ofSeconds(101)); - try (DefaultEventProcessor ep = (DefaultEventProcessor)epf.createEventProcessor(SDK_KEY, LDConfig.DEFAULT)) { + try (DefaultEventProcessor ep = (DefaultEventProcessor)epf.createEventProcessor(clientContext(SDK_KEY, LDConfig.DEFAULT))) { EventsConfiguration ec = ep.dispatcher.eventsConfig; assertThat(ec.allAttributesPrivate, is(true)); assertThat(ec.capacity, equalTo(3333)); @@ -120,7 +121,7 @@ public void builderCanSpecifyConfiguration() throws Exception { // 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)) { + try (DefaultEventProcessor ep = (DefaultEventProcessor)epf1.createEventProcessor(clientContext(SDK_KEY, LDConfig.DEFAULT))) { EventsConfiguration ec = ep.dispatcher.eventsConfig; assertThat(ec.allAttributesPrivate, is(false)); assertThat(ec.inlineUsersInEvents, is(true)); diff --git a/src/test/java/com/launchdarkly/client/LDClientTest.java b/src/test/java/com/launchdarkly/client/LDClientTest.java index 16e445900..2288974ea 100644 --- a/src/test/java/com/launchdarkly/client/LDClientTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientTest.java @@ -4,7 +4,9 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.Iterables; +import com.launchdarkly.client.interfaces.ClientContext; import com.launchdarkly.client.interfaces.DataSource; +import com.launchdarkly.client.interfaces.DataSourceFactory; import com.launchdarkly.client.interfaces.DataStore; import com.launchdarkly.client.interfaces.Event; import com.launchdarkly.client.interfaces.EventProcessor; @@ -44,11 +46,12 @@ 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.assertNull; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; @@ -157,55 +160,56 @@ public void pollingClientHasPollingProcessor() throws IOException { @Test public void sameDiagnosticAccumulatorPassedToFactoriesWhenSupported() throws IOException { - EventProcessorFactoryWithDiagnostics mockEventProcessorFactory = createStrictMock(EventProcessorFactoryWithDiagnostics.class); - DataSourceFactoryWithDiagnostics mockDataSourceFactory = createStrictMock(DataSourceFactoryWithDiagnostics.class); + DataSourceFactory mockDataSourceFactory = createStrictMock(DataSourceFactory.class); LDConfig config = new LDConfig.Builder() - .events(mockEventProcessorFactory) .dataSource(mockDataSourceFactory) + .events(Components.sendEvents().baseURI(URI.create("fake-host"))) // event processor will try to send a diagnostic event here .startWait(Duration.ZERO) .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(mockDataSourceFactory.createDataSource(eq(SDK_KEY), isA(LDConfig.class), isA(DataStore.class), capture(capturedUpdateAccumulator))).andReturn(failedDataSource()); + Capture capturedDataSourceContext = Capture.newInstance(); + expect(mockDataSourceFactory.createDataSource(capture(capturedDataSourceContext), isA(DataStore.class))).andReturn(failedDataSource()); replayAll(); try (LDClient client = new LDClient(SDK_KEY, config)) { verifyAll(); - assertNotNull(capturedEventAccumulator.getValue()); - assertEquals(capturedEventAccumulator.getValue(), capturedUpdateAccumulator.getValue()); + DiagnosticAccumulator acc = ((DefaultEventProcessor)client.eventProcessor).dispatcher.diagnosticAccumulator; + assertNotNull(acc); + assertSame(acc, ClientContextImpl.getDiagnosticAccumulator(capturedDataSourceContext.getValue())); } } @Test public void nullDiagnosticAccumulatorPassedToFactoriesWhenOptedOut() throws IOException { - EventProcessorFactoryWithDiagnostics mockEventProcessorFactory = createStrictMock(EventProcessorFactoryWithDiagnostics.class); - DataSourceFactoryWithDiagnostics mockDataSourceFactory = createStrictMock(DataSourceFactoryWithDiagnostics.class); + DataSourceFactory mockDataSourceFactory = createStrictMock(DataSourceFactory.class); LDConfig config = new LDConfig.Builder() - .events(mockEventProcessorFactory) .dataSource(mockDataSourceFactory) .diagnosticOptOut(true) .startWait(Duration.ZERO) .build(); - expect(mockEventProcessorFactory.createEventProcessor(eq(SDK_KEY), isA(LDConfig.class), isNull(DiagnosticAccumulator.class))).andReturn(niceMock(EventProcessor.class)); - expect(mockDataSourceFactory.createDataSource(eq(SDK_KEY), isA(LDConfig.class), isA(DataStore.class), isNull(DiagnosticAccumulator.class))).andReturn(failedDataSource()); + Capture capturedDataSourceContext = Capture.newInstance(); + expect(mockDataSourceFactory.createDataSource(capture(capturedDataSourceContext), isA(DataStore.class))).andReturn(failedDataSource()); replayAll(); try (LDClient client = new LDClient(SDK_KEY, config)) { verifyAll(); + assertNull(((DefaultEventProcessor)client.eventProcessor).dispatcher.diagnosticAccumulator); + assertNull(ClientContextImpl.getDiagnosticAccumulator(capturedDataSourceContext.getValue())); } } @Test public void nullDiagnosticAccumulatorPassedToUpdateFactoryWhenEventProcessorDoesNotSupportDiagnostics() throws IOException { + EventProcessor mockEventProcessor = createStrictMock(EventProcessor.class); + mockEventProcessor.close(); + EasyMock.expectLastCall().anyTimes(); EventProcessorFactory mockEventProcessorFactory = createStrictMock(EventProcessorFactory.class); - DataSourceFactoryWithDiagnostics mockDataSourceFactory = createStrictMock(DataSourceFactoryWithDiagnostics.class); + DataSourceFactory mockDataSourceFactory = createStrictMock(DataSourceFactory.class); LDConfig config = new LDConfig.Builder() .events(mockEventProcessorFactory) @@ -213,13 +217,17 @@ public void nullDiagnosticAccumulatorPassedToUpdateFactoryWhenEventProcessorDoes .startWait(Duration.ZERO) .build(); - expect(mockEventProcessorFactory.createEventProcessor(eq(SDK_KEY), isA(LDConfig.class))).andReturn(niceMock(EventProcessor.class)); - expect(mockDataSourceFactory.createDataSource(eq(SDK_KEY), isA(LDConfig.class), isA(DataStore.class), isNull(DiagnosticAccumulator.class))).andReturn(failedDataSource()); + Capture capturedEventContext = Capture.newInstance(); + Capture capturedDataSourceContext = Capture.newInstance(); + expect(mockEventProcessorFactory.createEventProcessor(capture(capturedEventContext))).andReturn(mockEventProcessor); + expect(mockDataSourceFactory.createDataSource(capture(capturedDataSourceContext), isA(DataStore.class))).andReturn(failedDataSource()); replayAll(); try (LDClient client = new LDClient(SDK_KEY, config)) { verifyAll(); + assertNull(ClientContextImpl.getDiagnosticAccumulator(capturedEventContext.getValue())); + assertNull(ClientContextImpl.getDiagnosticAccumulator(capturedDataSourceContext.getValue())); } } diff --git a/src/test/java/com/launchdarkly/client/PollingProcessorTest.java b/src/test/java/com/launchdarkly/client/PollingProcessorTest.java index d70f5fc9c..7fc4c137e 100644 --- a/src/test/java/com/launchdarkly/client/PollingProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/PollingProcessorTest.java @@ -14,6 +14,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import static com.launchdarkly.client.TestUtil.clientContext; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.junit.Assert.assertFalse; @@ -28,7 +29,7 @@ public class PollingProcessorTest { @Test public void builderHasDefaultConfiguration() throws Exception { DataSourceFactory f = Components.pollingDataSource(); - try (PollingProcessor pp = (PollingProcessor)f.createDataSource(SDK_KEY, LDConfig.DEFAULT, null)) { + try (PollingProcessor pp = (PollingProcessor)f.createDataSource(clientContext(SDK_KEY, LDConfig.DEFAULT), null)) { assertThat(((DefaultFeatureRequestor)pp.requestor).baseUri, equalTo(LDConfig.DEFAULT_BASE_URI)); assertThat(pp.pollInterval, equalTo(PollingDataSourceBuilder.DEFAULT_POLL_INTERVAL)); } @@ -40,7 +41,7 @@ public void builderCanSpecifyConfiguration() throws Exception { DataSourceFactory f = Components.pollingDataSource() .baseURI(uri) .pollInterval(LENGTHY_INTERVAL); - try (PollingProcessor pp = (PollingProcessor)f.createDataSource(SDK_KEY, LDConfig.DEFAULT, null)) { + try (PollingProcessor pp = (PollingProcessor)f.createDataSource(clientContext(SDK_KEY, LDConfig.DEFAULT), null)) { assertThat(((DefaultFeatureRequestor)pp.requestor).baseUri, equalTo(uri)); assertThat(pp.pollInterval, equalTo(LENGTHY_INTERVAL)); } diff --git a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java index e0bdb67f5..a820d8e7d 100644 --- a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java @@ -30,6 +30,7 @@ import static com.launchdarkly.client.ModelBuilders.segmentBuilder; import static com.launchdarkly.client.TestHttpUtil.eventStreamResponse; import static com.launchdarkly.client.TestHttpUtil.makeStartedServer; +import static com.launchdarkly.client.TestUtil.clientContext; import static org.easymock.EasyMock.expect; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; @@ -79,7 +80,7 @@ public void setup() { @Test public void builderHasDefaultConfiguration() throws Exception { DataSourceFactory f = Components.streamingDataSource(); - try (StreamProcessor sp = (StreamProcessor)f.createDataSource(SDK_KEY, LDConfig.DEFAULT, null)) { + try (StreamProcessor sp = (StreamProcessor)f.createDataSource(clientContext(SDK_KEY, LDConfig.DEFAULT), null)) { assertThat(sp.initialReconnectDelay, equalTo(StreamingDataSourceBuilder.DEFAULT_INITIAL_RECONNECT_DELAY)); assertThat(sp.streamUri, equalTo(LDConfig.DEFAULT_STREAM_URI)); assertThat(((DefaultFeatureRequestor)sp.requestor).baseUri, equalTo(LDConfig.DEFAULT_BASE_URI)); @@ -94,7 +95,7 @@ public void builderCanSpecifyConfiguration() throws Exception { .baseURI(streamUri) .initialReconnectDelay(Duration.ofMillis(5555)) .pollingBaseURI(pollUri); - try (StreamProcessor sp = (StreamProcessor)f.createDataSource(SDK_KEY, LDConfig.DEFAULT, null)) { + try (StreamProcessor sp = (StreamProcessor)f.createDataSource(clientContext(SDK_KEY, LDConfig.DEFAULT), null)) { assertThat(sp.initialReconnectDelay, equalTo(Duration.ofMillis(5555))); assertThat(sp.streamUri, equalTo(streamUri)); assertThat(((DefaultFeatureRequestor)sp.requestor).baseUri, equalTo(pollUri)); diff --git a/src/test/java/com/launchdarkly/client/TestUtil.java b/src/test/java/com/launchdarkly/client/TestUtil.java index 402881b96..e1a2f5eed 100644 --- a/src/test/java/com/launchdarkly/client/TestUtil.java +++ b/src/test/java/com/launchdarkly/client/TestUtil.java @@ -3,6 +3,7 @@ import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.SettableFuture; import com.launchdarkly.client.integrations.EventProcessorBuilder; +import com.launchdarkly.client.interfaces.ClientContext; import com.launchdarkly.client.interfaces.DataSource; import com.launchdarkly.client.interfaces.DataSourceFactory; import com.launchdarkly.client.interfaces.DataStore; @@ -32,9 +33,16 @@ @SuppressWarnings("javadoc") public class TestUtil { + public static ClientContext clientContext(final String sdkKey, final LDConfig config) { + return new ClientContextImpl(sdkKey, config, null); + } + + public static ClientContext clientContext(final String sdkKey, final LDConfig config, DiagnosticAccumulator diagnosticAccumulator) { + return new ClientContextImpl(sdkKey, config, diagnosticAccumulator); + } public static DataStoreFactory specificDataStore(final DataStore store) { - return () -> store; + return context -> store; } public static DataStore initedDataStore() { @@ -44,15 +52,15 @@ public static DataStore initedDataStore() { } public static EventProcessorFactory specificEventProcessor(final EventProcessor ep) { - return (String sdkKey, LDConfig config) -> ep; + return context -> ep; } public static DataSourceFactory specificDataSource(final DataSource up) { - return (String sdkKey, LDConfig config, DataStore dataStore) -> up; + return (context, dataStore) -> up; } public static DataSourceFactory dataSourceWithData(final Map, Map> data) { - return (String sdkKey, LDConfig config, final DataStore dataStore) -> { + return (ClientContext context, final DataStore dataStore) -> { return new DataSource() { public Future start() { dataStore.init(data); diff --git a/src/test/java/com/launchdarkly/client/integrations/FileDataSourceTest.java b/src/test/java/com/launchdarkly/client/integrations/FileDataSourceTest.java index e57cdf045..819dd0fa8 100644 --- a/src/test/java/com/launchdarkly/client/integrations/FileDataSourceTest.java +++ b/src/test/java/com/launchdarkly/client/integrations/FileDataSourceTest.java @@ -2,8 +2,8 @@ import com.launchdarkly.client.InMemoryDataStore; import com.launchdarkly.client.LDConfig; -import com.launchdarkly.client.interfaces.DataStore; import com.launchdarkly.client.interfaces.DataSource; +import com.launchdarkly.client.interfaces.DataStore; import org.junit.Test; @@ -15,6 +15,7 @@ import static com.launchdarkly.client.DataModel.DataKinds.FEATURES; import static com.launchdarkly.client.DataModel.DataKinds.SEGMENTS; +import static com.launchdarkly.client.TestUtil.clientContext; 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; @@ -38,10 +39,14 @@ public FileDataSourceTest() throws Exception { private static FileDataSourceBuilder makeFactoryWithFile(Path path) { return FileData.dataSource().filePaths(path); } + + private DataSource makeDataSource(FileDataSourceBuilder builder) { + return builder.createDataSource(clientContext("", config), store); + } @Test public void flagsAreNotLoadedUntilStart() throws Exception { - try (DataSource fp = factory.createDataSource("", config, store)) { + try (DataSource fp = makeDataSource(factory)) { assertThat(store.initialized(), equalTo(false)); assertThat(store.all(FEATURES).size(), equalTo(0)); assertThat(store.all(SEGMENTS).size(), equalTo(0)); @@ -50,7 +55,7 @@ public void flagsAreNotLoadedUntilStart() throws Exception { @Test public void flagsAreLoadedOnStart() throws Exception { - try (DataSource fp = factory.createDataSource("", config, store)) { + try (DataSource fp = makeDataSource(factory)) { fp.start(); assertThat(store.initialized(), equalTo(true)); assertThat(store.all(FEATURES).keySet(), equalTo(ALL_FLAG_KEYS)); @@ -60,7 +65,7 @@ public void flagsAreLoadedOnStart() throws Exception { @Test public void startFutureIsCompletedAfterSuccessfulLoad() throws Exception { - try (DataSource fp = factory.createDataSource("", config, store)) { + try (DataSource fp = makeDataSource(factory)) { Future future = fp.start(); assertThat(future.isDone(), equalTo(true)); } @@ -68,7 +73,7 @@ public void startFutureIsCompletedAfterSuccessfulLoad() throws Exception { @Test public void initializedIsTrueAfterSuccessfulLoad() throws Exception { - try (DataSource fp = factory.createDataSource("", config, store)) { + try (DataSource fp = makeDataSource(factory)) { fp.start(); assertThat(fp.initialized(), equalTo(true)); } @@ -77,7 +82,7 @@ public void initializedIsTrueAfterSuccessfulLoad() throws Exception { @Test public void startFutureIsCompletedAfterUnsuccessfulLoad() throws Exception { factory.filePaths(badFilePath); - try (DataSource fp = factory.createDataSource("", config, store)) { + try (DataSource fp = makeDataSource(factory)) { Future future = fp.start(); assertThat(future.isDone(), equalTo(true)); } @@ -86,7 +91,7 @@ public void startFutureIsCompletedAfterUnsuccessfulLoad() throws Exception { @Test public void initializedIsFalseAfterUnsuccessfulLoad() throws Exception { factory.filePaths(badFilePath); - try (DataSource fp = factory.createDataSource("", config, store)) { + try (DataSource fp = makeDataSource(factory)) { fp.start(); assertThat(fp.initialized(), equalTo(false)); } @@ -98,7 +103,7 @@ public void modifiedFileIsNotReloadedIfAutoUpdateIsOff() throws Exception { FileDataSourceBuilder factory1 = makeFactoryWithFile(file.toPath()); try { setFileContents(file, getResourceContents("flag-only.json")); - try (DataSource fp = factory1.createDataSource("", config, store)) { + try (DataSource fp = makeDataSource(factory1)) { fp.start(); setFileContents(file, getResourceContents("segment-only.json")); Thread.sleep(400); @@ -120,7 +125,7 @@ public void modifiedFileIsReloadedIfAutoUpdateIsOn() throws Exception { long maxMsToWait = 10000; try { setFileContents(file, getResourceContents("flag-only.json")); // this file has 1 flag - try (DataSource fp = factory1.createDataSource("", config, store)) { + try (DataSource fp = makeDataSource(factory1)) { fp.start(); Thread.sleep(1000); setFileContents(file, getResourceContents("all-properties.json")); // this file has all the flags @@ -146,7 +151,7 @@ public void ifFilesAreBadAtStartTimeAutoUpdateCanStillLoadGoodDataLater() throws FileDataSourceBuilder factory1 = makeFactoryWithFile(file.toPath()).autoUpdate(true); long maxMsToWait = 10000; try { - try (DataSource fp = factory1.createDataSource("", config, store)) { + try (DataSource fp = makeDataSource(factory1)) { fp.start(); Thread.sleep(1000); setFileContents(file, getResourceContents("flag-only.json")); // this file has 1 flag diff --git a/src/test/java/com/launchdarkly/client/integrations/RedisDataStoreImplTest.java b/src/test/java/com/launchdarkly/client/integrations/RedisDataStoreImplTest.java index f217b3347..e16aaa512 100644 --- a/src/test/java/com/launchdarkly/client/integrations/RedisDataStoreImplTest.java +++ b/src/test/java/com/launchdarkly/client/integrations/RedisDataStoreImplTest.java @@ -23,12 +23,12 @@ public static void maybeSkipDatabaseTests() { @Override protected RedisDataStoreImpl makeStore() { - return (RedisDataStoreImpl)Redis.dataStore().uri(REDIS_URI).createPersistentDataStore(); + return (RedisDataStoreImpl)Redis.dataStore().uri(REDIS_URI).createPersistentDataStore(null); } @Override protected RedisDataStoreImpl makeStoreWithPrefix(String prefix) { - return (RedisDataStoreImpl)Redis.dataStore().uri(REDIS_URI).prefix(prefix).createPersistentDataStore(); + return (RedisDataStoreImpl)Redis.dataStore().uri(REDIS_URI).prefix(prefix).createPersistentDataStore(null); } @Override diff --git a/src/test/java/com/launchdarkly/client/integrations/RedisDataStoreTest.java b/src/test/java/com/launchdarkly/client/integrations/RedisDataStoreTest.java index 97fed5f94..b2f82b1d7 100644 --- a/src/test/java/com/launchdarkly/client/integrations/RedisDataStoreTest.java +++ b/src/test/java/com/launchdarkly/client/integrations/RedisDataStoreTest.java @@ -37,14 +37,14 @@ protected DataStore makeStore() { } else { builder.noCaching(); } - return builder.createDataStore(); + return builder.createDataStore(null); } @Override protected DataStore makeStoreWithPrefix(String prefix) { return Components.persistentDataStore( Redis.dataStore().uri(REDIS_URI).prefix(prefix) - ).noCaching().createDataStore(); + ).noCaching().createDataStore(null); } @Override From 9ccc71194988e2292239c24ee8096a9421e49c6d Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 31 Jan 2020 16:20:04 -0800 Subject: [PATCH 311/673] fix deprecated usage --- .../java/com/launchdarkly/client/DefaultEventProcessor.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java index fa12bf5e4..e0f9112b8 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_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 MediaType JSON_CONTENT_TYPE = MediaType.parse("application/json; charset=utf-8"); @VisibleForTesting final EventDispatcher dispatcher; private final BlockingQueue inbox; @@ -485,7 +486,7 @@ private static void postJson(OkHttpClient httpClient, Headers headers, String js Request request = new Request.Builder() .url(uriStr) - .post(RequestBody.create(MediaType.parse("application/json; charset=utf-8"), json)) + .post(RequestBody.create(json, JSON_CONTENT_TYPE)) .headers(headers) .build(); From 83c8a47b1f97ce3d0055dd460d7fba787fd232c6 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 31 Jan 2020 16:59:01 -0800 Subject: [PATCH 312/673] (5.0) revise DataSource interface to use a simpler push model --- .../com/launchdarkly/client/Components.java | 15 ++--- .../client/DataStoreClientWrapper.java | 58 ------------------- .../client/DataStoreUpdatesImpl.java | 34 +++++++++++ .../com/launchdarkly/client/LDClient.java | 7 ++- .../launchdarkly/client/PollingProcessor.java | 10 ++-- .../launchdarkly/client/StreamProcessor.java | 27 ++++----- .../integrations/FileDataSourceBuilder.java | 6 +- .../integrations/FileDataSourceImpl.java | 10 ++-- .../client/interfaces/DataSourceFactory.java | 4 +- .../client/interfaces/DataStoreUpdates.java | 35 +++++++++++ .../com/launchdarkly/client/LDClientTest.java | 11 ++-- .../client/PollingProcessorTest.java | 13 +++-- .../client/StreamProcessorTest.java | 5 +- .../com/launchdarkly/client/TestUtil.java | 11 +++- .../integrations/FileDataSourceTest.java | 3 +- 15 files changed, 137 insertions(+), 112 deletions(-) delete mode 100644 src/main/java/com/launchdarkly/client/DataStoreClientWrapper.java create mode 100644 src/main/java/com/launchdarkly/client/DataStoreUpdatesImpl.java create mode 100644 src/main/java/com/launchdarkly/client/interfaces/DataStoreUpdates.java diff --git a/src/main/java/com/launchdarkly/client/Components.java b/src/main/java/com/launchdarkly/client/Components.java index 4e672b8ee..b8b78512e 100644 --- a/src/main/java/com/launchdarkly/client/Components.java +++ b/src/main/java/com/launchdarkly/client/Components.java @@ -10,6 +10,7 @@ import com.launchdarkly.client.interfaces.DataSourceFactory; import com.launchdarkly.client.interfaces.DataStore; import com.launchdarkly.client.interfaces.DataStoreFactory; +import com.launchdarkly.client.interfaces.DataStoreUpdates; import com.launchdarkly.client.interfaces.DiagnosticDescription; import com.launchdarkly.client.interfaces.Event; import com.launchdarkly.client.interfaces.EventProcessor; @@ -248,7 +249,7 @@ private static final class NullDataSourceFactory implements DataSourceFactory, D static final NullDataSourceFactory INSTANCE = new NullDataSourceFactory(); @Override - public DataSource createDataSource(ClientContext context, DataStore dataStore) { + public DataSource createDataSource(ClientContext context, DataStoreUpdates dataStoreUpdates) { if (context.getConfiguration().offline) { // If they have explicitly called offline(true) to disable everything, we'll log this slightly // more specific message. @@ -293,11 +294,11 @@ public void close() throws IOException {} private static final class StreamingDataSourceBuilderImpl extends StreamingDataSourceBuilder implements DiagnosticDescription { @Override - public DataSource createDataSource(ClientContext context, DataStore dataStore) { + public DataSource createDataSource(ClientContext context, DataStoreUpdates dataStoreUpdates) { // Note, we log startup messages under the LDClient class to keep logs more readable if (context.getConfiguration().offline) { - return Components.externalUpdatesOnly().createDataSource(context, dataStore); + return Components.externalUpdatesOnly().createDataSource(context, dataStoreUpdates); } LDClient.logger.info("Enabling streaming API"); @@ -323,7 +324,7 @@ public DataSource createDataSource(ClientContext context, DataStore dataStore) { context.getSdkKey(), context.getConfiguration().httpConfig, requestor, - dataStore, + dataStoreUpdates, null, ClientContextImpl.getDiagnosticAccumulator(context), streamUri, @@ -351,11 +352,11 @@ public LDValue describeConfiguration(LDConfig config) { private static final class PollingDataSourceBuilderImpl extends PollingDataSourceBuilder implements DiagnosticDescription { @Override - public DataSource createDataSource(ClientContext context, DataStore dataStore) { + public DataSource createDataSource(ClientContext context, DataStoreUpdates dataStoreUpdates) { // Note, we log startup messages under the LDClient class to keep logs more readable if (context.getConfiguration().offline) { - return Components.externalUpdatesOnly().createDataSource(context, dataStore); + return Components.externalUpdatesOnly().createDataSource(context, dataStoreUpdates); } LDClient.logger.info("Disabling streaming API"); @@ -367,7 +368,7 @@ public DataSource createDataSource(ClientContext context, DataStore dataStore) { baseURI == null ? LDConfig.DEFAULT_BASE_URI : baseURI, true ); - return new PollingProcessor(requestor, dataStore, pollInterval); + return new PollingProcessor(requestor, dataStoreUpdates, pollInterval); } @Override diff --git a/src/main/java/com/launchdarkly/client/DataStoreClientWrapper.java b/src/main/java/com/launchdarkly/client/DataStoreClientWrapper.java deleted file mode 100644 index ba2c4a5a6..000000000 --- a/src/main/java/com/launchdarkly/client/DataStoreClientWrapper.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.launchdarkly.client; - -import com.launchdarkly.client.interfaces.DataStore; -import com.launchdarkly.client.interfaces.VersionedData; -import com.launchdarkly.client.interfaces.VersionedDataKind; - -import java.io.IOException; -import java.util.Map; - -/** - * Provides additional behavior that the client requires before or after data 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 DataStoreClientWrapper implements DataStore { - private final DataStore store; - - public DataStoreClientWrapper(DataStore store) { - this.store = store; - } - - @Override - public void init(Map, Map> allData) { - store.init(DataStoreDataSetSorter.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/DataStoreUpdatesImpl.java b/src/main/java/com/launchdarkly/client/DataStoreUpdatesImpl.java new file mode 100644 index 000000000..872153e00 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/DataStoreUpdatesImpl.java @@ -0,0 +1,34 @@ +package com.launchdarkly.client; + +import com.launchdarkly.client.interfaces.DataStore; +import com.launchdarkly.client.interfaces.DataStoreUpdates; +import com.launchdarkly.client.interfaces.VersionedData; +import com.launchdarkly.client.interfaces.VersionedDataKind; + +import java.util.Map; + +/** + * The data source will push updates into this component. We then apply any necessary + * transformations before putting them into the data store; currently that just means sorting + * the data set for init(). In the future we may also use this to provide an update listener + * capability. + * + * @since 4.11.0 + */ +final class DataStoreUpdatesImpl implements DataStoreUpdates { + private final DataStore store; + + DataStoreUpdatesImpl(DataStore store) { + this.store = store; + } + + @Override + public void init(Map, Map> allData) { + store.init(DataStoreDataSetSorter.sortAllCollections(allData)); + } + + @Override + public void upsert(VersionedDataKind kind, T item) { + store.upsert(kind, item); + } +} \ 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 61001e0c1..37ce89a6e 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -5,6 +5,7 @@ import com.launchdarkly.client.interfaces.DataSourceFactory; import com.launchdarkly.client.interfaces.DataStore; import com.launchdarkly.client.interfaces.DataStoreFactory; +import com.launchdarkly.client.interfaces.DataStoreUpdates; import com.launchdarkly.client.interfaces.Event; import com.launchdarkly.client.interfaces.EventProcessor; import com.launchdarkly.client.interfaces.EventProcessorFactory; @@ -85,8 +86,7 @@ public LDClient(String sdkKey, LDConfig config) { DataStoreFactory factory = config.dataStoreFactory == null ? Components.inMemoryDataStore() : config.dataStoreFactory; - DataStore store = factory.createDataStore(context); - this.dataStore = new DataStoreClientWrapper(store); + this.dataStore = factory.createDataStore(context); this.evaluator = new Evaluator(new Evaluator.Getters() { public DataModel.FeatureFlag getFlag(String key) { @@ -100,7 +100,8 @@ public DataModel.Segment getSegment(String key) { DataSourceFactory dataSourceFactory = this.config.dataSourceFactory == null ? Components.streamingDataSource() : this.config.dataSourceFactory; - this.dataSource = dataSourceFactory.createDataSource(context, dataStore); + DataStoreUpdates dataStoreUpdates = new DataStoreUpdatesImpl(dataStore); + this.dataSource = dataSourceFactory.createDataSource(context, dataStoreUpdates); Future startFuture = dataSource.start(); if (!this.config.startWait.isZero() && !this.config.startWait.isNegative()) { diff --git a/src/main/java/com/launchdarkly/client/PollingProcessor.java b/src/main/java/com/launchdarkly/client/PollingProcessor.java index f91019d0c..560c3c3fa 100644 --- a/src/main/java/com/launchdarkly/client/PollingProcessor.java +++ b/src/main/java/com/launchdarkly/client/PollingProcessor.java @@ -4,7 +4,7 @@ import com.google.common.util.concurrent.SettableFuture; import com.google.common.util.concurrent.ThreadFactoryBuilder; import com.launchdarkly.client.interfaces.DataSource; -import com.launchdarkly.client.interfaces.DataStore; +import com.launchdarkly.client.interfaces.DataStoreUpdates; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -25,14 +25,14 @@ final class PollingProcessor implements DataSource { private static final Logger logger = LoggerFactory.getLogger(PollingProcessor.class); @VisibleForTesting final FeatureRequestor requestor; - private final DataStore store; + private final DataStoreUpdates dataStoreUpdates; @VisibleForTesting final Duration pollInterval; private AtomicBoolean initialized = new AtomicBoolean(false); private ScheduledExecutorService scheduler = null; - PollingProcessor(FeatureRequestor requestor, DataStore featureStore, Duration pollInterval) { + PollingProcessor(FeatureRequestor requestor, DataStoreUpdates dataStoreUpdates, Duration pollInterval) { this.requestor = requestor; // note that HTTP configuration is applied to the requestor when it is created - this.store = featureStore; + this.dataStoreUpdates = dataStoreUpdates; this.pollInterval = pollInterval; } @@ -63,7 +63,7 @@ public Future start() { scheduler.scheduleAtFixedRate(() -> { try { FeatureRequestor.AllData allData = requestor.getAllData(); - store.init(DefaultFeatureRequestor.toVersionedDataMap(allData)); + dataStoreUpdates.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 b60b30dd0..2de23570d 100644 --- a/src/main/java/com/launchdarkly/client/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/client/StreamProcessor.java @@ -5,7 +5,7 @@ import com.google.gson.Gson; import com.google.gson.JsonElement; import com.launchdarkly.client.interfaces.DataSource; -import com.launchdarkly.client.interfaces.DataStore; +import com.launchdarkly.client.interfaces.DataStoreUpdates; import com.launchdarkly.client.interfaces.VersionedDataKind; import com.launchdarkly.eventsource.ConnectionErrorHandler; import com.launchdarkly.eventsource.ConnectionErrorHandler.Action; @@ -42,7 +42,7 @@ final class StreamProcessor implements DataSource { private static final Logger logger = LoggerFactory.getLogger(StreamProcessor.class); private static final Duration DEAD_CONNECTION_INTERVAL = Duration.ofSeconds(300); -private final DataStore store; + private final DataStoreUpdates dataStoreUpdates; private final HttpConfiguration httpConfig; private final Headers headers; @VisibleForTesting final URI streamUri; @@ -65,13 +65,13 @@ EventSource createEventSource(EventHandler handler, URI streamUri, Duration init String sdkKey, HttpConfiguration httpConfig, FeatureRequestor requestor, - DataStore dataStore, + DataStoreUpdates dataStoreUpdates, EventSourceCreator eventSourceCreator, DiagnosticAccumulator diagnosticAccumulator, URI streamUri, Duration initialReconnectDelay ) { - this.store = dataStore; + this.dataStoreUpdates = dataStoreUpdates; this.httpConfig = httpConfig; this.requestor = requestor; this.diagnosticAccumulator = diagnosticAccumulator; @@ -130,7 +130,7 @@ public void onMessage(String name, MessageEvent event) throws Exception { recordStreamInit(false); esStarted = 0; PutData putData = gson.fromJson(event.getData(), PutData.class); - store.init(DefaultFeatureRequestor.toVersionedDataMap(putData.data)); + dataStoreUpdates.init(DefaultFeatureRequestor.toVersionedDataMap(putData.data)); if (!initialized.getAndSet(true)) { initFuture.set(null); logger.info("Initialized LaunchDarkly client."); @@ -140,9 +140,9 @@ public void onMessage(String name, MessageEvent event) throws Exception { case PATCH: { PatchData data = gson.fromJson(event.getData(), PatchData.class); if (getKeyFromStreamApiPath(FEATURES, data.path) != null) { - store.upsert(FEATURES, gson.fromJson(data.data, DataModel.FeatureFlag.class)); + dataStoreUpdates.upsert(FEATURES, gson.fromJson(data.data, DataModel.FeatureFlag.class)); } else if (getKeyFromStreamApiPath(SEGMENTS, data.path) != null) { - store.upsert(SEGMENTS, gson.fromJson(data.data, DataModel.Segment.class)); + dataStoreUpdates.upsert(SEGMENTS, gson.fromJson(data.data, DataModel.Segment.class)); } break; } @@ -150,11 +150,11 @@ public void onMessage(String name, MessageEvent event) throws Exception { DeleteData data = gson.fromJson(event.getData(), DeleteData.class); String featureKey = getKeyFromStreamApiPath(FEATURES, data.path); if (featureKey != null) { - store.delete(FEATURES, featureKey, data.version); + dataStoreUpdates.upsert(FEATURES, FEATURES.makeDeletedItem(featureKey, data.version)); } else { String segmentKey = getKeyFromStreamApiPath(SEGMENTS, data.path); if (segmentKey != null) { - store.delete(SEGMENTS, segmentKey, data.version); + dataStoreUpdates.upsert(SEGMENTS, SEGMENTS.makeDeletedItem(segmentKey, data.version)); } } break; @@ -162,7 +162,7 @@ public void onMessage(String name, MessageEvent event) throws Exception { case INDIRECT_PUT: try { FeatureRequestor.AllData allData = requestor.getAllData(); - store.init(DefaultFeatureRequestor.toVersionedDataMap(allData)); + dataStoreUpdates.init(DefaultFeatureRequestor.toVersionedDataMap(allData)); if (!initialized.getAndSet(true)) { initFuture.set(null); logger.info("Initialized LaunchDarkly client."); @@ -178,12 +178,12 @@ public void onMessage(String name, MessageEvent event) throws Exception { String featureKey = getKeyFromStreamApiPath(FEATURES, path); if (featureKey != null) { DataModel.FeatureFlag feature = requestor.getFlag(featureKey); - store.upsert(FEATURES, feature); + dataStoreUpdates.upsert(FEATURES, feature); } else { String segmentKey = getKeyFromStreamApiPath(SEGMENTS, path); if (segmentKey != null) { DataModel.Segment segment = requestor.getSegment(segmentKey); - store.upsert(SEGMENTS, segment); + dataStoreUpdates.upsert(SEGMENTS, segment); } } } catch (IOException e) { @@ -232,9 +232,6 @@ public void close() throws IOException { if (es != null) { es.close(); } - if (store != null) { - store.close(); - } requestor.close(); } diff --git a/src/main/java/com/launchdarkly/client/integrations/FileDataSourceBuilder.java b/src/main/java/com/launchdarkly/client/integrations/FileDataSourceBuilder.java index 97b7ae205..f101c7fdb 100644 --- a/src/main/java/com/launchdarkly/client/integrations/FileDataSourceBuilder.java +++ b/src/main/java/com/launchdarkly/client/integrations/FileDataSourceBuilder.java @@ -3,7 +3,7 @@ import com.launchdarkly.client.interfaces.ClientContext; import com.launchdarkly.client.interfaces.DataSource; import com.launchdarkly.client.interfaces.DataSourceFactory; -import com.launchdarkly.client.interfaces.DataStore; +import com.launchdarkly.client.interfaces.DataStoreUpdates; import java.nio.file.InvalidPathException; import java.nio.file.Path; @@ -77,7 +77,7 @@ public FileDataSourceBuilder autoUpdate(boolean autoUpdate) { * Used internally by the LaunchDarkly client. */ @Override - public DataSource createDataSource(ClientContext context, DataStore featureStore) { - return new FileDataSourceImpl(featureStore, sources, autoUpdate); + public DataSource createDataSource(ClientContext context, DataStoreUpdates dataStoreUpdates) { + return new FileDataSourceImpl(dataStoreUpdates, sources, autoUpdate); } } \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/integrations/FileDataSourceImpl.java b/src/main/java/com/launchdarkly/client/integrations/FileDataSourceImpl.java index 40ca74de3..67e9e4e37 100644 --- a/src/main/java/com/launchdarkly/client/integrations/FileDataSourceImpl.java +++ b/src/main/java/com/launchdarkly/client/integrations/FileDataSourceImpl.java @@ -7,7 +7,7 @@ import com.launchdarkly.client.integrations.FileDataSourceParsing.FlagFileParser; import com.launchdarkly.client.integrations.FileDataSourceParsing.FlagFileRep; import com.launchdarkly.client.interfaces.DataSource; -import com.launchdarkly.client.interfaces.DataStore; +import com.launchdarkly.client.interfaces.DataStoreUpdates; import com.launchdarkly.client.interfaces.VersionedData; import com.launchdarkly.client.interfaces.VersionedDataKind; import com.launchdarkly.client.value.LDValue; @@ -45,13 +45,13 @@ final class FileDataSourceImpl implements DataSource { private static final Logger logger = LoggerFactory.getLogger(FileDataSourceImpl.class); - private final DataStore store; + private final DataStoreUpdates dataStoreUpdates; private final DataLoader dataLoader; private final AtomicBoolean inited = new AtomicBoolean(false); private final FileWatcher fileWatcher; - FileDataSourceImpl(DataStore store, List sources, boolean autoUpdate) { - this.store = store; + FileDataSourceImpl(DataStoreUpdates dataStoreUpdates, List sources, boolean autoUpdate) { + this.dataStoreUpdates = dataStoreUpdates; this.dataLoader = new DataLoader(sources); FileWatcher fw = null; @@ -93,7 +93,7 @@ private boolean reload() { logger.error(e.getDescription()); return false; } - store.init(builder.build()); + dataStoreUpdates.init(builder.build()); inited.set(true); return true; } diff --git a/src/main/java/com/launchdarkly/client/interfaces/DataSourceFactory.java b/src/main/java/com/launchdarkly/client/interfaces/DataSourceFactory.java index bf9defe87..01cf9c8b6 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/DataSourceFactory.java +++ b/src/main/java/com/launchdarkly/client/interfaces/DataSourceFactory.java @@ -12,8 +12,8 @@ public interface DataSourceFactory { * Creates an implementation instance. * * @param context allows access to the client configuration - * @param dataStore the {@link DataStore} to use for storing the latest flag state + * @param dataStoreUpdates the component pushes data into the SDK via this interface * @return an {@link DataSource} */ - public DataSource createDataSource(ClientContext context, DataStore dataStore); + public DataSource createDataSource(ClientContext context, DataStoreUpdates dataStoreUpdates); } diff --git a/src/main/java/com/launchdarkly/client/interfaces/DataStoreUpdates.java b/src/main/java/com/launchdarkly/client/interfaces/DataStoreUpdates.java new file mode 100644 index 000000000..98fe5e97a --- /dev/null +++ b/src/main/java/com/launchdarkly/client/interfaces/DataStoreUpdates.java @@ -0,0 +1,35 @@ +package com.launchdarkly.client.interfaces; + +import java.util.Map; + +/** + * Interface that a data source implementation will use to push data into the underlying + * data store. + *

    + * This layer of indirection allows the SDK to perform any other necessary operations that must + * happen when data is updated, by providing its own implementation of {@link DataStoreUpdates}. + * + * @since 5.0.0 + */ +public interface DataStoreUpdates { + /** + * Overwrites the store's contents with a set of items for each collection. + *

    + * All previous data will be discarded, regardless of versioning. + * + * @param allData all objects to be stored + */ + void init(Map, Map> allData); + + /** + * Update or insert the object associated with the specified key, if its version is less than or + * equal the version specified in the argument object. + *

    + * Deletions are implemented by upserting a deleted item placeholder. + * + * @param class of the object to be updated + * @param kind the kind of object to update + * @param item the object to update or insert + */ + void upsert(VersionedDataKind kind, T item); +} diff --git a/src/test/java/com/launchdarkly/client/LDClientTest.java b/src/test/java/com/launchdarkly/client/LDClientTest.java index 2288974ea..c7205ec0b 100644 --- a/src/test/java/com/launchdarkly/client/LDClientTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientTest.java @@ -8,6 +8,7 @@ import com.launchdarkly.client.interfaces.DataSource; import com.launchdarkly.client.interfaces.DataSourceFactory; import com.launchdarkly.client.interfaces.DataStore; +import com.launchdarkly.client.interfaces.DataStoreUpdates; import com.launchdarkly.client.interfaces.Event; import com.launchdarkly.client.interfaces.EventProcessor; import com.launchdarkly.client.interfaces.EventProcessorFactory; @@ -42,7 +43,6 @@ import static com.launchdarkly.client.TestUtil.specificDataStore; 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; @@ -169,7 +169,8 @@ public void sameDiagnosticAccumulatorPassedToFactoriesWhenSupported() throws IOE .build(); Capture capturedDataSourceContext = Capture.newInstance(); - expect(mockDataSourceFactory.createDataSource(capture(capturedDataSourceContext), isA(DataStore.class))).andReturn(failedDataSource()); + expect(mockDataSourceFactory.createDataSource(capture(capturedDataSourceContext), + isA(DataStoreUpdates.class))).andReturn(failedDataSource()); replayAll(); @@ -192,7 +193,8 @@ public void nullDiagnosticAccumulatorPassedToFactoriesWhenOptedOut() throws IOEx .build(); Capture capturedDataSourceContext = Capture.newInstance(); - expect(mockDataSourceFactory.createDataSource(capture(capturedDataSourceContext), isA(DataStore.class))).andReturn(failedDataSource()); + expect(mockDataSourceFactory.createDataSource(capture(capturedDataSourceContext), + isA(DataStoreUpdates.class))).andReturn(failedDataSource()); replayAll(); @@ -220,7 +222,8 @@ public void nullDiagnosticAccumulatorPassedToUpdateFactoryWhenEventProcessorDoes Capture capturedEventContext = Capture.newInstance(); Capture capturedDataSourceContext = Capture.newInstance(); expect(mockEventProcessorFactory.createEventProcessor(capture(capturedEventContext))).andReturn(mockEventProcessor); - expect(mockDataSourceFactory.createDataSource(capture(capturedDataSourceContext), isA(DataStore.class))).andReturn(failedDataSource()); + expect(mockDataSourceFactory.createDataSource(capture(capturedDataSourceContext), + isA(DataStoreUpdates.class))).andReturn(failedDataSource()); replayAll(); diff --git a/src/test/java/com/launchdarkly/client/PollingProcessorTest.java b/src/test/java/com/launchdarkly/client/PollingProcessorTest.java index 7fc4c137e..974eb7306 100644 --- a/src/test/java/com/launchdarkly/client/PollingProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/PollingProcessorTest.java @@ -15,6 +15,7 @@ import java.util.concurrent.TimeoutException; import static com.launchdarkly.client.TestUtil.clientContext; +import static com.launchdarkly.client.TestUtil.dataStoreUpdates; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.junit.Assert.assertFalse; @@ -53,7 +54,7 @@ public void testConnectionOk() throws Exception { requestor.allData = new FeatureRequestor.AllData(new HashMap(), new HashMap()); DataStore store = new InMemoryDataStore(); - try (PollingProcessor pollingProcessor = new PollingProcessor(requestor, store, LENGTHY_INTERVAL)) { + try (PollingProcessor pollingProcessor = new PollingProcessor(requestor, dataStoreUpdates(store), LENGTHY_INTERVAL)) { Future initFuture = pollingProcessor.start(); initFuture.get(1000, TimeUnit.MILLISECONDS); assertTrue(pollingProcessor.initialized()); @@ -67,7 +68,7 @@ public void testConnectionProblem() throws Exception { requestor.ioException = new IOException("This exception is part of a test and yes you should be seeing it."); DataStore store = new InMemoryDataStore(); - try (PollingProcessor pollingProcessor = new PollingProcessor(requestor, store, LENGTHY_INTERVAL)) { + try (PollingProcessor pollingProcessor = new PollingProcessor(requestor, dataStoreUpdates(store), LENGTHY_INTERVAL)) { Future initFuture = pollingProcessor.start(); try { initFuture.get(200L, TimeUnit.MILLISECONDS); @@ -113,7 +114,9 @@ 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(requestor, new InMemoryDataStore(), LENGTHY_INTERVAL)) { + DataStore store = new InMemoryDataStore(); + + try (PollingProcessor pollingProcessor = new PollingProcessor(requestor, dataStoreUpdates(store), LENGTHY_INTERVAL)) { long startTime = System.currentTimeMillis(); Future initFuture = pollingProcessor.start(); try { @@ -130,7 +133,9 @@ 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(requestor, new InMemoryDataStore(), LENGTHY_INTERVAL)) { + DataStore store = new InMemoryDataStore(); + + try (PollingProcessor pollingProcessor = new PollingProcessor(requestor, dataStoreUpdates(store), 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 a820d8e7d..f95845a8b 100644 --- a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java @@ -31,6 +31,7 @@ import static com.launchdarkly.client.TestHttpUtil.eventStreamResponse; import static com.launchdarkly.client.TestHttpUtil.makeStartedServer; import static com.launchdarkly.client.TestUtil.clientContext; +import static com.launchdarkly.client.TestUtil.dataStoreUpdates; import static org.easymock.EasyMock.expect; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; @@ -531,13 +532,13 @@ private StreamProcessor createStreamProcessor(LDConfig config, URI streamUri) { } private StreamProcessor createStreamProcessor(LDConfig config, URI streamUri, DiagnosticAccumulator diagnosticAccumulator) { - return new StreamProcessor(SDK_KEY, config.httpConfig, mockRequestor, dataStore, + return new StreamProcessor(SDK_KEY, config.httpConfig, mockRequestor, dataStoreUpdates(dataStore), new StubEventSourceCreator(), diagnosticAccumulator, streamUri, StreamingDataSourceBuilder.DEFAULT_INITIAL_RECONNECT_DELAY); } private StreamProcessor createStreamProcessorWithRealHttp(LDConfig config, URI streamUri) { - return new StreamProcessor(SDK_KEY, config.httpConfig, mockRequestor, dataStore, null, null, + return new StreamProcessor(SDK_KEY, config.httpConfig, mockRequestor, dataStoreUpdates(dataStore), null, null, streamUri, StreamingDataSourceBuilder.DEFAULT_INITIAL_RECONNECT_DELAY); } diff --git a/src/test/java/com/launchdarkly/client/TestUtil.java b/src/test/java/com/launchdarkly/client/TestUtil.java index e1a2f5eed..b1ceb9e22 100644 --- a/src/test/java/com/launchdarkly/client/TestUtil.java +++ b/src/test/java/com/launchdarkly/client/TestUtil.java @@ -8,6 +8,7 @@ import com.launchdarkly.client.interfaces.DataSourceFactory; import com.launchdarkly.client.interfaces.DataStore; import com.launchdarkly.client.interfaces.DataStoreFactory; +import com.launchdarkly.client.interfaces.DataStoreUpdates; import com.launchdarkly.client.interfaces.Event; import com.launchdarkly.client.interfaces.EventProcessor; import com.launchdarkly.client.interfaces.EventProcessorFactory; @@ -41,6 +42,10 @@ public static ClientContext clientContext(final String sdkKey, final LDConfig co return new ClientContextImpl(sdkKey, config, diagnosticAccumulator); } + public static DataStoreUpdates dataStoreUpdates(final DataStore store) { + return new DataStoreUpdatesImpl(store); + } + public static DataStoreFactory specificDataStore(final DataStore store) { return context -> store; } @@ -56,14 +61,14 @@ public static EventProcessorFactory specificEventProcessor(final EventProcessor } public static DataSourceFactory specificDataSource(final DataSource up) { - return (context, dataStore) -> up; + return (context, dataStoreUpdates) -> up; } public static DataSourceFactory dataSourceWithData(final Map, Map> data) { - return (ClientContext context, final DataStore dataStore) -> { + return (ClientContext context, final DataStoreUpdates dataStoreUpdates) -> { return new DataSource() { public Future start() { - dataStore.init(data); + dataStoreUpdates.init(data); return Futures.immediateFuture(null); } diff --git a/src/test/java/com/launchdarkly/client/integrations/FileDataSourceTest.java b/src/test/java/com/launchdarkly/client/integrations/FileDataSourceTest.java index 819dd0fa8..f4ddc189a 100644 --- a/src/test/java/com/launchdarkly/client/integrations/FileDataSourceTest.java +++ b/src/test/java/com/launchdarkly/client/integrations/FileDataSourceTest.java @@ -16,6 +16,7 @@ import static com.launchdarkly.client.DataModel.DataKinds.FEATURES; import static com.launchdarkly.client.DataModel.DataKinds.SEGMENTS; import static com.launchdarkly.client.TestUtil.clientContext; +import static com.launchdarkly.client.TestUtil.dataStoreUpdates; 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; @@ -41,7 +42,7 @@ private static FileDataSourceBuilder makeFactoryWithFile(Path path) { } private DataSource makeDataSource(FileDataSourceBuilder builder) { - return builder.createDataSource(clientContext("", config), store); + return builder.createDataSource(clientContext("", config), dataStoreUpdates(store)); } @Test From af36cd2141d4b00f59ea02c34d0a36fe1e89eeae Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 31 Jan 2020 17:29:46 -0800 Subject: [PATCH 313/673] rename initialized() to isInitialized() --- .../com/launchdarkly/client/Components.java | 2 +- .../java/com/launchdarkly/client/LDClient.java | 4 ++-- .../launchdarkly/client/PollingProcessor.java | 2 +- .../launchdarkly/client/StreamProcessor.java | 2 +- .../integrations/FileDataSourceImpl.java | 2 +- .../client/interfaces/DataSource.java | 2 +- .../com/launchdarkly/client/LDClientTest.java | 18 +++++++++--------- .../client/PollingProcessorTest.java | 8 ++++---- .../client/StreamProcessorTest.java | 8 ++++---- .../java/com/launchdarkly/client/TestUtil.java | 6 +++--- .../integrations/FileDataSourceTest.java | 4 ++-- 11 files changed, 29 insertions(+), 29 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/Components.java b/src/main/java/com/launchdarkly/client/Components.java index b8b78512e..272d7ec88 100644 --- a/src/main/java/com/launchdarkly/client/Components.java +++ b/src/main/java/com/launchdarkly/client/Components.java @@ -283,7 +283,7 @@ public Future start() { } @Override - public boolean initialized() { + public boolean isInitialized() { return true; } diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index 37ce89a6e..e2cdfadbc 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -116,7 +116,7 @@ public DataModel.Segment getSegment(String key) { logger.error("Exception encountered waiting for LaunchDarkly client initialization: {}", e.toString()); logger.debug(e.toString(), e); } - if (!dataSource.initialized()) { + if (!dataSource.isInitialized()) { logger.warn("LaunchDarkly client was not successfully initialized"); } } @@ -124,7 +124,7 @@ public DataModel.Segment getSegment(String key) { @Override public boolean initialized() { - return dataSource.initialized(); + return dataSource.isInitialized(); } @Override diff --git a/src/main/java/com/launchdarkly/client/PollingProcessor.java b/src/main/java/com/launchdarkly/client/PollingProcessor.java index 560c3c3fa..91424756d 100644 --- a/src/main/java/com/launchdarkly/client/PollingProcessor.java +++ b/src/main/java/com/launchdarkly/client/PollingProcessor.java @@ -37,7 +37,7 @@ final class PollingProcessor implements DataSource { } @Override - public boolean initialized() { + public boolean isInitialized() { return initialized.get(); } diff --git a/src/main/java/com/launchdarkly/client/StreamProcessor.java b/src/main/java/com/launchdarkly/client/StreamProcessor.java index 2de23570d..3e5405eae 100644 --- a/src/main/java/com/launchdarkly/client/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/client/StreamProcessor.java @@ -236,7 +236,7 @@ public void close() throws IOException { } @Override - public boolean initialized() { + public boolean isInitialized() { return initialized.get(); } diff --git a/src/main/java/com/launchdarkly/client/integrations/FileDataSourceImpl.java b/src/main/java/com/launchdarkly/client/integrations/FileDataSourceImpl.java index 67e9e4e37..d03f85a6e 100644 --- a/src/main/java/com/launchdarkly/client/integrations/FileDataSourceImpl.java +++ b/src/main/java/com/launchdarkly/client/integrations/FileDataSourceImpl.java @@ -99,7 +99,7 @@ private boolean reload() { } @Override - public boolean initialized() { + public boolean isInitialized() { return inited.get(); } diff --git a/src/main/java/com/launchdarkly/client/interfaces/DataSource.java b/src/main/java/com/launchdarkly/client/interfaces/DataSource.java index 1d4898464..008f08240 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/DataSource.java +++ b/src/main/java/com/launchdarkly/client/interfaces/DataSource.java @@ -20,7 +20,7 @@ public interface DataSource extends Closeable { * Returns true once the client has been initialized and will never return false again. * @return true if the client has been initialized */ - boolean initialized(); + boolean isInitialized(); /** * Tells the component to shut down and release any resources it is using. diff --git a/src/test/java/com/launchdarkly/client/LDClientTest.java b/src/test/java/com/launchdarkly/client/LDClientTest.java index c7205ec0b..61f043c01 100644 --- a/src/test/java/com/launchdarkly/client/LDClientTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientTest.java @@ -240,7 +240,7 @@ public void noWaitForDataSourceIfWaitMillisIsZero() throws Exception { .startWait(Duration.ZERO); expect(dataSource.start()).andReturn(initFuture); - expect(dataSource.initialized()).andReturn(false); + expect(dataSource.isInitialized()).andReturn(false); replayAll(); client = createMockClient(config); @@ -256,7 +256,7 @@ public void willWaitForDataSourceIfWaitMillisIsNonZero() throws Exception { expect(dataSource.start()).andReturn(initFuture); expect(initFuture.get(10L, TimeUnit.MILLISECONDS)).andReturn(null); - expect(dataSource.initialized()).andReturn(false).anyTimes(); + expect(dataSource.isInitialized()).andReturn(false).anyTimes(); replayAll(); client = createMockClient(config); @@ -272,7 +272,7 @@ public void dataSourceCanTimeOut() throws Exception { expect(dataSource.start()).andReturn(initFuture); expect(initFuture.get(10L, TimeUnit.MILLISECONDS)).andThrow(new TimeoutException()); - expect(dataSource.initialized()).andReturn(false).anyTimes(); + expect(dataSource.isInitialized()).andReturn(false).anyTimes(); replayAll(); client = createMockClient(config); @@ -288,7 +288,7 @@ public void clientCatchesRuntimeExceptionFromDataSource() throws Exception { expect(dataSource.start()).andReturn(initFuture); expect(initFuture.get(10L, TimeUnit.MILLISECONDS)).andThrow(new RuntimeException()); - expect(dataSource.initialized()).andReturn(false).anyTimes(); + expect(dataSource.isInitialized()).andReturn(false).anyTimes(); replayAll(); client = createMockClient(config); @@ -304,7 +304,7 @@ public void isFlagKnownReturnsTrueForExistingFlag() throws Exception { .startWait(Duration.ZERO) .dataStore(specificDataStore(testDataStore)); expect(dataSource.start()).andReturn(initFuture); - expect(dataSource.initialized()).andReturn(true).times(1); + expect(dataSource.isInitialized()).andReturn(true).times(1); replayAll(); client = createMockClient(config); @@ -321,7 +321,7 @@ public void isFlagKnownReturnsFalseForUnknownFlag() throws Exception { .startWait(Duration.ZERO) .dataStore(specificDataStore(testDataStore)); expect(dataSource.start()).andReturn(initFuture); - expect(dataSource.initialized()).andReturn(true).times(1); + expect(dataSource.isInitialized()).andReturn(true).times(1); replayAll(); client = createMockClient(config); @@ -337,7 +337,7 @@ public void isFlagKnownReturnsFalseIfStoreAndClientAreNotInitialized() throws Ex .startWait(Duration.ZERO) .dataStore(specificDataStore(testDataStore)); expect(dataSource.start()).andReturn(initFuture); - expect(dataSource.initialized()).andReturn(false).times(1); + expect(dataSource.isInitialized()).andReturn(false).times(1); replayAll(); client = createMockClient(config); @@ -354,7 +354,7 @@ public void isFlagKnownUsesStoreIfStoreIsInitializedButClientIsNot() throws Exce .startWait(Duration.ZERO) .dataStore(specificDataStore(testDataStore)); expect(dataSource.start()).andReturn(initFuture); - expect(dataSource.initialized()).andReturn(false).times(1); + expect(dataSource.isInitialized()).andReturn(false).times(1); replayAll(); client = createMockClient(config); @@ -371,7 +371,7 @@ public void evaluationUsesStoreIfStoreIsInitializedButClientIsNot() throws Excep .dataStore(specificDataStore(testDataStore)) .startWait(Duration.ZERO); expect(dataSource.start()).andReturn(initFuture); - expect(dataSource.initialized()).andReturn(false); + expect(dataSource.isInitialized()).andReturn(false); expectEventsSent(1); replayAll(); diff --git a/src/test/java/com/launchdarkly/client/PollingProcessorTest.java b/src/test/java/com/launchdarkly/client/PollingProcessorTest.java index 974eb7306..e20095648 100644 --- a/src/test/java/com/launchdarkly/client/PollingProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/PollingProcessorTest.java @@ -57,7 +57,7 @@ public void testConnectionOk() throws Exception { try (PollingProcessor pollingProcessor = new PollingProcessor(requestor, dataStoreUpdates(store), LENGTHY_INTERVAL)) { Future initFuture = pollingProcessor.start(); initFuture.get(1000, TimeUnit.MILLISECONDS); - assertTrue(pollingProcessor.initialized()); + assertTrue(pollingProcessor.isInitialized()); assertTrue(store.initialized()); } } @@ -76,7 +76,7 @@ public void testConnectionProblem() throws Exception { } catch (TimeoutException ignored) { } assertFalse(initFuture.isDone()); - assertFalse(pollingProcessor.initialized()); + assertFalse(pollingProcessor.isInitialized()); assertFalse(store.initialized()); } } @@ -126,7 +126,7 @@ private void testUnrecoverableHttpError(int status) throws Exception { } assertTrue((System.currentTimeMillis() - startTime) < 9000); assertTrue(initFuture.isDone()); - assertFalse(pollingProcessor.initialized()); + assertFalse(pollingProcessor.isInitialized()); } } @@ -143,7 +143,7 @@ private void testRecoverableHttpError(int status) throws Exception { } catch (TimeoutException ignored) { } assertFalse(initFuture.isDone()); - assertFalse(pollingProcessor.initialized()); + assertFalse(pollingProcessor.isInitialized()); } } diff --git a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java index f95845a8b..9c17acb3e 100644 --- a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java @@ -175,7 +175,7 @@ public void putCausesStoreToBeInitialized() throws Exception { public void processorNotInitializedByDefault() throws Exception { StreamProcessor sp = createStreamProcessor(STREAM_URI); sp.start(); - assertFalse(sp.initialized()); + assertFalse(sp.isInitialized()); } @Test @@ -183,7 +183,7 @@ public void putCausesProcessorToBeInitialized() throws Exception { StreamProcessor sp = createStreamProcessor(STREAM_URI); sp.start(); eventHandler.onMessage("put", emptyPutEvent()); - assertTrue(sp.initialized()); + assertTrue(sp.isInitialized()); } @Test @@ -501,7 +501,7 @@ private void testUnrecoverableHttpError(int status) throws Exception { } assertTrue((System.currentTimeMillis() - startTime) < 9000); assertTrue(initFuture.isDone()); - assertFalse(sp.initialized()); + assertFalse(sp.isInitialized()); } private void testRecoverableHttpError(int status) throws Exception { @@ -520,7 +520,7 @@ private void testRecoverableHttpError(int status) throws Exception { } assertTrue((System.currentTimeMillis() - startTime) >= 200); assertFalse(initFuture.isDone()); - assertFalse(sp.initialized()); + assertFalse(sp.isInitialized()); } private StreamProcessor createStreamProcessor(URI streamUri) { diff --git a/src/test/java/com/launchdarkly/client/TestUtil.java b/src/test/java/com/launchdarkly/client/TestUtil.java index b1ceb9e22..232aa9c16 100644 --- a/src/test/java/com/launchdarkly/client/TestUtil.java +++ b/src/test/java/com/launchdarkly/client/TestUtil.java @@ -72,7 +72,7 @@ public Future start() { return Futures.immediateFuture(null); } - public boolean initialized() { + public boolean isInitialized() { return true; } @@ -107,7 +107,7 @@ public void delete(VersionedDataKind kind, String k public void upsert(VersionedDataKind kind, T item) { } @Override - public boolean initialized() { + public boolean isInitialized() { return true; } }; @@ -121,7 +121,7 @@ public Future start() { } @Override - public boolean initialized() { + public boolean isInitialized() { return false; } diff --git a/src/test/java/com/launchdarkly/client/integrations/FileDataSourceTest.java b/src/test/java/com/launchdarkly/client/integrations/FileDataSourceTest.java index f4ddc189a..931407a0c 100644 --- a/src/test/java/com/launchdarkly/client/integrations/FileDataSourceTest.java +++ b/src/test/java/com/launchdarkly/client/integrations/FileDataSourceTest.java @@ -76,7 +76,7 @@ public void startFutureIsCompletedAfterSuccessfulLoad() throws Exception { public void initializedIsTrueAfterSuccessfulLoad() throws Exception { try (DataSource fp = makeDataSource(factory)) { fp.start(); - assertThat(fp.initialized(), equalTo(true)); + assertThat(fp.isInitialized(), equalTo(true)); } } @@ -94,7 +94,7 @@ public void initializedIsFalseAfterUnsuccessfulLoad() throws Exception { factory.filePaths(badFilePath); try (DataSource fp = makeDataSource(factory)) { fp.start(); - assertThat(fp.initialized(), equalTo(false)); + assertThat(fp.isInitialized(), equalTo(false)); } } From b064923efe3224348819a3a3979e6e9f7f2632de Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 31 Jan 2020 19:20:46 -0800 Subject: [PATCH 314/673] fix rename mistake --- 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 232aa9c16..bb7f801b6 100644 --- a/src/test/java/com/launchdarkly/client/TestUtil.java +++ b/src/test/java/com/launchdarkly/client/TestUtil.java @@ -107,7 +107,7 @@ public void delete(VersionedDataKind kind, String k public void upsert(VersionedDataKind kind, T item) { } @Override - public boolean isInitialized() { + public boolean initialized() { return true; } }; From aeffd776398b56a1abed77f068841cad8d256b41 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 4 Mar 2020 00:01:02 -0800 Subject: [PATCH 315/673] implement new DataStore/PersistentDataStore model --- .../com/launchdarkly/client/Components.java | 15 +- .../com/launchdarkly/client/DataModel.java | 88 +-- .../client/DataStoreCacheConfig.java | 258 -------- .../client/DataStoreDataSetSorter.java | 87 ++- .../client/DataStoreUpdatesImpl.java | 13 +- .../client/DefaultFeatureRequestor.java | 33 +- .../client/InMemoryDataStore.java | 111 ++-- .../com/launchdarkly/client/LDClient.java | 46 +- .../launchdarkly/client/PollingProcessor.java | 2 +- .../launchdarkly/client/StreamProcessor.java | 27 +- .../launchdarkly/client/TestDataStore.java | 135 ---- .../client/integrations/CacheMonitor.java | 5 +- .../integrations/FileDataSourceImpl.java | 37 +- .../integrations/FileDataSourceParsing.java | 15 +- .../PersistentDataStoreBuilder.java | 20 +- .../PersistentDataStoreWrapper.java | 405 ++++++++++++ .../integrations/RedisDataStoreImpl.java | 86 +-- .../client/interfaces/DataStore.java | 122 ++-- .../client/interfaces/DataStoreTypes.java | 295 +++++++++ .../client/interfaces/DataStoreUpdates.java | 32 +- .../interfaces/PersistentDataStore.java | 154 +++-- .../client/utils/CachingStoreWrapper.java | 389 ----------- .../client/utils/DataStoreHelpers.java | 61 -- .../client/utils/package-info.java | 4 - .../client/DataStoreCachingTest.java | 109 ---- .../client/DataStoreDatabaseTestBase.java | 240 ------- .../client/DataStoreTestBase.java | 85 ++- .../client/DataStoreTestTypes.java | 172 +++-- .../client/LDClientEvaluationTest.java | 76 +-- .../client/LDClientEventTest.java | 42 +- .../LDClientExternalUpdatesOnlyTest.java | 4 +- .../client/LDClientOfflineTest.java | 4 +- .../com/launchdarkly/client/LDClientTest.java | 72 +-- .../client/PollingProcessorTest.java | 4 +- .../client/StreamProcessorTest.java | 19 +- .../com/launchdarkly/client/TestUtil.java | 66 +- .../client/integrations/DataLoaderTest.java | 15 +- .../integrations/FileDataSourceTest.java | 22 +- .../PersistentDataStoreTestBase.java | 198 +++--- .../PersistentDataStoreWrapperTest.java | 611 ++++++++++++++++++ .../integrations/RedisDataStoreTest.java | 68 -- .../client/utils/CachingStoreWrapperTest.java | 605 ----------------- 42 files changed, 2183 insertions(+), 2669 deletions(-) delete mode 100644 src/main/java/com/launchdarkly/client/DataStoreCacheConfig.java delete mode 100644 src/main/java/com/launchdarkly/client/TestDataStore.java create mode 100644 src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreWrapper.java create mode 100644 src/main/java/com/launchdarkly/client/interfaces/DataStoreTypes.java delete mode 100644 src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java delete mode 100644 src/main/java/com/launchdarkly/client/utils/DataStoreHelpers.java delete mode 100644 src/main/java/com/launchdarkly/client/utils/package-info.java delete mode 100644 src/test/java/com/launchdarkly/client/DataStoreCachingTest.java delete mode 100644 src/test/java/com/launchdarkly/client/DataStoreDatabaseTestBase.java create mode 100644 src/test/java/com/launchdarkly/client/integrations/PersistentDataStoreWrapperTest.java delete mode 100644 src/test/java/com/launchdarkly/client/integrations/RedisDataStoreTest.java delete mode 100644 src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java diff --git a/src/main/java/com/launchdarkly/client/Components.java b/src/main/java/com/launchdarkly/client/Components.java index 272d7ec88..951114fd5 100644 --- a/src/main/java/com/launchdarkly/client/Components.java +++ b/src/main/java/com/launchdarkly/client/Components.java @@ -15,9 +15,7 @@ import com.launchdarkly.client.interfaces.Event; import com.launchdarkly.client.interfaces.EventProcessor; import com.launchdarkly.client.interfaces.EventProcessorFactory; -import com.launchdarkly.client.interfaces.PersistentDataStore; import com.launchdarkly.client.interfaces.PersistentDataStoreFactory; -import com.launchdarkly.client.utils.CachingStoreWrapper; import com.launchdarkly.client.value.LDValue; import java.io.IOException; @@ -435,23 +433,12 @@ public PersistentDataStoreBuilderImpl(PersistentDataStoreFactory persistentDataS super(persistentDataStoreFactory); } - @Override - public DataStore createDataStore(ClientContext context) { - PersistentDataStore core = persistentDataStoreFactory.createPersistentDataStore(context); - DataStoreCacheConfig caching = DataStoreCacheConfig.DEFAULT.ttl(cacheTime) - .staleValuesPolicy(DataStoreCacheConfig.StaleValuesPolicy.fromNewEnum(staleValuesPolicy)); - 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("?"); + return LDValue.of("custom"); } } } diff --git a/src/main/java/com/launchdarkly/client/DataModel.java b/src/main/java/com/launchdarkly/client/DataModel.java index 5a7efc54a..999d79190 100644 --- a/src/main/java/com/launchdarkly/client/DataModel.java +++ b/src/main/java/com/launchdarkly/client/DataModel.java @@ -1,16 +1,14 @@ package com.launchdarkly.client; -import com.google.common.collect.ImmutableList; -import com.google.gson.Gson; import com.google.gson.annotations.JsonAdapter; +import com.launchdarkly.client.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.client.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.client.interfaces.VersionedData; import com.launchdarkly.client.interfaces.VersionedDataKind; import com.launchdarkly.client.value.LDValue; import java.util.List; -import static com.google.common.collect.Iterables.transform; - /** * Defines the full data model for feature flags and user segments, in the format provided by the SDK endpoints of * the LaunchDarkly service. @@ -25,70 +23,32 @@ public abstract class DataModel { */ public static abstract class DataKinds { /** - * The {@link VersionedDataKind} instance that describes feature flag data. + * The {@link DataKind} instance that describes feature flag data. */ - public static VersionedDataKind FEATURES = new DataKindImpl("features", DataModel.FeatureFlag.class, "/flags/", 1) { - public DataModel.FeatureFlag makeDeletedItem(String key, int version) { - return new DataModel.FeatureFlag(key, version, false, null, null, null, null, null, null, null, false, false, false, null, true); - } - - public boolean isDependencyOrdered() { - return true; - } - - public Iterable getDependencyKeys(VersionedData item) { - DataModel.FeatureFlag flag = (DataModel.FeatureFlag)item; - if (flag.getPrerequisites() == null || flag.getPrerequisites().isEmpty()) { - return ImmutableList.of(); - } - return transform(flag.getPrerequisites(), p -> p.getKey()); - } - }; - + public static DataKind FEATURES = new DataKind("features", + DataKinds::serializeItem, + s -> deserializeItem(s, FeatureFlag.class), + DataKinds::serializeDeletedItemPlaceholder); + /** - * The {@link VersionedDataKind} instance that describes user segment data. + * The {@link DataKind} instance that describes user segment data. */ - public static VersionedDataKind SEGMENTS = new DataKindImpl("segments", DataModel.Segment.class, "/segments/", 0) { - - public DataModel.Segment makeDeletedItem(String key, int version) { - return new DataModel.Segment(key, null, null, null, null, version, true); - } - }; + public static DataKind SEGMENTS = new DataKind("segments", + DataKinds::serializeItem, + s -> deserializeItem(s, Segment.class), + DataKinds::serializeDeletedItemPlaceholder); - static abstract class DataKindImpl extends VersionedDataKind { - private static final Gson gson = new Gson(); - - private final String namespace; - private final Class itemClass; - private final String streamApiPath; - private final int priority; - - DataKindImpl(String namespace, Class itemClass, String streamApiPath, int priority) { - this.namespace = namespace; - this.itemClass = itemClass; - this.streamApiPath = streamApiPath; - this.priority = priority; - } - - public String getNamespace() { - return namespace; - } - - public Class getItemClass() { - return itemClass; - } - - public String getStreamApiPath() { - return streamApiPath; - } - - public int getPriority() { - return priority; - } - - public T deserialize(String serializedData) { - return gson.fromJson(serializedData, itemClass); - } + private static String serializeItem(Object o) { + return JsonHelpers.gsonInstance().toJson(o); + } + + private static ItemDescriptor deserializeItem(String s, Class itemClass) { + VersionedData o = JsonHelpers.gsonInstance().fromJson(s, itemClass); + return o.isDeleted() ? ItemDescriptor.deletedItem(o.getVersion()) : new ItemDescriptor(o.getVersion(), o); + } + + private static String serializeDeletedItemPlaceholder(int version) { + return "{\"version\":" + version + ",\"deleted\":true}"; } } diff --git a/src/main/java/com/launchdarkly/client/DataStoreCacheConfig.java b/src/main/java/com/launchdarkly/client/DataStoreCacheConfig.java deleted file mode 100644 index 2695780de..000000000 --- a/src/main/java/com/launchdarkly/client/DataStoreCacheConfig.java +++ /dev/null @@ -1,258 +0,0 @@ -package com.launchdarkly.client; - -import com.google.common.cache.CacheBuilder; -import com.launchdarkly.client.integrations.PersistentDataStoreBuilder; -import com.launchdarkly.client.interfaces.DataStore; - -import java.time.Duration; -import java.util.Objects; -import java.util.concurrent.TimeUnit; - -/** - * Parameters that can be used for {@link DataStore} 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 - * to set other properties: - * - *

    
    - *     Components.redisDataStore()
    - *         .caching(
    - *             DataStoreCacheConfig.enabled()
    - *                 .ttlSeconds(30)
    - *                 .staleValuesPolicy(DataStoreCacheConfig.StaleValuesPolicy.REFRESH)
    - *         )
    - * 
    - * - * @since 4.6.0 - */ -public final class DataStoreCacheConfig { - /** - * The default TTL used by {@link #DEFAULT}. - */ - public static final Duration DEFAULT_TIME = Duration.ofSeconds(15); - - /** - * The caching parameters that the data store should use by default. Caching is enabled, with a - * TTL of {@link #DEFAULT_TIME} and the {@link StaleValuesPolicy#EVICT} policy. - */ - public static final DataStoreCacheConfig DEFAULT = - new DataStoreCacheConfig(DEFAULT_TIME, StaleValuesPolicy.EVICT); - - private static final DataStoreCacheConfig DISABLED = - new DataStoreCacheConfig(Duration.ZERO, StaleValuesPolicy.EVICT); - - private final Duration cacheTime; - private final StaleValuesPolicy staleValuesPolicy; - - /** - * Possible values for {@link DataStoreCacheConfig#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 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 data 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; - - /** - * Used internally for backward compatibility. - * @return the equivalent enum value - * @since 4.12.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.12.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; - } - } - }; - - /** - * Returns a parameter object indicating that caching should be disabled. Specifying any additional - * properties on this object will have no effect. - * @return a {@link DataStoreCacheConfig} instance - */ - public static DataStoreCacheConfig disabled() { - return DISABLED; - } - - /** - * Returns a parameter object indicating that caching should be enabled, using the default TTL of - * {@link #DEFAULT_TIME}. You can further modify the cache properties using the other - * methods of this class. - * @return a {@link DataStoreCacheConfig} instance - */ - public static DataStoreCacheConfig enabled() { - return DEFAULT; - } - - private DataStoreCacheConfig(Duration cacheTime, StaleValuesPolicy staleValuesPolicy) { - this.cacheTime = cacheTime == null ? DEFAULT_TIME : cacheTime; - this.staleValuesPolicy = staleValuesPolicy; - } - - /** - * Returns true if caching will be enabled. - * @return true if the cache TTL is nonzero - */ - public boolean isEnabled() { - return !cacheTime.isZero(); - } - - /** - * 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 cacheTime.isNegative(); - } - - /** - * 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 - */ - public Duration getCacheTime() { - return 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. - * 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 - * @return an updated parameters object - */ - public DataStoreCacheConfig ttl(Duration cacheTime) { - return new DataStoreCacheConfig(cacheTime, staleValuesPolicy); - } - - /** - * Shortcut for calling {@link #ttl(Duration)} with a duration in milliseconds. - * - * @param millis the cache TTL in milliseconds - * @return an updated parameters object - */ - public DataStoreCacheConfig ttlMillis(long millis) { - return ttl(Duration.ofMillis(millis)); - } - - /** - * Shortcut for calling {@link #ttl(Duration)} with a duration in seconds. - * - * @param seconds the cache TTL in seconds - * @return an updated parameters object - */ - public DataStoreCacheConfig ttlSeconds(long seconds) { - return ttl(Duration.ofSeconds(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 DataStoreCacheConfig staleValuesPolicy(StaleValuesPolicy policy) { - return new DataStoreCacheConfig(cacheTime, policy); - } - - @Override - public boolean equals(Object other) { - if (other instanceof DataStoreCacheConfig) { - DataStoreCacheConfig o = (DataStoreCacheConfig) other; - return o.cacheTime.equals(this.cacheTime) && o.staleValuesPolicy == this.staleValuesPolicy; - } - return false; - } - - @Override - public int hashCode() { - return Objects.hash(cacheTime, staleValuesPolicy); - } -} diff --git a/src/main/java/com/launchdarkly/client/DataStoreDataSetSorter.java b/src/main/java/com/launchdarkly/client/DataStoreDataSetSorter.java index 8ba1174f0..e28f35a97 100644 --- a/src/main/java/com/launchdarkly/client/DataStoreDataSetSorter.java +++ b/src/main/java/com/launchdarkly/client/DataStoreDataSetSorter.java @@ -1,14 +1,21 @@ package com.launchdarkly.client; +import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSortedMap; -import com.launchdarkly.client.interfaces.VersionedData; +import com.launchdarkly.client.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.client.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.client.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.client.interfaces.DataStoreTypes.KeyedItems; import com.launchdarkly.client.interfaces.VersionedDataKind; import java.util.Comparator; import java.util.HashMap; import java.util.Map; +import static com.google.common.collect.Iterables.isEmpty; +import static com.google.common.collect.Iterables.transform; + /** * Implements a dependency graph ordering for data to be stored in a data store. We must use this * on every data set that will be passed to {@link com.launchdarkly.client.interfaces.DataStore#init(Map)}. @@ -25,55 +32,83 @@ abstract class DataStoreDataSetSorter { * @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 = + public static FullDataSet sortAllCollections(FullDataSet allData) { + ImmutableSortedMap.Builder> builder = ImmutableSortedMap.orderedBy(dataKindPriorityOrder); - for (Map.Entry, Map> entry: allData.entrySet()) { - VersionedDataKind kind = entry.getKey(); + for (Map.Entry> entry: allData.getData()) { + DataKind kind = entry.getKey(); builder.put(kind, sortCollection(kind, entry.getValue())); } - return builder.build(); + return new FullDataSet<>(builder.build().entrySet()); } - private static Map sortCollection(VersionedDataKind kind, Map input) { - if (!kind.isDependencyOrdered() || input.isEmpty()) { + private static KeyedItems sortCollection(DataKind kind, KeyedItems input) { + if (!isDependencyOrdered(kind) || isEmpty(input.getItems())) { return input; } - Map remainingItems = new HashMap<>(input); - ImmutableMap.Builder builder = ImmutableMap.builder(); + Map remainingItems = new HashMap<>(); + for (Map.Entry e: input.getItems()) { + remainingItems.put(e.getKey(), e.getValue()); + } + 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(kind, entry.getValue(), remainingItems, builder); + for (Map.Entry entry: remainingItems.entrySet()) { + addWithDependenciesFirst(kind, entry.getKey(), entry.getValue(), remainingItems, builder); break; } } - return builder.build(); + return new KeyedItems<>(builder.build().entrySet()); } - private static void addWithDependenciesFirst(VersionedDataKind kind, - VersionedData item, - Map remainingItems, - ImmutableMap.Builder builder) { - remainingItems.remove(item.getKey()); // we won't need to visit this item again - for (String prereqKey: kind.getDependencyKeys(item)) { - VersionedData prereqItem = remainingItems.get(prereqKey); + private static void addWithDependenciesFirst(DataKind kind, + String key, + ItemDescriptor item, + Map remainingItems, + ImmutableMap.Builder builder) { + remainingItems.remove(key); // we won't need to visit this item again + for (String prereqKey: getDependencyKeys(kind, item)) { + ItemDescriptor prereqItem = remainingItems.get(prereqKey); if (prereqItem != null) { - addWithDependenciesFirst(kind, prereqItem, remainingItems, builder); + addWithDependenciesFirst(kind, prereqKey, prereqItem, remainingItems, builder); } } - builder.put(item.getKey(), item); + builder.put(key, item); + } + + private static boolean isDependencyOrdered(DataKind kind) { + return kind == DataModel.DataKinds.FEATURES; + } + + private static Iterable getDependencyKeys(DataKind kind, Object item) { + if (kind == DataModel.DataKinds.FEATURES) { + DataModel.FeatureFlag flag = (DataModel.FeatureFlag)item; + if (flag.getPrerequisites() == null || flag.getPrerequisites().isEmpty()) { + return ImmutableList.of(); + } + return transform(flag.getPrerequisites(), p -> p.getKey()); + } + return null; + } + + private static int getPriority(DataKind kind) { + if (kind == DataModel.DataKinds.FEATURES) { + return 1; + } else if (kind == DataModel.DataKinds.SEGMENTS) { + return 0; + } else { + return kind.getName().length() + 2; + } } - private static Comparator> dataKindPriorityOrder = new Comparator>() { + private static Comparator dataKindPriorityOrder = new Comparator() { @Override - public int compare(VersionedDataKind o1, VersionedDataKind o2) { - return o1.getPriority() - o2.getPriority(); + public int compare(DataKind o1, DataKind o2) { + return getPriority(o1) - getPriority(o2); } }; } diff --git a/src/main/java/com/launchdarkly/client/DataStoreUpdatesImpl.java b/src/main/java/com/launchdarkly/client/DataStoreUpdatesImpl.java index 872153e00..5a830da68 100644 --- a/src/main/java/com/launchdarkly/client/DataStoreUpdatesImpl.java +++ b/src/main/java/com/launchdarkly/client/DataStoreUpdatesImpl.java @@ -1,11 +1,10 @@ package com.launchdarkly.client; import com.launchdarkly.client.interfaces.DataStore; +import com.launchdarkly.client.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.client.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.client.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.client.interfaces.DataStoreUpdates; -import com.launchdarkly.client.interfaces.VersionedData; -import com.launchdarkly.client.interfaces.VersionedDataKind; - -import java.util.Map; /** * The data source will push updates into this component. We then apply any necessary @@ -23,12 +22,12 @@ final class DataStoreUpdatesImpl implements DataStoreUpdates { } @Override - public void init(Map, Map> allData) { + public void init(FullDataSet allData) { store.init(DataStoreDataSetSorter.sortAllCollections(allData)); } @Override - public void upsert(VersionedDataKind kind, T item) { - store.upsert(kind, item); + public void upsert(DataKind kind, String key, ItemDescriptor item) { + store.upsert(kind, key, item); } } \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java b/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java index 44071a408..aa286d936 100644 --- a/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java +++ b/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java @@ -1,9 +1,11 @@ package com.launchdarkly.client; import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableMap; import com.google.common.io.Files; -import com.launchdarkly.client.interfaces.VersionedData; -import com.launchdarkly.client.interfaces.VersionedDataKind; +import com.launchdarkly.client.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.client.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.client.interfaces.DataStoreTypes.KeyedItems; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -11,11 +13,8 @@ import java.io.File; import java.io.IOException; import java.net.URI; -import java.util.HashMap; import java.util.Map; -import static com.launchdarkly.client.DataModel.DataKinds.FEATURES; -import static com.launchdarkly.client.DataModel.DataKinds.SEGMENTS; import static com.launchdarkly.client.JsonHelpers.gsonInstance; import static com.launchdarkly.client.Util.configureHttpClientBuilder; import static com.launchdarkly.client.Util.getHeadersBuilderFor; @@ -79,12 +78,24 @@ public AllData getAllData() throws IOException, HttpErrorException { String body = get(GET_LATEST_ALL_PATH); return gsonInstance().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; + + static FullDataSet toFullDataSet(AllData allData) { + ImmutableMap.Builder flagsBuilder = ImmutableMap.builder(); + ImmutableMap.Builder segmentsBuilder = ImmutableMap.builder(); + if (allData.flags != null) { + for (Map.Entry e: allData.flags.entrySet()) { + flagsBuilder.put(e.getKey(), new ItemDescriptor(e.getValue().getVersion(), e.getValue())); + } + } + if (allData.segments != null) { + for (Map.Entry e: allData.segments.entrySet()) { + segmentsBuilder.put(e.getKey(), new ItemDescriptor(e.getValue().getVersion(), e.getValue())); + } + } + return new FullDataSet(ImmutableMap.of( + DataModel.DataKinds.FEATURES, new KeyedItems<>(flagsBuilder.build().entrySet()), + DataModel.DataKinds.SEGMENTS, new KeyedItems<>(segmentsBuilder.build().entrySet()) + ).entrySet()); } private String get(String path) throws IOException, HttpErrorException { diff --git a/src/main/java/com/launchdarkly/client/InMemoryDataStore.java b/src/main/java/com/launchdarkly/client/InMemoryDataStore.java index 6c145a023..632fa692b 100644 --- a/src/main/java/com/launchdarkly/client/InMemoryDataStore.java +++ b/src/main/java/com/launchdarkly/client/InMemoryDataStore.java @@ -1,14 +1,14 @@ package com.launchdarkly.client; +import com.google.common.collect.ImmutableList; import com.launchdarkly.client.interfaces.DataStore; +import com.launchdarkly.client.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.client.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.client.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.client.interfaces.DataStoreTypes.KeyedItems; import com.launchdarkly.client.interfaces.DiagnosticDescription; -import com.launchdarkly.client.interfaces.VersionedData; -import com.launchdarkly.client.interfaces.VersionedDataKind; import com.launchdarkly.client.value.LDValue; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import java.io.IOException; import java.util.HashMap; import java.util.Map; @@ -19,118 +19,79 @@ * {@link HashMap}. This is the default implementation of {@link DataStore}. */ public class InMemoryDataStore implements DataStore, DiagnosticDescription { - private static final Logger logger = LoggerFactory.getLogger(InMemoryDataStore.class); - private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); - private final Map, Map> allData = new HashMap<>(); + private final Map> allData = new HashMap<>(); private volatile boolean initialized = false; @Override - public T get(VersionedDataKind kind, String key) { + public void init(FullDataSet allData) { 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; + lock.writeLock().lock(); + this.allData.clear(); + for (Map.Entry> e0: allData.getData()) { + DataKind kind = e0.getKey(); + Map itemsMap = new HashMap<>(); + for (Map.Entry e1: e0.getValue().getItems()) { + itemsMap.put(e1.getKey(), e1.getValue()); + } + this.allData.put(kind, itemsMap); } - logger.debug("[get] Key: {} with version: {} found in \"{}\".", key, item.getVersion(), kind.getNamespace()); - return item; + initialized = true; } finally { - lock.readLock().unlock(); + lock.writeLock().unlock(); } } @Override - public Map all(VersionedDataKind kind) { + public ItemDescriptor get(DataKind kind, String key) { 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 items = allData.get(kind); + if (items == null) { + return null; } - return fs; + return items.get(key); } finally { lock.readLock().unlock(); } } @Override - public void init(Map, Map> allData) { + public KeyedItems getAll(DataKind kind) { try { - lock.writeLock().lock(); - this.allData.clear(); - for (Map.Entry, Map> entry: allData.entrySet()) { - // Note, the DataStore 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 { - lock.writeLock().unlock(); - } - } - - @Override - public void delete(VersionedDataKind kind, String key, int version) { - try { - lock.writeLock().lock(); - Map items = allData.get(kind); + lock.readLock().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)); + return new KeyedItems<>(null); } + return new KeyedItems<>(ImmutableList.copyOf(items.entrySet())); } finally { - lock.writeLock().unlock(); + lock.readLock().unlock(); } } @Override - public void upsert(VersionedDataKind kind, T item) { + public boolean upsert(DataKind kind, String key, ItemDescriptor item) { try { lock.writeLock().lock(); - Map items = (Map) allData.get(kind); + Map items = allData.get(kind); if (items == null) { items = new HashMap<>(); allData.put(kind, items); } - VersionedData old = items.get(item.getKey()); - + ItemDescriptor old = items.get(key); if (old == null || old.getVersion() < item.getVersion()) { - items.put(item.getKey(), item); + items.put(key, item); + return true; } + return false; } finally { lock.writeLock().unlock(); } } @Override - public boolean initialized() { + public boolean isInitialized() { return initialized; } diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index e2cdfadbc..50bf4b9d4 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -5,6 +5,8 @@ import com.launchdarkly.client.interfaces.DataSourceFactory; import com.launchdarkly.client.interfaces.DataStore; import com.launchdarkly.client.interfaces.DataStoreFactory; +import com.launchdarkly.client.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.client.interfaces.DataStoreTypes.KeyedItems; import com.launchdarkly.client.interfaces.DataStoreUpdates; import com.launchdarkly.client.interfaces.Event; import com.launchdarkly.client.interfaces.EventProcessor; @@ -31,8 +33,6 @@ import javax.crypto.spec.SecretKeySpec; import static com.google.common.base.Preconditions.checkNotNull; -import static com.launchdarkly.client.DataModel.DataKinds.FEATURES; -import static com.launchdarkly.client.DataModel.DataKinds.SEGMENTS; /** * A client for the LaunchDarkly API. Client instances are thread-safe. Applications should instantiate @@ -62,6 +62,24 @@ public LDClient(String sdkKey) { this(sdkKey, LDConfig.DEFAULT); } + private static final DataModel.FeatureFlag getFlagIfNotDeleted(DataStore store, String key) { + ItemDescriptor item = store.get(DataModel.DataKinds.FEATURES, key); + if (item == null) { + return null; + } + DataModel.FeatureFlag flag = (DataModel.FeatureFlag)item.getItem(); + return flag.isDeleted() ? null : flag; + } + + private static final DataModel.Segment getSegmentIfNotDeleted(DataStore store, String key) { + ItemDescriptor item = store.get(DataModel.DataKinds.SEGMENTS, key); + if (item == null) { + return null; + } + DataModel.Segment segment = (DataModel.Segment)item.getItem(); + return segment.isDeleted() ? null : segment; + } + /** * 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. @@ -90,11 +108,11 @@ public LDClient(String sdkKey, LDConfig config) { this.evaluator = new Evaluator(new Evaluator.Getters() { public DataModel.FeatureFlag getFlag(String key) { - return LDClient.this.dataStore.get(FEATURES, key); + return getFlagIfNotDeleted(LDClient.this.dataStore, key); } public DataModel.Segment getSegment(String key) { - return LDClient.this.dataStore.get(SEGMENTS, key); + return getSegmentIfNotDeleted(LDClient.this.dataStore, key); } }); @@ -173,7 +191,7 @@ public FeatureFlagsState allFlagsState(LDUser user, FlagsStateOption... options) } if (!initialized()) { - if (dataStore.initialized()) { + if (dataStore.isInitialized()) { logger.warn("allFlagsState() was called before client initialized; using last known values from data store"); } else { logger.warn("allFlagsState() was called before client initialized; data store unavailable, returning no data"); @@ -187,10 +205,10 @@ public FeatureFlagsState allFlagsState(LDUser user, FlagsStateOption... options) } boolean clientSideOnly = FlagsStateOption.hasOption(options, FlagsStateOption.CLIENT_SIDE_ONLY); - Map flags = dataStore.all(FEATURES); - for (Map.Entry entry : flags.entrySet()) { - DataModel.FeatureFlag flag = entry.getValue(); - if (clientSideOnly && !flag.isClientSide()) { + KeyedItems flags = dataStore.getAll(DataModel.DataKinds.FEATURES); + for (Map.Entry entry : flags.getItems()) { + DataModel.FeatureFlag flag = (DataModel.FeatureFlag)entry.getValue().getItem(); + if (flag.isDeleted() || (clientSideOnly && !flag.isClientSide())) { continue; } try { @@ -199,7 +217,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(), new Evaluator.EvalResult(LDValue.ofNull(), null, EvaluationReason.exception(e))); + builder.addFlag(flag, new Evaluator.EvalResult(LDValue.ofNull(), null, EvaluationReason.exception(e))); } } return builder.build(); @@ -271,7 +289,7 @@ public EvaluationDetail jsonValueVariationDetail(String featureKey, LDU @Override public boolean isFlagKnown(String featureKey) { if (!initialized()) { - if (dataStore.initialized()) { + if (dataStore.isInitialized()) { logger.warn("isFlagKnown called before client initialized for feature flag \"{}\"; using last known values from data store", featureKey); } else { logger.warn("isFlagKnown called before client initialized for feature flag \"{}\"; data store unavailable, returning false", featureKey); @@ -280,7 +298,7 @@ public boolean isFlagKnown(String featureKey) { } try { - if (dataStore.get(FEATURES, featureKey) != null) { + if (getFlagIfNotDeleted(dataStore, featureKey) != null) { return true; } } catch (Exception e) { @@ -302,7 +320,7 @@ private Evaluator.EvalResult errorResult(EvaluationReason.ErrorKind errorKind, f private Evaluator.EvalResult evaluateInternal(String featureKey, LDUser user, LDValue defaultValue, boolean checkType, EventFactory eventFactory) { if (!initialized()) { - if (dataStore.initialized()) { + if (dataStore.isInitialized()) { logger.warn("Evaluation called before client initialized for feature flag \"{}\"; using last known values from data store", featureKey); } else { logger.warn("Evaluation called before client initialized for feature flag \"{}\"; data store unavailable, returning default value", featureKey); @@ -314,7 +332,7 @@ private Evaluator.EvalResult evaluateInternal(String featureKey, LDUser user, LD DataModel.FeatureFlag featureFlag = null; try { - featureFlag = dataStore.get(FEATURES, featureKey); + featureFlag = getFlagIfNotDeleted(dataStore, featureKey); if (featureFlag == null) { logger.info("Unknown feature flag \"{}\"; returning default value", featureKey); sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue, diff --git a/src/main/java/com/launchdarkly/client/PollingProcessor.java b/src/main/java/com/launchdarkly/client/PollingProcessor.java index 91424756d..a71e15002 100644 --- a/src/main/java/com/launchdarkly/client/PollingProcessor.java +++ b/src/main/java/com/launchdarkly/client/PollingProcessor.java @@ -63,7 +63,7 @@ public Future start() { scheduler.scheduleAtFixedRate(() -> { try { FeatureRequestor.AllData allData = requestor.getAllData(); - dataStoreUpdates.init(DefaultFeatureRequestor.toVersionedDataMap(allData)); + dataStoreUpdates.init(DefaultFeatureRequestor.toFullDataSet(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 3e5405eae..fd507b2c3 100644 --- a/src/main/java/com/launchdarkly/client/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/client/StreamProcessor.java @@ -5,8 +5,9 @@ import com.google.gson.Gson; import com.google.gson.JsonElement; import com.launchdarkly.client.interfaces.DataSource; +import com.launchdarkly.client.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.client.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.client.interfaces.DataStoreUpdates; -import com.launchdarkly.client.interfaces.VersionedDataKind; import com.launchdarkly.eventsource.ConnectionErrorHandler; import com.launchdarkly.eventsource.ConnectionErrorHandler.Action; import com.launchdarkly.eventsource.EventHandler; @@ -130,7 +131,7 @@ public void onMessage(String name, MessageEvent event) throws Exception { recordStreamInit(false); esStarted = 0; PutData putData = gson.fromJson(event.getData(), PutData.class); - dataStoreUpdates.init(DefaultFeatureRequestor.toVersionedDataMap(putData.data)); + dataStoreUpdates.init(DefaultFeatureRequestor.toFullDataSet(putData.data)); if (!initialized.getAndSet(true)) { initFuture.set(null); logger.info("Initialized LaunchDarkly client."); @@ -140,21 +141,24 @@ public void onMessage(String name, MessageEvent event) throws Exception { case PATCH: { PatchData data = gson.fromJson(event.getData(), PatchData.class); if (getKeyFromStreamApiPath(FEATURES, data.path) != null) { - dataStoreUpdates.upsert(FEATURES, gson.fromJson(data.data, DataModel.FeatureFlag.class)); + DataModel.FeatureFlag flag = JsonHelpers.gsonInstance().fromJson(data.data, DataModel.FeatureFlag.class); + dataStoreUpdates.upsert(FEATURES, flag.getKey(), new ItemDescriptor(flag.getVersion(), flag)); } else if (getKeyFromStreamApiPath(SEGMENTS, data.path) != null) { - dataStoreUpdates.upsert(SEGMENTS, gson.fromJson(data.data, DataModel.Segment.class)); + DataModel.Segment segment = JsonHelpers.gsonInstance().fromJson(data.data, DataModel.Segment.class); + dataStoreUpdates.upsert(SEGMENTS, segment.getKey(), new ItemDescriptor(segment.getVersion(), segment)); } break; } case DELETE: { DeleteData data = gson.fromJson(event.getData(), DeleteData.class); + ItemDescriptor placeholder = new ItemDescriptor(data.version, null); String featureKey = getKeyFromStreamApiPath(FEATURES, data.path); if (featureKey != null) { - dataStoreUpdates.upsert(FEATURES, FEATURES.makeDeletedItem(featureKey, data.version)); + dataStoreUpdates.upsert(FEATURES, featureKey, placeholder); } else { String segmentKey = getKeyFromStreamApiPath(SEGMENTS, data.path); if (segmentKey != null) { - dataStoreUpdates.upsert(SEGMENTS, SEGMENTS.makeDeletedItem(segmentKey, data.version)); + dataStoreUpdates.upsert(SEGMENTS, segmentKey, placeholder); } } break; @@ -162,7 +166,7 @@ public void onMessage(String name, MessageEvent event) throws Exception { case INDIRECT_PUT: try { FeatureRequestor.AllData allData = requestor.getAllData(); - dataStoreUpdates.init(DefaultFeatureRequestor.toVersionedDataMap(allData)); + dataStoreUpdates.init(DefaultFeatureRequestor.toFullDataSet(allData)); if (!initialized.getAndSet(true)) { initFuture.set(null); logger.info("Initialized LaunchDarkly client."); @@ -178,12 +182,12 @@ public void onMessage(String name, MessageEvent event) throws Exception { String featureKey = getKeyFromStreamApiPath(FEATURES, path); if (featureKey != null) { DataModel.FeatureFlag feature = requestor.getFlag(featureKey); - dataStoreUpdates.upsert(FEATURES, feature); + dataStoreUpdates.upsert(FEATURES, featureKey, new ItemDescriptor(feature.getVersion(), feature)); } else { String segmentKey = getKeyFromStreamApiPath(SEGMENTS, path); if (segmentKey != null) { DataModel.Segment segment = requestor.getSegment(segmentKey); - dataStoreUpdates.upsert(SEGMENTS, segment); + dataStoreUpdates.upsert(SEGMENTS, segmentKey, new ItemDescriptor(segment.getVersion(), segment)); } } } catch (IOException e) { @@ -240,8 +244,9 @@ public boolean isInitialized() { return initialized.get(); } - private static String getKeyFromStreamApiPath(VersionedDataKind kind, String path) { - return path.startsWith(kind.getStreamApiPath()) ? path.substring(kind.getStreamApiPath().length()) : null; + private static String getKeyFromStreamApiPath(DataKind kind, String path) { + String prefix = (kind == SEGMENTS) ? "/segments/" : "/features/"; + return path.startsWith(prefix) ? path.substring(prefix.length()) : null; } private static final class PutData { diff --git a/src/main/java/com/launchdarkly/client/TestDataStore.java b/src/main/java/com/launchdarkly/client/TestDataStore.java deleted file mode 100644 index 1e220f747..000000000 --- a/src/main/java/com/launchdarkly/client/TestDataStore.java +++ /dev/null @@ -1,135 +0,0 @@ -package com.launchdarkly.client; - -import com.launchdarkly.client.interfaces.VersionedData; -import com.launchdarkly.client.interfaces.VersionedDataKind; -import com.launchdarkly.client.value.LDValue; - -import java.util.Arrays; -import java.util.List; -import java.util.Map; -import java.util.concurrent.atomic.AtomicInteger; - -import static com.launchdarkly.client.DataModel.DataKinds.FEATURES; - -/** - * A decorated {@link InMemoryDataStore} which provides functionality to create (or override) true or false feature flags for all users. - *

    - * Using this store is useful for testing purposes when you want to have runtime support for turning specific features on or off. - * - * @deprecated Will be replaced by a file-based test fixture. - */ -@Deprecated -public class TestDataStore extends InMemoryDataStore { - static List TRUE_FALSE_VARIATIONS = Arrays.asList(LDValue.of(true), LDValue.of(false)); - - private AtomicInteger version = new AtomicInteger(0); - private volatile boolean initializedForTests = false; - - /** - * Sets the value of a boolean feature flag for all users. - * - * @param key the key of the feature flag - * @param value the new value of the feature flag - * @return the feature flag - */ - public DataModel.FeatureFlag setBooleanValue(String key, boolean value) { - return setJsonValue(key, LDValue.of(value)); - } - - /** - * Turns a feature, identified by key, to evaluate to true for every user. If the feature rules already exist in the store then it will override it to be true for every {@link LDUser}. - * 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 DataModel.FeatureFlag setFeatureTrue(String key) { - return setBooleanValue(key, true); - } - - /** - * Turns a feature, identified by key, to evaluate to false for every user. If the feature rules already exist in the store then it will override it to be false for every {@link LDUser}. - * 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 DataModel.FeatureFlag setFeatureFalse(String key) { - return setBooleanValue(key, false); - } - - /** - * 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 DataModel.FeatureFlag setIntegerValue(String key, int value) { - return setJsonValue(key, LDValue.of(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 DataModel.FeatureFlag setDoubleValue(String key, double value) { - return setJsonValue(key, LDValue.of(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 DataModel.FeatureFlag setStringValue(String key, String value) { - return setJsonValue(key, LDValue.of(value)); - } - - /** - * Sets the value of a 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 DataModel.FeatureFlag setJsonValue(String key, LDValue value) { - DataModel.FeatureFlag newFeature = new DataModel.FeatureFlag(key, - version.incrementAndGet(), - false, - null, - null, - null, - null, - null, - 0, - Arrays.asList(value), - false, - false, - false, - null, - false); - upsert(FEATURES, newFeature); - return newFeature; - } - - @Override - public void init(Map, Map> allData) { - super.init(allData); - initializedForTests = true; - } - - @Override - public boolean initialized() { - return initializedForTests; - } - - /** - * Sets the initialization status that the data 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/integrations/CacheMonitor.java b/src/main/java/com/launchdarkly/client/integrations/CacheMonitor.java index 977982d9f..e3592029a 100644 --- a/src/main/java/com/launchdarkly/client/integrations/CacheMonitor.java +++ b/src/main/java/com/launchdarkly/client/integrations/CacheMonitor.java @@ -20,11 +20,8 @@ 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) { + void setSource(Callable source) { this.source = source; } diff --git a/src/main/java/com/launchdarkly/client/integrations/FileDataSourceImpl.java b/src/main/java/com/launchdarkly/client/integrations/FileDataSourceImpl.java index d03f85a6e..e65a19785 100644 --- a/src/main/java/com/launchdarkly/client/integrations/FileDataSourceImpl.java +++ b/src/main/java/com/launchdarkly/client/integrations/FileDataSourceImpl.java @@ -1,5 +1,6 @@ package com.launchdarkly.client.integrations; +import com.google.common.collect.ImmutableList; import com.google.common.util.concurrent.Futures; import com.launchdarkly.client.DataModel; import com.launchdarkly.client.integrations.FileDataSourceParsing.FileDataException; @@ -7,9 +8,11 @@ import com.launchdarkly.client.integrations.FileDataSourceParsing.FlagFileParser; import com.launchdarkly.client.integrations.FileDataSourceParsing.FlagFileRep; import com.launchdarkly.client.interfaces.DataSource; +import com.launchdarkly.client.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.client.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.client.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.client.interfaces.DataStoreTypes.KeyedItems; import com.launchdarkly.client.interfaces.DataStoreUpdates; -import com.launchdarkly.client.interfaces.VersionedData; -import com.launchdarkly.client.interfaces.VersionedDataKind; import com.launchdarkly.client.value.LDValue; import org.slf4j.Logger; @@ -25,6 +28,7 @@ import java.nio.file.WatchKey; import java.nio.file.WatchService; import java.nio.file.Watchable; +import java.util.AbstractMap; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; @@ -214,17 +218,17 @@ public void load(DataBuilder builder) throws FileDataException FlagFileRep fileContents = parser.parse(new ByteArrayInputStream(data)); if (fileContents.flags != null) { for (Map.Entry e: fileContents.flags.entrySet()) { - builder.add(DataModel.DataKinds.FEATURES, FlagFactory.flagFromJson(e.getValue())); + builder.add(DataModel.DataKinds.FEATURES, e.getKey(), FlagFactory.flagFromJson(e.getValue())); } } if (fileContents.flagValues != null) { for (Map.Entry e: fileContents.flagValues.entrySet()) { - builder.add(DataModel.DataKinds.FEATURES, FlagFactory.flagWithValue(e.getKey(), e.getValue())); + builder.add(DataModel.DataKinds.FEATURES, e.getKey(), FlagFactory.flagWithValue(e.getKey(), e.getValue())); } } if (fileContents.segments != null) { for (Map.Entry e: fileContents.segments.entrySet()) { - builder.add(DataModel.DataKinds.SEGMENTS, FlagFactory.segmentFromJson(e.getValue())); + builder.add(DataModel.DataKinds.SEGMENTS, e.getKey(), FlagFactory.segmentFromJson(e.getValue())); } } } catch (FileDataException e) { @@ -241,23 +245,26 @@ public void load(DataBuilder builder) throws FileDataException * 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<>(); + private final Map> allData = new HashMap<>(); - public Map, Map> build() { - return allData; + public FullDataSet build() { + ImmutableList.Builder>> allBuilder = ImmutableList.builder(); + for (Map.Entry> e0: allData.entrySet()) { + allBuilder.add(new AbstractMap.SimpleEntry<>(e0.getKey(), new KeyedItems<>(e0.getValue().entrySet()))); + } + return new FullDataSet<>(allBuilder.build()); } - public void add(VersionedDataKind kind, VersionedData item) throws FileDataException { - @SuppressWarnings("unchecked") - Map items = (Map)allData.get(kind); + public void add(DataKind kind, String key, ItemDescriptor item) throws FileDataException { + Map items = allData.get(kind); if (items == null) { - items = new HashMap(); + 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); + if (items.containsKey(key)) { + throw new FileDataException("in " + kind.getName() + ", key \"" + key + "\" was already defined", null, null); } - items.put(item.getKey(), item); + items.put(key, item); } } } diff --git a/src/main/java/com/launchdarkly/client/integrations/FileDataSourceParsing.java b/src/main/java/com/launchdarkly/client/integrations/FileDataSourceParsing.java index 137f6e3ea..a8c461e35 100644 --- a/src/main/java/com/launchdarkly/client/integrations/FileDataSourceParsing.java +++ b/src/main/java/com/launchdarkly/client/integrations/FileDataSourceParsing.java @@ -4,7 +4,7 @@ import com.google.gson.JsonElement; import com.google.gson.JsonSyntaxException; import com.launchdarkly.client.DataModel; -import com.launchdarkly.client.interfaces.VersionedData; +import com.launchdarkly.client.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.client.value.LDValue; import org.yaml.snakeyaml.Yaml; @@ -181,11 +181,11 @@ public FlagFileRep parse(InputStream input) throws FileDataException, IOExceptio * build some JSON and then parse that. */ static final class FlagFactory { - static VersionedData flagFromJson(String jsonString) { + static ItemDescriptor flagFromJson(String jsonString) { return DataModel.DataKinds.FEATURES.deserialize(jsonString); } - static VersionedData flagFromJson(LDValue jsonTree) { + static ItemDescriptor flagFromJson(LDValue jsonTree) { return flagFromJson(jsonTree.toJsonString()); } @@ -193,7 +193,7 @@ static VersionedData flagFromJson(LDValue jsonTree) { * 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, LDValue jsonValue) { + static ItemDescriptor flagWithValue(String key, LDValue jsonValue) { LDValue o = LDValue.buildObject() .put("key", key) .put("on", true) @@ -202,14 +202,15 @@ static VersionedData flagWithValue(String key, LDValue jsonValue) { .build(); // 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. - return flagFromJson(o); + Object item = DataModel.DataKinds.FEATURES.deserialize(o.toJsonString()); + return new ItemDescriptor(1, item); } - static VersionedData segmentFromJson(String jsonString) { + static ItemDescriptor segmentFromJson(String jsonString) { return DataModel.DataKinds.SEGMENTS.deserialize(jsonString); } - static VersionedData segmentFromJson(LDValue jsonTree) { + static ItemDescriptor segmentFromJson(LDValue jsonTree) { return segmentFromJson(jsonTree.toJsonString()); } } diff --git a/src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreBuilder.java b/src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreBuilder.java index 186dbe1f1..6e7b7c99f 100644 --- a/src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreBuilder.java +++ b/src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreBuilder.java @@ -1,8 +1,10 @@ package com.launchdarkly.client.integrations; import com.launchdarkly.client.Components; +import com.launchdarkly.client.interfaces.ClientContext; +import com.launchdarkly.client.interfaces.DataStore; import com.launchdarkly.client.interfaces.DataStoreFactory; -import com.launchdarkly.client.interfaces.DiagnosticDescription; +import com.launchdarkly.client.interfaces.PersistentDataStore; import com.launchdarkly.client.interfaces.PersistentDataStoreFactory; import java.time.Duration; @@ -36,7 +38,7 @@ * {@link Components#persistentDataStore(PersistentDataStoreFactory)}. * @since 4.12.0 */ -public abstract class PersistentDataStoreBuilder implements DataStoreFactory, DiagnosticDescription { +public abstract class PersistentDataStoreBuilder implements DataStoreFactory { /** * The default value for the cache TTL. */ @@ -179,9 +181,8 @@ public PersistentDataStoreBuilder staleValuesPolicy(StaleValuesPolicy staleValue * 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. + * method when you are configuring the persistent data store. The store will modify the + * {@link CacheMonitor} instance to make the caching statistics available through that instance. *

    * Note that turning on cache monitoring may slightly decrease performance, due to the need to * record statistics for each cache operation. @@ -204,4 +205,13 @@ public PersistentDataStoreBuilder cacheMonitor(CacheMonitor cacheMonitor) { this.cacheMonitor = cacheMonitor; return this; } + + /** + * Called by the SDK to create the data store instance. + */ + @Override + public DataStore createDataStore(ClientContext context) { + PersistentDataStore core = persistentDataStoreFactory.createPersistentDataStore(context); + return new PersistentDataStoreWrapper(core, cacheTime, staleValuesPolicy, cacheMonitor); + } } diff --git a/src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreWrapper.java b/src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreWrapper.java new file mode 100644 index 000000000..41d31a0c1 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreWrapper.java @@ -0,0 +1,405 @@ +package com.launchdarkly.client.integrations; + +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.collect.ImmutableList; +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.interfaces.DataStore; +import com.launchdarkly.client.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.client.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.client.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.client.interfaces.DataStoreTypes.KeyedItems; +import com.launchdarkly.client.interfaces.DataStoreTypes.SerializedItemDescriptor; +import com.launchdarkly.client.interfaces.PersistentDataStore; + +import java.io.IOException; +import java.time.Duration; +import java.util.AbstractMap; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicBoolean; + +import static com.google.common.collect.Iterables.concat; +import static com.google.common.collect.Iterables.filter; +import static com.google.common.collect.Iterables.isEmpty; + +/** + * Package-private implementation of {@link DataStore} that delegates the basic functionality to an + * instance of {@link PersistentDataStore}. It provides optional caching behavior and other logic that + * would otherwise be repeated in every data store implementation. This makes it easier to create new + * database integrations by implementing only the database-specific logic. + *

    + * This class is only constructed by {@link PersistentDataStoreBuilder}. + */ +class PersistentDataStoreWrapper implements DataStore { + private static final String CACHE_REFRESH_THREAD_POOL_NAME_FORMAT = "CachingStoreWrapper-refresher-pool-%d"; + + private final PersistentDataStore core; + private final LoadingCache> itemCache; + private final LoadingCache> allCache; + private final LoadingCache initCache; + private final boolean cacheIndefinitely; + private final AtomicBoolean inited = new AtomicBoolean(false); + private final ListeningExecutorService executorService; + + PersistentDataStoreWrapper( + final PersistentDataStore core, + Duration cacheTtl, + PersistentDataStoreBuilder.StaleValuesPolicy staleValuesPolicy, + CacheMonitor cacheMonitor + ) { + this.core = core; + + if (cacheTtl == null || cacheTtl.isZero()) { + itemCache = null; + allCache = null; + initCache = null; + executorService = null; + cacheIndefinitely = false; + } else { + cacheIndefinitely = cacheTtl.isNegative(); + CacheLoader> itemLoader = new CacheLoader>() { + @Override + public Optional load(CacheKey key) throws Exception { + return Optional.fromNullable(getAndDeserializeItem(key.kind, key.key)); + } + }; + CacheLoader> allLoader = new CacheLoader>() { + @Override + public KeyedItems load(DataKind kind) throws Exception { + return getAllAndDeserialize(kind); + } + }; + CacheLoader initLoader = new CacheLoader() { + @Override + public Boolean load(String key) throws Exception { + return core.isInitialized(); + } + }; + + if (staleValuesPolicy == PersistentDataStoreBuilder.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); + + // 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; + } + + itemCache = newCacheBuilder(cacheTtl, staleValuesPolicy, cacheMonitor).build(itemLoader); + allCache = newCacheBuilder(cacheTtl, staleValuesPolicy, cacheMonitor).build(allLoader); + initCache = newCacheBuilder(cacheTtl, staleValuesPolicy, cacheMonitor).build(initLoader); + + if (cacheMonitor != null) { + cacheMonitor.setSource(new CacheStatsSource()); + } + } + } + + private static CacheBuilder newCacheBuilder( + Duration cacheTtl, + PersistentDataStoreBuilder.StaleValuesPolicy staleValuesPolicy, + CacheMonitor cacheMonitor + ) { + CacheBuilder builder = CacheBuilder.newBuilder(); + boolean isInfiniteTtl = cacheTtl.isNegative(); + if (!isInfiniteTtl) { + if (staleValuesPolicy == PersistentDataStoreBuilder.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(cacheTtl); + } 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. + builder = builder.refreshAfterWrite(cacheTtl); + } + } + if (cacheMonitor != null) { + builder = builder.recordStats(); + } + return builder; + } + + @Override + public void close() throws IOException { + if (executorService != null) { + executorService.shutdownNow(); + } + core.close(); + } + + @Override + public boolean isInitialized() { + if (inited.get()) { + return true; + } + boolean result; + if (initCache != null) { + try { + result = initCache.get(""); + } catch (ExecutionException e) { + result = false; + } + } else { + result = core.isInitialized(); + } + if (result) { + inited.set(true); + } + return result; + } + + @Override + public void init(FullDataSet allData) { + ImmutableList.Builder>> allBuilder = ImmutableList.builder(); + for (Map.Entry> e0: allData.getData()) { + DataKind kind = e0.getKey(); + ImmutableList.Builder> itemsBuilder = ImmutableList.builder(); + for (Map.Entry e1: e0.getValue().getItems()) { + itemsBuilder.add(new AbstractMap.SimpleEntry<>(e1.getKey(), serialize(kind, e1.getValue()))); + } + allBuilder.add(new AbstractMap.SimpleEntry<>(kind, new KeyedItems<>(itemsBuilder.build()))); + } + RuntimeException failure = null; + try { + core.init(new FullDataSet<>(allBuilder.build())); + } catch (RuntimeException e) { + failure = e; + } + if (itemCache != null && allCache != null) { + itemCache.invalidateAll(); + allCache.invalidateAll(); + if (failure != null && !cacheIndefinitely) { + // 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. + throw failure; + } + for (Map.Entry> e0: allData.getData()) { + DataKind kind = e0.getKey(); + KeyedItems immutableItems = new KeyedItems<>(ImmutableList.copyOf(e0.getValue().getItems())); + allCache.put(kind, immutableItems); + for (Map.Entry e1: e0.getValue().getItems()) { + itemCache.put(CacheKey.forItem(kind, e1.getKey()), Optional.of(e1.getValue())); + } + } + } + if (failure == null || cacheIndefinitely) { + inited.set(true); + } + if (failure != null) { + throw failure; + } + } + + @Override + public ItemDescriptor get(DataKind kind, String key) { + if (itemCache != null) { + try { + return itemCache.get(CacheKey.forItem(kind, key)).orNull(); + } catch (ExecutionException e) { + throw new RuntimeException(e); + } + } + return getAndDeserializeItem(kind, key); + } + + @Override + public KeyedItems getAll(DataKind kind) { + if (allCache != null) { + try { + return allCache.get(kind); + } catch (ExecutionException e) { + throw new RuntimeException(e); + } + } + return getAllAndDeserialize(kind); + } + + @Override + public boolean upsert(DataKind kind, String key, ItemDescriptor item) { + SerializedItemDescriptor serializedItem = serialize(kind, item); + boolean updated = false; + RuntimeException failure = null; + try { + updated = core.upsert(kind, key, serializedItem); + } 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 (!cacheIndefinitely) + { + throw e; + } + failure = e; + } + if (itemCache != null) { + CacheKey cacheKey = CacheKey.forItem(kind, key); + if (failure == null) { + if (updated) { + itemCache.put(cacheKey, Optional.of(item)); + } else { + // there was a concurrent modification elsewhere - update the cache to get the new state + itemCache.refresh(cacheKey); + } + } else { + try { + Optional oldItem = itemCache.get(cacheKey); + if (oldItem.isPresent() && oldItem.get().getVersion() < item.getVersion()) { + itemCache.put(cacheKey, Optional.of(item)); + } + } catch (ExecutionException e) { + // An exception here means that the underlying database is down *and* there was no + // cached item; in that case we just go ahead and update the cache. + itemCache.put(cacheKey, Optional.of(item)); + } + } + } + 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 (cacheIndefinitely) { + try { + KeyedItems cachedAll = allCache.get(kind); + allCache.put(kind, updateSingleItem(cachedAll, key, item)); + } catch (ExecutionException 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; + } + return updated; + } + + /** + * Return the underlying Guava cache stats object. + * + * @return the cache statistics object + */ + public CacheStats getCacheStats() { + if (itemCache != null) { + return itemCache.stats(); + } + return null; + } + + /** + * Return the underlying implementation object. + * + * @return the underlying implementation object + */ + public PersistentDataStore getCore() { + return core; + } + + private ItemDescriptor getAndDeserializeItem(DataKind kind, String key) { + SerializedItemDescriptor maybeSerializedItem = core.get(kind, key); + return maybeSerializedItem == null ? null : deserialize(kind, maybeSerializedItem); + } + + private KeyedItems getAllAndDeserialize(DataKind kind) { + KeyedItems allItems = core.getAll(kind); + if (isEmpty(allItems.getItems())) { + return new KeyedItems(null); + } + ImmutableList.Builder> b = ImmutableList.builder(); + for (Map.Entry e: allItems.getItems()) { + b.add(new AbstractMap.SimpleEntry<>(e.getKey(), deserialize(kind, e.getValue()))); + } + return new KeyedItems<>(b.build()); + } + + private SerializedItemDescriptor serialize(DataKind kind, ItemDescriptor itemDesc) { + Object item = itemDesc.getItem(); + return new SerializedItemDescriptor(itemDesc.getVersion(), item == null ? null : kind.serialize(item)); + } + + private ItemDescriptor deserialize(DataKind kind, SerializedItemDescriptor serializedItemDesc) { + String serializedItem = serializedItemDesc.getSerializedItem(); + if (serializedItem == null) { + return ItemDescriptor.deletedItem(serializedItemDesc.getVersion()); + } + ItemDescriptor deserializedItem = kind.deserialize(serializedItem); + return (serializedItemDesc.getVersion() == 0 || serializedItemDesc.getVersion() == deserializedItem.getVersion()) + ? deserializedItem + : new ItemDescriptor(serializedItemDesc.getVersion(), deserializedItem.getItem()); + } + + private KeyedItems updateSingleItem(KeyedItems items, String key, ItemDescriptor item) { + // This is somewhat inefficient but it's preferable to use immutable data structures in the cache. + return new KeyedItems<>( + ImmutableList.copyOf(concat( + filter(items.getItems(), e -> !e.getKey().equals(key)), + ImmutableList.>of(new AbstractMap.SimpleEntry<>(key, item)) + ) + )); + } + + 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 DataKind kind; + final String key; + + public static CacheKey forItem(DataKind kind, String key) { + return new CacheKey(kind, key); + } + + private CacheKey(DataKind 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.getName().equals(this.kind.getName()) && o.key.equals(this.key); + } + return false; + } + + @Override + public int hashCode() { + return kind.getName().hashCode() * 31 + key.hashCode(); + } + } +} diff --git a/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreImpl.java b/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreImpl.java index 0543f90d8..636c89ce9 100644 --- a/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreImpl.java +++ b/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreImpl.java @@ -1,21 +1,20 @@ package com.launchdarkly.client.integrations; import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.Maps; +import com.launchdarkly.client.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.client.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.client.interfaces.DataStoreTypes.KeyedItems; +import com.launchdarkly.client.interfaces.DataStoreTypes.SerializedItemDescriptor; import com.launchdarkly.client.interfaces.PersistentDataStore; -import com.launchdarkly.client.interfaces.VersionedData; -import com.launchdarkly.client.interfaces.VersionedDataKind; 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.DataStoreHelpers.marshalJson; -import static com.launchdarkly.client.utils.DataStoreHelpers.unmarshalJson; - import redis.clients.jedis.Jedis; import redis.clients.jedis.JedisPool; import redis.clients.jedis.JedisPoolConfig; @@ -69,40 +68,34 @@ final class RedisDataStoreImpl implements PersistentDataStore { } @Override - public VersionedData getInternal(VersionedDataKind kind, String key) { + public SerializedItemDescriptor get(DataKind 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; + String item = getRedis(kind, key, jedis); + return item == null ? null : new SerializedItemDescriptor(0, item); } } @Override - public Map getAllInternal(VersionedDataKind kind) { + public KeyedItems getAll(DataKind 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; + return new KeyedItems<>( + Maps.transformValues(allJson, itemJson -> new SerializedItemDescriptor(0, itemJson)).entrySet() + ); } } @Override - public void initInternal(Map, Map> allData) { + public void init(FullDataSet allData) { try (Jedis jedis = pool.getResource()) { Transaction t = jedis.multi(); - for (Map.Entry, Map> entry: allData.entrySet()) { - String baseKey = itemsKey(entry.getKey()); + for (Map.Entry> e0: allData.getData()) { + DataKind kind = e0.getKey(); + String baseKey = itemsKey(kind); t.del(baseKey); - for (VersionedData item: entry.getValue().values()) { - t.hset(baseKey, item.getKey(), marshalJson(item)); + for (Map.Entry e1: e0.getValue().getItems()) { + t.hset(baseKey, e1.getKey(), jsonOrPlaceholder(kind, e1.getValue())); } } @@ -112,7 +105,7 @@ public void initInternal(Map, Map> a } @Override - public VersionedData upsertInternal(VersionedDataKind kind, VersionedData newItem) { + public boolean upsert(DataKind kind, String key, SerializedItemDescriptor newItem) { while (true) { Jedis jedis = null; try { @@ -121,21 +114,23 @@ public VersionedData upsertInternal(VersionedDataKind kind, VersionedData new jedis.watch(baseKey); if (updateListener != null) { - updateListener.aboutToUpdate(baseKey, newItem.getKey()); + updateListener.aboutToUpdate(baseKey, key); } - VersionedData oldItem = getRedis(kind, newItem.getKey(), jedis); + String oldItemJson = getRedis(kind, key, jedis); + // In this implementation, we have to parse the existing item in order to determine its version. + int oldVersion = oldItemJson == null ? -1 : kind.deserialize(oldItemJson).getVersion(); - if (oldItem != null && oldItem.getVersion() >= newItem.getVersion()) { + if (oldVersion >= 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; + newItem.getSerializedItem() == null ? "delete" : "update", + key, oldVersion, newItem.getVersion(), kind.getName()); + return false; } Transaction tx = jedis.multi(); - tx.hset(baseKey, newItem.getKey(), marshalJson(newItem)); + tx.hset(baseKey, key, jsonOrPlaceholder(kind, newItem)); List result = tx.exec(); if (result.isEmpty()) { // if exec failed, it means the watch was triggered and we should retry @@ -143,7 +138,7 @@ public VersionedData upsertInternal(VersionedDataKind kind, VersionedData new continue; } - return newItem; + return true; } finally { if (jedis != null) { jedis.unwatch(); @@ -154,7 +149,7 @@ public VersionedData upsertInternal(VersionedDataKind kind, VersionedData new } @Override - public boolean initializedInternal() { + public boolean isInitialized() { try (Jedis jedis = pool.getResource()) { return jedis.exists(initedKey()); } @@ -171,23 +166,30 @@ void setUpdateListener(UpdateListener updateListener) { this.updateListener = updateListener; } - private String itemsKey(VersionedDataKind kind) { - return prefix + ":" + kind.getNamespace(); + private String itemsKey(DataKind kind) { + return prefix + ":" + kind.getName(); } private String initedKey() { return prefix + ":$inited"; } - private T getRedis(VersionedDataKind kind, String key, Jedis jedis) { + private String getRedis(DataKind 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; + logger.debug("[get] Key: {} not found in \"{}\". Returning null", key, kind.getName()); } - - return unmarshalJson(kind, json); + + return json; + } + + private static String jsonOrPlaceholder(DataKind kind, SerializedItemDescriptor serializedItem) { + String s = serializedItem.getSerializedItem(); + if (s != null) { + return s; + } + return kind.serializeDeletedItemPlaceholder(serializedItem.getVersion()); } static interface UpdateListener { diff --git a/src/main/java/com/launchdarkly/client/interfaces/DataStore.java b/src/main/java/com/launchdarkly/client/interfaces/DataStore.java index 3738d93e4..125d83aed 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/DataStore.java +++ b/src/main/java/com/launchdarkly/client/interfaces/DataStore.java @@ -1,87 +1,81 @@ package com.launchdarkly.client.interfaces; +import com.launchdarkly.client.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.client.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.client.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.client.interfaces.DataStoreTypes.KeyedItems; + import java.io.Closeable; -import java.util.Map; /** - * A thread-safe, versioned store for feature flags and related objects received from the - * streaming API. Implementations should permit concurrent access and updates. - *

    - * Delete and upsert requests are versioned-- if the version number in the request is less than - * the currently stored version of the object, the request should be ignored. + * Interface for a data store that holds feature flags and related data received by the SDK. *

    - * These semantics support the primary use case for the store, which synchronizes a collection - * of objects based on update messages that may be received out-of-order. + * Ordinarily, the only implementations of this interface are the default in-memory implementation, + * which holds references to actual SDK data model objects, and the persistent data store + * implementation that delegates to a {@link PersistentDataStore}. + *

    + * All implementations must permit concurrent access and updates. + * * @since 5.0.0 */ public interface DataStore extends Closeable { /** - * Returns the object to which the specified key is mapped, or - * null if the key is not associated or the associated object has - * been deleted. - * - * @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 if the key is not associated or the associated object has - * been deleted. - */ - T get(VersionedDataKind kind, String key); - - /** - * Returns a {@link java.util.Map} of all associated objects of a given kind. - * - * @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. + * Overwrites the store's contents with a set of items for each collection. + *

    + * All previous data should be discarded, regardless of versioning. + *

    + * The update should be done atomically. If it cannot be done atomically, then the store + * must first add or update each item in the same order that they are given in the input + * data, and then delete any previously stored items that were not in the input data. + * + * @param allData a list of {@link DataStoreTypes.DataKind} instances and their corresponding data sets */ - Map all(VersionedDataKind kind); - + void init(FullDataSet allData); + /** - * 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. + * Retrieves an item from the specified collection, if available. *

    - * 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. + * If the item has been deleted and the store contains a placeholder, it should + * return that placeholder rather than null. * - * @param allData all objects to be stored + * @param kind specifies which collection to use + * @param key the unique key of the item within that collection + * @return a versioned item that contains the stored data (or placeholder for deleted data); + * null if the key is unknown */ - void init(Map, Map> allData); - + ItemDescriptor get(DataKind kind, String key); + /** - * Deletes the object associated with the specified key, if it exists and its version - * is less than or equal to the specified version. - * - * @param class of the object to be deleted - * @param kind the kind of object to delete - * @param key the key of the object to be deleted - * @param version the version for the delete operation + * Retrieves all items from the specified collection. + *

    + * If the store contains placeholders for deleted items, it should include them in + * the results, not filter them out. + * + * @param kind specifies which collection to use + * @return a collection of key-value pairs; the ordering is not significant */ - void delete(VersionedDataKind kind, String key, int version); - + KeyedItems getAll(DataKind kind); + /** - * Update or insert the object associated with the specified key, if its version - * is less than or equal to the version specified in the argument object. - * - * @param class of the object to be updated - * @param kind the kind of object to update - * @param item the object to update or insert + * Updates or inserts an item in the specified collection. For updates, the object will only be + * updated if the existing version is less than the new version. + *

    + * The SDK may pass an {@link ItemDescriptor} that contains a null, to represent a placeholder + * for a deleted item. In that case, assuming the version is greater than any existing version of + * that item, the store should retain that placeholder rather than simply not storing anything. + * + * @param kind specifies which collection to use + * @param key the unique key for the item within that collection + * @param item the item to insert or update + * @return true if the item was updated; false if it was not updated because the store contains + * an equal or greater version */ - void upsert(VersionedDataKind kind, T item); - + boolean upsert(DataKind kind, String key, ItemDescriptor item); + /** - * Returns true if this store has been initialized. + * Checks whether this store has been initialized with any data yet. * - * @return true if this store has been initialized + * @return true if the store contains data */ - boolean initialized(); - + boolean isInitialized(); } diff --git a/src/main/java/com/launchdarkly/client/interfaces/DataStoreTypes.java b/src/main/java/com/launchdarkly/client/interfaces/DataStoreTypes.java new file mode 100644 index 000000000..dc707ea5a --- /dev/null +++ b/src/main/java/com/launchdarkly/client/interfaces/DataStoreTypes.java @@ -0,0 +1,295 @@ +package com.launchdarkly.client.interfaces; + +import com.google.common.base.Objects; +import com.google.common.collect.ImmutableList; + +import java.util.Map; +import java.util.function.Function; + +/** + * Types that are used by the {@link DataStore} interface. + * + * @since 5.0.0 + */ +public abstract class DataStoreTypes { + /** + * Represents a separately namespaced collection of storable data items. + *

    + * The SDK passes instances of this type to the data store to specify whether it is referring to + * a feature flag, a user segment, etc. The data store implementation should not look for a + * specific data kind (such as feature flags), but should treat all data kinds generically. + */ + public static final class DataKind { + private final String name; + private final Function serializer; + private final Function deserializer; + private final Function deletedItemSerializer; + + /** + * A case-sensitive alphabetic string that uniquely identifies this data kind. + *

    + * This is in effect a namespace for a collection of items of the same kind. Item keys must be + * unique within that namespace. Persistent data store implementations could use this string + * as part of a composite key or table name. + * + * @return the namespace string + */ + public String getName() { + return name; + } + + /** + * Returns a serialized representation of an item of this kind. + *

    + * The SDK uses this function to generate the data that is stored by a {@link PersistentDataStore}. + * Store implementations should not call it. + * + * @param o an object which the serializer can assume is of the appropriate class + * @return the serialized representation + * @exception ClassCastException if the object is of the wrong class + */ + public String serialize(Object o) { + return serializer.apply(o); + } + + /** + * Creates an item of this kind from its serialized representation. + *

    + * The SDK uses this function to translate data that is returned by a {@link PersistentDataStore}. + * Store implementations do not normally need to call it, but there is a special case described in + * the documentation for {@link PersistentDataStore}, regarding updates. + *

    + * The returned {@link ItemDescriptor} has two properties: {@link ItemDescriptor#getItem()}, which + * is the deserialized object or a {@code null} value if the serialized string was the value + * produced by {@link #serializeDeletedItemPlaceholder(int)}, and {@link ItemDescriptor#getVersion()}, + * which provides the object's version number regardless of whether it is deleted or not. + * + * @param s the serialized representation + * @return an {@link ItemDescriptor} describing the deserialized object + */ + public ItemDescriptor deserialize(String s) { + return deserializer.apply(s); + } + + /** + * Returns a special serialized representation for a deleted item placeholder. + *

    + * This method should be used only by {@link PersistentDataStore} implementations in the special + * case described in the documentation for {@link PersistentDataStore} regarding deleted items. + * + * @param version the version number + * @return the serialized representation + */ + public String serializeDeletedItemPlaceholder(int version) { + return deletedItemSerializer.apply(version); + } + + /** + * Constructs a DataKind instance. + * + * @param name the value for {@link #getName()} + * @param serializer the function to use for {@link #serialize(Object)} + * @param deserializer the function to use for {@link #deserialize(String)} + * @param deletedItemSerializer the function to use for {@link #serializeDeletedItemPlaceholder(int)} + */ + public DataKind(String name, Function serializer, Function deserializer, + Function deletedItemSerializer) { + this.name = name; + this.serializer = serializer; + this.deserializer = deserializer; + this.deletedItemSerializer = deletedItemSerializer; + } + + @Override + public String toString() { + return "DataKind(" + name + ")"; + } + } + + /** + * A versioned item (or placeholder) storable in a {@link DataStore}. + *

    + * This is used for data stores that directly store objects as-is, as the default in-memory + * store does. Items are typed as {@code Object}; the store should not know or care what the + * actual object is. + *

    + * For any given key within a {@link DataKind}, there can be either an existing item with a + * version, or a "tombstone" placeholder representing a deleted item (also with a version). + * Deleted item placeholders are used so that if an item is first updated with version N and + * then deleted with version N+1, but the SDK receives those changes out of order, version N + * will not overwrite the deletion. + *

    + * Persistent data stores use {@link SerializedItemDescriptor} instead. + */ + public static final class ItemDescriptor { + private final int version; + private final Object item; + + /** + * Returns the version number of this data, provided by the SDK. + * @return the version number + */ + public int getVersion() { + return version; + } + + /** + * Returns the data item, or null if this is a placeholder for a deleted item. + * @return an object or null + */ + public Object getItem() { + return item; + } + + /** + * Constructs a new instance. + * @param version the version number + * @param item an object or null + */ + public ItemDescriptor(int version, Object item) { + this.version = version; + this.item = item; + } + + /** + * Convenience method for constructing a deleted item placeholder. + * @param version the version number + * @return an ItemDescriptor + */ + public static ItemDescriptor deletedItem(int version) { + return new ItemDescriptor(version, null); + } + + @Override + public boolean equals(Object o) { + if (o instanceof ItemDescriptor) { + ItemDescriptor other = (ItemDescriptor)o; + return version == other.version && Objects.equal(item, other.item); + } + return false; + } + + @Override + public String toString() { + return "ItemDescriptor(" + version + "," + item + ")"; + } + } + + /** + * A versioned item (or placeholder) storable in a {@link PersistentDataStore}. + *

    + * This is equivalent to {@link ItemDescriptor}, but is used for persistent data stores. The + * SDK will convert each data item to and from its serialized string form; the persistent data + * store deals only with the serialized form. + */ + public static final class SerializedItemDescriptor { + private final int version; + private final String serializedItem; + + /** + * Returns the version number of this data, provided by the SDK. + * @return the version number + */ + public int getVersion() { + return version; + } + + /** + * Returns the data item's serialized representation, or null if this is a placeholder for a + * deleted item. + * @return the serialized data or null + */ + public String getSerializedItem() { + return serializedItem; + } + + /** + * Constructs a new instance. + * @param version the version number + * @param serializedItem the serialized data or null + */ + public SerializedItemDescriptor(int version, String serializedItem) { + this.version = version; + this.serializedItem = serializedItem; + } + + /** + * Convenience method for constructing a deleted item placeholder. + * @param version the version number + * @return a SerializedItemDescriptor + */ + public static SerializedItemDescriptor deletedItem(int version) { + return new SerializedItemDescriptor(version, null); + } + + @Override + public boolean equals(Object o) { + if (o instanceof SerializedItemDescriptor) { + SerializedItemDescriptor other = (SerializedItemDescriptor)o; + return version == other.version && Objects.equal(serializedItem, other.serializedItem); + } + return false; + } + + @Override + public String toString() { + return "SerializedItemDescriptor(" + version + "," + serializedItem + ")"; + } + } + + /** + * Wrapper for a set of storable items being passed to a data store. + *

    + * Since the generic type signature for the data set is somewhat complicated (it is an ordered + * list of key-value pairs where each key is a {@link DataKind}, and each value is another ordered + * list of key-value pairs for the individual data items), this type simplifies the declaration of + * data store methods and makes it easier to see what the type represents. + * + * @param will be {@link ItemDescriptor} or {@link SerializedItemDescriptor} + */ + public static final class FullDataSet { + private final Iterable>> data; + + /** + * Returns the wrapped data set. + * @return an enumeration of key-value pairs; may be empty, but will not be null + */ + public Iterable>> getData() { + return data; + } + + /** + * Constructs a new instance. + * @param data the data set + */ + public FullDataSet(Iterable>> data) { + this.data = data == null ? ImmutableList.of(): data; + } + } + + /** + * Wrapper for a set of storable items being passed to a data store, within a single + * {@link DataKind}. + * + * @param will be {@link ItemDescriptor} or {@link SerializedItemDescriptor} + */ + public static final class KeyedItems { + private final Iterable> items; + + /** + * Returns the wrapped data set. + * @return an enumeration of key-value pairs; may be empty, but will not be null + */ + public Iterable> getItems() { + return items; + } + + /** + * Constructs a new instance. + * @param items the data set + */ + public KeyedItems(Iterable> items) { + this.items = items == null ? ImmutableList.of() : items; + } + } +} diff --git a/src/main/java/com/launchdarkly/client/interfaces/DataStoreUpdates.java b/src/main/java/com/launchdarkly/client/interfaces/DataStoreUpdates.java index 98fe5e97a..6fc91d764 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/DataStoreUpdates.java +++ b/src/main/java/com/launchdarkly/client/interfaces/DataStoreUpdates.java @@ -1,6 +1,8 @@ package com.launchdarkly.client.interfaces; -import java.util.Map; +import com.launchdarkly.client.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.client.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.client.interfaces.DataStoreTypes.ItemDescriptor; /** * Interface that a data source implementation will use to push data into the underlying @@ -15,21 +17,27 @@ public interface DataStoreUpdates { /** * Overwrites the store's contents with a set of items for each collection. *

    - * All previous data will be discarded, regardless of versioning. + * All previous data should be discarded, regardless of versioning. + *

    + * The update should be done atomically. If it cannot be done atomically, then the store + * must first add or update each item in the same order that they are given in the input + * data, and then delete any previously stored items that were not in the input data. * - * @param allData all objects to be stored + * @param allData a list of {@link DataStoreTypes.DataKind} instances and their corresponding data sets */ - void init(Map, Map> allData); + void init(FullDataSet allData); /** - * Update or insert the object associated with the specified key, if its version is less than or - * equal the version specified in the argument object. + * Updates or inserts an item in the specified collection. For updates, the object will only be + * updated if the existing version is less than the new version. *

    - * Deletions are implemented by upserting a deleted item placeholder. - * - * @param class of the object to be updated - * @param kind the kind of object to update - * @param item the object to update or insert + * The SDK may pass an {@link ItemDescriptor} that contains a null, to represent a placeholder + * for a deleted item. In that case, assuming the version is greater than any existing version of + * that item, the store should retain that placeholder rather than simply not storing anything. + * + * @param kind specifies which collection to use + * @param key the unique key for the item within that collection + * @param item the item to insert or update */ - void upsert(VersionedDataKind kind, T item); + void upsert(DataKind kind, String key, ItemDescriptor item); } diff --git a/src/main/java/com/launchdarkly/client/interfaces/PersistentDataStore.java b/src/main/java/com/launchdarkly/client/interfaces/PersistentDataStore.java index 9bee8987a..e5fbbc557 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/PersistentDataStore.java +++ b/src/main/java/com/launchdarkly/client/interfaces/PersistentDataStore.java @@ -1,85 +1,117 @@ package com.launchdarkly.client.interfaces; -import com.launchdarkly.client.utils.CachingStoreWrapper; -import com.launchdarkly.client.utils.DataStoreHelpers; +import com.launchdarkly.client.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.client.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.client.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.client.interfaces.DataStoreTypes.KeyedItems; +import com.launchdarkly.client.interfaces.DataStoreTypes.SerializedItemDescriptor; import java.io.Closeable; -import java.util.Map; /** - * PersistentDataStore is an interface for a simplified subset of the functionality of - * {@link DataStore}, to be used in conjunction with {@link CachingStoreWrapper}. This allows - * developers of custom DataStore implementations to avoid repeating logic that would - * commonly be needed in any such implementation, such as caching. Instead, they can implement - * only DataStoreCore and then create a CachingStoreWrapper. + * Interface for a data store that holds feature flags and related data in a serialized form. *

    - * 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 DataStoreHelpers} may be - * useful for this. + * This interface should be used for database integrations, or any other data store + * implementation that stores data in some external service. The SDK will take care of + * converting between its own internal data model and a serialized string form; the data + * store interacts only with the serialized form. The SDK will also provide its own caching + * layer on top of the persistent data store; the data store implementation should not + * provide caching, but simply do every query or update that the SDK tells it to do. + *

    + * Implementations must be thread-safe. + *

    + * Conceptually, each item in the store is a {@link SerializedItemDescriptor} consisting of a + * version number plus either a string of serialized data or a null; the null represents a + * placeholder (tombstone) indicating that the item was deleted. + *

    + * Preferably, the store implementation should store the version number as a separate property + * from the string, and store a null or empty string for deleted items, so that no + * deserialization is required to simply determine the version (for updates) or the deleted + * state. + *

    + * However, due to how persistent stores were implemented in earlier SDK versions, for + * interoperability it may be necessary for a store to use a somewhat different model in + * which the version number and deleted state are encoded inside the serialized string. In + * this case, to avoid unnecessary extra parsing, the store should work as follows: + *

      + *
    • When querying items, set the {@link SerializedItemDescriptor} to have a version + * number of zero; the SDK will be able to determine the version number, and to filter out + * any items that were actually deleted, after it deserializes the item.
    • + *
    • When inserting or updating items, if the {@link SerializedItemDescriptor} contains + * a null, pass its version number to {@link DataKind#serializeDeletedItemPlaceholder(int)} + * and store the string that that method returns.
    • + *
    * * @since 5.0.0 */ public interface PersistentDataStore 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 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 + * Overwrites the store's contents with a set of items for each collection. + *

    + * All previous data should be discarded, regardless of versioning. + *

    + * The update should be done atomically. If it cannot be done atomically, then the store + * must first add or update each item in the same order that they are given in the input + * data, and then delete any previously stored items that were not in the input data. + * + * @param allData a list of {@link DataStoreTypes.DataKind} instances and their corresponding data sets */ - VersionedData getInternal(VersionedDataKind kind, String key); - + void init(FullDataSet allData); + /** - * 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 kind the kind of objects to get - * @return a map of all associated objects. + * Retrieves an item from the specified collection, if available. + *

    + * If the item has been deleted and the store contains a placeholder, it should return a + * {@link SerializedItemDescriptor} for that placeholder rather than returning null. + *

    + * If it is possible for the data store to know the version number of the data item without + * deserializing it, then it should return that number in the version property of the + * {@link SerializedItemDescriptor}. If not, then it should just return zero for the version + * and it will be parsed out later. + * + * @param kind specifies which collection to use + * @param key the unique key of the item within that collection + * @return a versioned item that contains the stored data (or placeholder for deleted data); + * null if the key is unknown */ - Map getAllInternal(VersionedDataKind kind); - + SerializedItemDescriptor get(DataKind kind, String key); + /** - * 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 - * data. + * Retrieves all items from the specified collection. *

    - * 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 + * If the store contains placeholders for deleted items, it should include them in + * the results, not filter them out. + * + * @param kind specifies which collection to use + * @return a collection of key-value pairs; the ordering is not significant */ - void initInternal(Map, Map> allData); - + KeyedItems getAll(DataKind kind); + /** - * 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 kind the kind of object to update - * @param item the object to update or insert - * @return the state of the object after the update + * Updates or inserts an item in the specified collection. For updates, the object will only be + * updated if the existing version is less than the new version. + *

    + * The SDK may pass an {@link ItemDescriptor} that contains a null, to represent a placeholder + * for a deleted item. In that case, assuming the version is greater than any existing version of + * that item, the store should retain that placeholder rather than simply not storing anything. + * + * @param kind specifies which collection to use + * @param key the unique key for the item within that collection + * @param item the item to insert or update + * @return true if the item was updated; false if it was not updated because the store contains + * an equal or greater version */ - VersionedData upsertInternal(VersionedDataKind kind, VersionedData item); - + boolean upsert(DataKind kind, String key, SerializedItemDescriptor 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, 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; CachingStoreWrapper will only call it when necessary. + * Returns true if this store has been initialized. + *

    + * In a shared data store, the implementation should be able to detect this state even if + * {@link #init(FullDataSet)} was called in a different process, i.e. it must query the underlying + * data store in some way. The method does not need to worry about caching this value; the SDK + * will call it rarely. * - * @return true if this store has been initialized + * @return true if the store has been initialized */ - boolean initializedInternal(); + boolean isInitialized(); } diff --git a/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java b/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java deleted file mode 100644 index a8f64bd67..000000000 --- a/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java +++ /dev/null @@ -1,389 +0,0 @@ -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.collect.ImmutableMap; -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.DataStoreCacheConfig; -import com.launchdarkly.client.integrations.CacheMonitor; -import com.launchdarkly.client.interfaces.DataStore; -import com.launchdarkly.client.interfaces.PersistentDataStore; -import com.launchdarkly.client.interfaces.VersionedData; -import com.launchdarkly.client.interfaces.VersionedDataKind; - -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; -import java.util.concurrent.atomic.AtomicBoolean; - -/** - * CachingStoreWrapper is a partial implementation of {@link DataStore} that delegates the basic - * functionality to an instance of {@link PersistentDataStore}. It provides optional caching behavior and - * other logic that would otherwise be repeated in every data 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(PersistentDataStore)}. - * - * @since 4.6.0 - */ -public class CachingStoreWrapper implements DataStore { - private static final String CACHE_REFRESH_THREAD_POOL_NAME_FORMAT = "CachingStoreWrapper-refresher-pool-%d"; - - private final PersistentDataStore core; - private final DataStoreCacheConfig caching; - private final LoadingCache> itemCache; - private final LoadingCache, ImmutableMap> allCache; - private final LoadingCache initCache; - private final AtomicBoolean inited = new AtomicBoolean(false); - private final ListeningExecutorService executorService; - - /** - * Creates a new builder. - * @param core the {@link PersistentDataStore} instance - * @return the builder - */ - public static CachingStoreWrapper.Builder builder(PersistentDataStore core) { - return new Builder(core); - } - - @SuppressWarnings("deprecation") - protected CachingStoreWrapper(final PersistentDataStore core, DataStoreCacheConfig caching, CacheMonitor cacheMonitor) { - this.core = core; - this.caching = caching; - - if (!caching.isEnabled()) { - 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, ImmutableMap> allLoader = new CacheLoader, ImmutableMap>() { - @Override - public ImmutableMap 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 (caching.getStaleValuesPolicy() == DataStoreCacheConfig.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); - - // 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; - } - - 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(DataStoreCacheConfig caching, CacheMonitor cacheMonitor) { - CacheBuilder builder = CacheBuilder.newBuilder(); - if (!caching.isInfiniteTtl()) { - if (caching.getStaleValuesPolicy() == DataStoreCacheConfig.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()); - } 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. - builder = builder.refreshAfterWrite(caching.getCacheTime()); - } - } - if (cacheMonitor != null) { - builder = builder.recordStats(); - } - return builder; - } - - @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) { - return (T)itemOnlyIfNotDeleted(cachedItem.orNull()); - } - } - return (T)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 itemsOnlyIfNotDeleted(core.getAllInternal(kind)); - } - - @SuppressWarnings("unchecked") - @Override - 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; - } - - 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())); - } - } - } - - @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 = 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); - } - } - } - if (failure != null) { - throw failure; - } - } - - @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; - } - - /** - * Return the underlying implementation object. - * - * @return the underlying implementation object - */ - public PersistentDataStore getCore() { - return core; - } - - private VersionedData itemOnlyIfNotDeleted(VersionedData item) { - return (item != null && item.isDeleted()) ? null : item; - } - - @SuppressWarnings("unchecked") - private ImmutableMap itemsOnlyIfNotDeleted(Map items) { - ImmutableMap.Builder builder = ImmutableMap.builder(); - if (items != null) { - for (Map.Entry item: items.entrySet()) { - if (!item.getValue().isDeleted()) { - builder.put(item.getKey(), (T) item.getValue()); - } - } - } - 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; - - 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 PersistentDataStore core; - private DataStoreCacheConfig caching = DataStoreCacheConfig.DEFAULT; - private CacheMonitor cacheMonitor = null; - - Builder(PersistentDataStore core) { - this.core = core; - } - - /** - * Sets the local caching properties. - * @param caching a {@link DataStoreCacheConfig} object specifying cache parameters - * @return the builder - */ - public Builder caching(DataStoreCacheConfig caching) { - this.caching = 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, cacheMonitor); - } - } -} diff --git a/src/main/java/com/launchdarkly/client/utils/DataStoreHelpers.java b/src/main/java/com/launchdarkly/client/utils/DataStoreHelpers.java deleted file mode 100644 index bd88068eb..000000000 --- a/src/main/java/com/launchdarkly/client/utils/DataStoreHelpers.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.launchdarkly.client.utils; - -import com.google.gson.Gson; -import com.google.gson.JsonParseException; -import com.launchdarkly.client.interfaces.DataStore; -import com.launchdarkly.client.interfaces.PersistentDataStore; -import com.launchdarkly.client.interfaces.VersionedData; -import com.launchdarkly.client.interfaces.VersionedDataKind; - -/** - * Helper methods that may be useful for implementing a {@link DataStore} or {@link PersistentDataStore}. - * - * @since 4.6.0 - */ -public abstract class DataStoreHelpers { - private static final Gson gson = new Gson(); - - /** - * Unmarshals a data store item from a JSON string. This is a very simple wrapper around a Gson - * method, just to allow external data 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 - * @throws UnmarshalException if the JSON string was invalid - */ - public static T unmarshalJson(VersionedDataKind kind, String data) { - try { - return gson.fromJson(data, kind.getItemClass()); - } catch (JsonParseException e) { - throw new UnmarshalException(e); - } - } - - /** - * Marshals a data store item into a JSON string. This is a very simple wrapper around a Gson - * method, just to allow external data 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); - } - - /** - * Thrown by {@link DataStoreHelpers#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); - } - } -} diff --git a/src/main/java/com/launchdarkly/client/utils/package-info.java b/src/main/java/com/launchdarkly/client/utils/package-info.java deleted file mode 100644 index 5be71fa92..000000000 --- a/src/main/java/com/launchdarkly/client/utils/package-info.java +++ /dev/null @@ -1,4 +0,0 @@ -/** - * Helper classes that may be useful in custom integrations. - */ -package com.launchdarkly.client.utils; diff --git a/src/test/java/com/launchdarkly/client/DataStoreCachingTest.java b/src/test/java/com/launchdarkly/client/DataStoreCachingTest.java deleted file mode 100644 index c28bbd77f..000000000 --- a/src/test/java/com/launchdarkly/client/DataStoreCachingTest.java +++ /dev/null @@ -1,109 +0,0 @@ -package com.launchdarkly.client; - -import org.junit.Test; - -import java.time.Duration; - -import static com.launchdarkly.client.DataStoreCacheConfig.StaleValuesPolicy.EVICT; -import static com.launchdarkly.client.DataStoreCacheConfig.StaleValuesPolicy.REFRESH; -import static com.launchdarkly.client.DataStoreCacheConfig.StaleValuesPolicy.REFRESH_ASYNC; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.equalTo; - -@SuppressWarnings("javadoc") -public class DataStoreCachingTest { - @Test - public void disabledHasExpectedProperties() { - DataStoreCacheConfig fsc = DataStoreCacheConfig.disabled(); - assertThat(fsc.getCacheTime(), equalTo(Duration.ZERO)); - assertThat(fsc.isEnabled(), equalTo(false)); - assertThat(fsc.getStaleValuesPolicy(), equalTo(EVICT)); - } - - @Test - public void enabledHasExpectedProperties() { - DataStoreCacheConfig fsc = DataStoreCacheConfig.enabled(); - assertThat(fsc.getCacheTime(), equalTo(DataStoreCacheConfig.DEFAULT_TIME)); - assertThat(fsc.isEnabled(), equalTo(true)); - assertThat(fsc.getStaleValuesPolicy(), equalTo(EVICT)); - } - - @Test - public void defaultIsEnabled() { - DataStoreCacheConfig fsc = DataStoreCacheConfig.DEFAULT; - assertThat(fsc.getCacheTime(), equalTo(DataStoreCacheConfig.DEFAULT_TIME)); - assertThat(fsc.isEnabled(), equalTo(true)); - assertThat(fsc.isInfiniteTtl(), equalTo(false)); - assertThat(fsc.getStaleValuesPolicy(), equalTo(EVICT)); - } - - @Test - public void canSetTtl() { - DataStoreCacheConfig fsc = DataStoreCacheConfig.enabled() - .staleValuesPolicy(REFRESH) - .ttl(Duration.ofDays(3)); - assertThat(fsc.getCacheTime(), equalTo(Duration.ofDays(3))); - assertThat(fsc.getStaleValuesPolicy(), equalTo(REFRESH)); - } - - @Test - public void canSetTtlInMillis() { - DataStoreCacheConfig fsc = DataStoreCacheConfig.enabled() - .staleValuesPolicy(REFRESH) - .ttlMillis(3); - assertThat(fsc.getCacheTime(), equalTo(Duration.ofMillis(3))); - assertThat(fsc.getStaleValuesPolicy(), equalTo(REFRESH)); - } - - @Test - public void canSetTtlInSeconds() { - DataStoreCacheConfig fsc = DataStoreCacheConfig.enabled() - .staleValuesPolicy(REFRESH) - .ttlSeconds(3); - assertThat(fsc.getCacheTime(), equalTo(Duration.ofSeconds(3))); - assertThat(fsc.getStaleValuesPolicy(), equalTo(REFRESH)); - } - - @Test - public void zeroTtlMeansDisabled() { - DataStoreCacheConfig fsc = DataStoreCacheConfig.enabled() - .ttl(Duration.ZERO); - assertThat(fsc.isEnabled(), equalTo(false)); - assertThat(fsc.isInfiniteTtl(), equalTo(false)); - } - - @Test - public void negativeTtlMeansInfinite() { - DataStoreCacheConfig fsc = DataStoreCacheConfig.enabled() - .ttl(Duration.ofSeconds(-1)); - assertThat(fsc.isEnabled(), equalTo(true)); - assertThat(fsc.isInfiniteTtl(), equalTo(true)); - } - - @Test - public void canSetStaleValuesPolicy() { - DataStoreCacheConfig fsc = DataStoreCacheConfig.enabled() - .ttlMillis(3) - .staleValuesPolicy(REFRESH_ASYNC); - assertThat(fsc.getStaleValuesPolicy(), equalTo(REFRESH_ASYNC)); - assertThat(fsc.getCacheTime(), equalTo(Duration.ofMillis(3))); - } - - @Test - public void equalityUsesTime() { - DataStoreCacheConfig fsc1 = DataStoreCacheConfig.enabled().ttlMillis(3); - DataStoreCacheConfig fsc2 = DataStoreCacheConfig.enabled().ttlMillis(3); - DataStoreCacheConfig fsc3 = DataStoreCacheConfig.enabled().ttlMillis(4); - assertThat(fsc1.equals(fsc2), equalTo(true)); - assertThat(fsc1.equals(fsc3), equalTo(false)); - } - - @Test - public void equalityUsesStaleValuesPolicy() { - DataStoreCacheConfig fsc1 = DataStoreCacheConfig.enabled().staleValuesPolicy(EVICT); - DataStoreCacheConfig fsc2 = DataStoreCacheConfig.enabled().staleValuesPolicy(EVICT); - DataStoreCacheConfig fsc3 = DataStoreCacheConfig.enabled().staleValuesPolicy(REFRESH); - assertThat(fsc1.equals(fsc2), equalTo(true)); - assertThat(fsc1.equals(fsc3), equalTo(false)); - } -} diff --git a/src/test/java/com/launchdarkly/client/DataStoreDatabaseTestBase.java b/src/test/java/com/launchdarkly/client/DataStoreDatabaseTestBase.java deleted file mode 100644 index 2be21a4e9..000000000 --- a/src/test/java/com/launchdarkly/client/DataStoreDatabaseTestBase.java +++ /dev/null @@ -1,240 +0,0 @@ -package com.launchdarkly.client; - -import com.launchdarkly.client.DataStoreTestTypes.TestItem; -import com.launchdarkly.client.TestUtil.DataBuilder; -import com.launchdarkly.client.interfaces.DataStore; -import com.launchdarkly.client.interfaces.VersionedData; - -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.DataStoreTestTypes.TEST_ITEMS; -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 DataStoreTestBase with tests for data stores where multiple store instances can - * use the same underlying data store (i.e. database implementations in general). - */ -@SuppressWarnings("javadoc") -@RunWith(Parameterized.class) -public abstract class DataStoreDatabaseTestBase extends DataStoreTestBase { - - @Parameters(name="cached={0}") - public static Iterable data() { - return Arrays.asList(new Boolean[] { false, true }); - } - - public DataStoreDatabaseTestBase(boolean cached) { - super(cached); - } - - /** - * Test subclasses should override this method if the data store class supports a key prefix option - * for keeping data sets distinct within the same database. - */ - protected DataStore makeStoreWithPrefix(String prefix) { - return null; - } - - /** - * Test classes should override this to return false if the data 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 data 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(DataStore 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(); - DataStore store2 = makeStore(); - - assertFalse(store.initialized()); - - store2.init(new DataBuilder().add(TEST_ITEMS, item1).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(); - DataStore 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 DataStore 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.upsert(TEST_ITEMS, startItem.withVersion(versionCounter)); - versionCounter++; - } - } - }; - - try { - assumeTrue(setUpdateHook(store, concurrentModifier)); - - store.init(new DataBuilder().add(TEST_ITEMS, startItem).build()); - - TestItem store1End = startItem.withVersion(store1VersionEnd); - store.upsert(TEST_ITEMS, store1End); - - VersionedData result = store.get(TEST_ITEMS, startItem.key); - assertEquals(store1VersionEnd, result.getVersion()); - } finally { - store2.close(); - } - } - - @Test - public void handlesUpsertRaceConditionAgainstExternalClientWithHigherVersion() throws Exception { - final DataStore 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.upsert(TEST_ITEMS, startItem.withVersion(store2Version)); - } - }; - - try { - assumeTrue(setUpdateHook(store, concurrentModifier)); - - store.init(new DataBuilder().add(TEST_ITEMS, startItem).build()); - - TestItem store1End = startItem.withVersion(store1VersionEnd); - store.upsert(TEST_ITEMS, store1End); - - VersionedData result = store.get(TEST_ITEMS, startItem.key); - assertEquals(store2Version, result.getVersion()); - } finally { - store2.close(); - } - } - - @Test - public void storesWithDifferentPrefixAreIndependent() throws Exception { - DataStore store1 = makeStoreWithPrefix("aaa"); - Assume.assumeNotNull(store1); - DataStore store2 = makeStoreWithPrefix("bbb"); - clearAllData(); - - try { - assertFalse(store1.initialized()); - assertFalse(store2.initialized()); - - 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(TEST_ITEMS, item1a, item1b).build()); - assertTrue(store1.initialized()); - assertFalse(store2.initialized()); - - store2.init(new DataBuilder().add(TEST_ITEMS, item2a, item2c).build()); - assertTrue(store1.initialized()); - assertTrue(store2.initialized()); - - Map items1 = store1.all(TEST_ITEMS); - Map items2 = store2.all(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.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/DataStoreTestBase.java b/src/test/java/com/launchdarkly/client/DataStoreTestBase.java index c5282dd3e..ee87122ba 100644 --- a/src/test/java/com/launchdarkly/client/DataStoreTestBase.java +++ b/src/test/java/com/launchdarkly/client/DataStoreTestBase.java @@ -1,10 +1,10 @@ package com.launchdarkly.client; +import com.launchdarkly.client.DataStoreTestTypes.DataBuilder; import com.launchdarkly.client.DataStoreTestTypes.TestItem; -import com.launchdarkly.client.TestUtil.DataBuilder; import com.launchdarkly.client.interfaces.DataStore; -import com.launchdarkly.client.interfaces.VersionedData; -import com.launchdarkly.client.interfaces.VersionedDataKind; +import com.launchdarkly.client.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.client.interfaces.DataStoreTypes.ItemDescriptor; import org.junit.After; import org.junit.Before; @@ -14,9 +14,9 @@ import static com.launchdarkly.client.DataStoreTestTypes.OTHER_TEST_ITEMS; import static com.launchdarkly.client.DataStoreTestTypes.TEST_ITEMS; +import static com.launchdarkly.client.DataStoreTestTypes.toItemsMap; 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; @@ -30,11 +30,11 @@ public abstract class DataStoreTestBase { protected DataStore store; protected boolean cached; - protected TestItem item1 = new TestItem("first", "key1", 10); + protected TestItem item1 = new TestItem("key1", "first", 10); - protected TestItem item2 = new TestItem("second", "key2", 10); + protected TestItem item2 = new TestItem("key2", "second", 10); - protected TestItem otherItem1 = new TestItem("other-first", "key1", 11); + protected TestItem otherItem1 = new TestItem("key1", "other-first", 11); public DataStoreTestBase() { this(false); @@ -51,14 +51,6 @@ public DataStoreTestBase(boolean cached) { */ protected abstract DataStore 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(); @@ -71,21 +63,18 @@ public void teardown() throws Exception { @Test public void storeNotInitializedBeforeInit() { - clearAllData(); - assertFalse(store.initialized()); + assertFalse(store.isInitialized()); } @Test public void storeInitializedAfterInit() { store.init(new DataBuilder().build()); - assertTrue(store.initialized()); + assertTrue(store.isInitialized()); } @Test public void initCompletelyReplacesPreviousData() { - clearAllData(); - - Map, Map> allData = + FullDataSet allData = new DataBuilder().add(TEST_ITEMS, item1, item2).add(OTHER_TEST_ITEMS, otherItem1).build(); store.init(allData); @@ -94,14 +83,14 @@ public void initCompletelyReplacesPreviousData() { store.init(allData); assertNull(store.get(TEST_ITEMS, item1.key)); - assertEquals(item2v2, store.get(TEST_ITEMS, item2.key)); + assertEquals(item2v2.toItemDescriptor(), store.get(TEST_ITEMS, item2.key)); assertNull(store.get(OTHER_TEST_ITEMS, otherItem1.key)); } @Test public void getExistingItem() { store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).build()); - assertEquals(item1, store.get(TEST_ITEMS, item1.key)); + assertEquals(item1.toItemDescriptor(), store.get(TEST_ITEMS, item1.key)); } @Test @@ -113,71 +102,77 @@ public void getNonexistingItem() { @Test public void getAll() { store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).add(OTHER_TEST_ITEMS, otherItem1).build()); - Map items = store.all(TEST_ITEMS); + Map items = toItemsMap(store.getAll(TEST_ITEMS)); assertEquals(2, items.size()); - assertEquals(item1, items.get(item1.key)); - assertEquals(item2, items.get(item2.key)); + assertEquals(item1.toItemDescriptor(), items.get(item1.key)); + assertEquals(item2.toItemDescriptor(), items.get(item2.key)); } @Test public void getAllWithDeletedItem() { 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()); - assertEquals(item2, items.get(item2.key)); + ItemDescriptor deletedItem = ItemDescriptor.deletedItem(item1.getVersion() + 1); + store.upsert(TEST_ITEMS, item1.key, deletedItem); + Map items = toItemsMap(store.getAll(TEST_ITEMS)); + assertEquals(2, items.size()); + assertEquals(deletedItem, items.get(item1.key)); + assertEquals(item2.toItemDescriptor(), items.get(item2.key)); } @Test public void upsertWithNewerVersion() { 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)); + store.upsert(TEST_ITEMS, item1.key, newVer.toItemDescriptor()); + assertEquals(newVer.toItemDescriptor(), store.get(TEST_ITEMS, item1.key)); } @Test public void upsertWithOlderVersion() { 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)); + store.upsert(TEST_ITEMS, item1.key, oldVer.toItemDescriptor()); + assertEquals(item1.toItemDescriptor(), store.get(TEST_ITEMS, item1.key)); } @Test 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)); + store.upsert(TEST_ITEMS, newItem.key, newItem.toItemDescriptor()); + assertEquals(newItem.toItemDescriptor(), store.get(TEST_ITEMS, newItem.key)); } @Test public void deleteWithNewerVersion() { 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)); + ItemDescriptor deletedItem = ItemDescriptor.deletedItem(item1.version + 1); + store.upsert(TEST_ITEMS, item1.key, deletedItem); + assertEquals(deletedItem, store.get(TEST_ITEMS, item1.key)); } @Test public void deleteWithOlderVersion() { 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)); + ItemDescriptor deletedItem = ItemDescriptor.deletedItem(item1.version - 1); + store.upsert(TEST_ITEMS, item1.key, deletedItem); + assertEquals(item1.toItemDescriptor(), store.get(TEST_ITEMS, item1.key)); } @Test 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")); + ItemDescriptor deletedItem = ItemDescriptor.deletedItem(item1.version - 1); + store.upsert(TEST_ITEMS, "biz", deletedItem); + assertEquals(deletedItem, store.get(TEST_ITEMS, "biz")); } @Test public void upsertOlderVersionAfterDelete() { 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)); + ItemDescriptor deletedItem = ItemDescriptor.deletedItem(item1.version + 1); + store.upsert(TEST_ITEMS, item1.key, deletedItem); + store.upsert(TEST_ITEMS, item1.key, item1.toItemDescriptor()); + assertEquals(deletedItem, store.get(TEST_ITEMS, item1.key)); } } diff --git a/src/test/java/com/launchdarkly/client/DataStoreTestTypes.java b/src/test/java/com/launchdarkly/client/DataStoreTestTypes.java index 4c70347b6..97ff630bc 100644 --- a/src/test/java/com/launchdarkly/client/DataStoreTestTypes.java +++ b/src/test/java/com/launchdarkly/client/DataStoreTestTypes.java @@ -1,28 +1,58 @@ package com.launchdarkly.client; import com.google.common.base.Objects; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Maps; +import com.launchdarkly.client.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.client.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.client.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.client.interfaces.DataStoreTypes.KeyedItems; +import com.launchdarkly.client.interfaces.DataStoreTypes.SerializedItemDescriptor; import com.launchdarkly.client.interfaces.VersionedData; -import com.launchdarkly.client.interfaces.VersionedDataKind; + +import java.util.AbstractMap; +import java.util.HashMap; +import java.util.Map; + +import static com.google.common.collect.Iterables.transform; @SuppressWarnings("javadoc") public class DataStoreTestTypes { + public static Map> toDataMap(FullDataSet data) { + return ImmutableMap.copyOf(transform(data.getData(), e -> new AbstractMap.SimpleEntry<>(e.getKey(), toItemsMap(e.getValue())))); + } + + public static Map toItemsMap(KeyedItems data) { + return ImmutableMap.copyOf(data.getItems()); + } + + public static SerializedItemDescriptor toSerialized(DataKind kind, ItemDescriptor item) { + return item.getItem() == null ? SerializedItemDescriptor.deletedItem(item.getVersion()) : + new SerializedItemDescriptor(item.getVersion(), kind.serialize(item.getItem())); + } + public static class TestItem implements VersionedData { - public final String name; public final String key; + public final String name; 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; + public TestItem(String key, String name, int version, boolean deleted) { this.key = key; + this.name = name; this.version = version; this.deleted = deleted; } + + public TestItem(String key, String name, int version) { + this(key, name, version, false); + } + public TestItem(String key, int version) { + this(key, "", version); + } + @Override public String getKey() { return key; @@ -32,22 +62,25 @@ public String getKey() { public int getVersion() { return version; } - - @Override + public boolean isDeleted() { return deleted; } - + public TestItem withName(String newName) { - return new TestItem(newName, key, version, deleted); + return new TestItem(key, newName, version); } public TestItem withVersion(int newVersion) { - return new TestItem(name, key, newVersion, deleted); + return new TestItem(key, name, newVersion); + } + + public ItemDescriptor toItemDescriptor() { + return new ItemDescriptor(version, this); } - public TestItem withDeleted(boolean newDeleted) { - return new TestItem(name, key, version, newDeleted); + public SerializedItemDescriptor toSerializedItemDescriptor() { + return new SerializedItemDescriptor(version, TEST_ITEMS.serialize(this)); } @Override @@ -56,74 +89,83 @@ public boolean equals(Object other) { TestItem o = (TestItem)other; return Objects.equal(name, o.name) && Objects.equal(key, o.key) && - version == o.version && - deleted == o.deleted; + version == o.version; } return false; } @Override public int hashCode() { - return Objects.hashCode(name, key, version, deleted); + return Objects.hashCode(name, key, version); } @Override public String toString() { - return "TestItem(" + name + "," + key + "," + version + "," + deleted + ")"; + return "TestItem(" + name + "," + key + "," + version + ")"; } } - 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; - } + public static final DataKind TEST_ITEMS = new DataKind("test-items", + DataStoreTestTypes::serializeTestItem, + DataStoreTestTypes::deserializeTestItem, + DataStoreTestTypes::serializeDeletedItemPlaceholder); - @Override - public TestItem makeDeletedItem(String key, int version) { - return new TestItem(null, key, version, true); - } + public static final DataKind OTHER_TEST_ITEMS = new DataKind("other-test-items", + DataStoreTestTypes::serializeTestItem, + DataStoreTestTypes::deserializeTestItem, + DataStoreTestTypes::serializeDeletedItemPlaceholder); - @Override - public TestItem deserialize(String serializedData) { - return JsonHelpers.gsonInstance().fromJson(serializedData, TestItem.class); - } - }; - - public static final VersionedDataKind OTHER_TEST_ITEMS = new VersionedDataKind() { - @Override - public String getNamespace() { - return "other-test-items"; - } - - @Override - public Class getItemClass() { - return TestItem.class; + private static String serializeTestItem(Object o) { + return JsonHelpers.gsonInstance().toJson(o); + } + + private static ItemDescriptor deserializeTestItem(String s) { + if (s.startsWith("DELETED:")) { + return ItemDescriptor.deletedItem(Integer.parseInt(s.substring(8))); } + TestItem ti = JsonHelpers.gsonInstance().fromJson(s, TestItem.class); + return new ItemDescriptor(ti.version, ti); + } + + private static String serializeDeletedItemPlaceholder(int version) { + return "DELETED:" + version; + } - @Override - public String getStreamApiPath() { - return null; + public static class DataBuilder { + private Map> data = new HashMap<>(); + + public DataBuilder add(DataKind kind, VersionedData... items) { + Map itemsMap = data.get(kind); + if (itemsMap == null) { + itemsMap = new HashMap<>(); + data.put(kind, itemsMap); + } + for (VersionedData item: items) { + itemsMap.put(item.getKey(), new ItemDescriptor(item.getVersion(), item)); + } + return this; } - - @Override - public TestItem makeDeletedItem(String key, int version) { - return new TestItem(null, key, version, true); + + public FullDataSet build() { + return new FullDataSet<>( + ImmutableMap.copyOf( + Maps.transformValues(data, itemsMap -> + new KeyedItems<>(ImmutableList.copyOf(itemsMap.entrySet())) + )).entrySet() + ); } - - @Override - public TestItem deserialize(String serializedData) { - return JsonHelpers.gsonInstance().fromJson(serializedData, TestItem.class); + + public FullDataSet buildSerialized() { + return new FullDataSet<>( + ImmutableMap.copyOf( + Maps.transformEntries(data, (kind, itemsMap) -> + new KeyedItems<>( + ImmutableMap.copyOf( + Maps.transformValues(itemsMap, item -> DataStoreTestTypes.toSerialized(kind, item)) + ).entrySet() + ) + ) + ).entrySet()); } - }; + } } diff --git a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java index b3b8d22ec..bc5c591f6 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java @@ -11,8 +11,6 @@ import java.time.Duration; import java.util.Map; -import static com.launchdarkly.client.DataModel.DataKinds.FEATURES; -import static com.launchdarkly.client.DataModel.DataKinds.SEGMENTS; import static com.launchdarkly.client.ModelBuilders.booleanFlagWithClauses; import static com.launchdarkly.client.ModelBuilders.clause; import static com.launchdarkly.client.ModelBuilders.fallthroughVariation; @@ -23,6 +21,8 @@ import static com.launchdarkly.client.TestUtil.failedDataSource; import static com.launchdarkly.client.TestUtil.specificDataSource; import static com.launchdarkly.client.TestUtil.specificDataStore; +import static com.launchdarkly.client.TestUtil.upsertFlag; +import static com.launchdarkly.client.TestUtil.upsertSegment; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; @@ -45,7 +45,7 @@ public class LDClientEvaluationTest { @Test public void boolVariationReturnsFlagValue() throws Exception { - dataStore.upsert(FEATURES, flagWithValue("key", LDValue.of(true))); + upsertFlag(dataStore, flagWithValue("key", LDValue.of(true))); assertTrue(client.boolVariation("key", user, false)); } @@ -57,31 +57,31 @@ public void boolVariationReturnsDefaultValueForUnknownFlag() throws Exception { @Test public void boolVariationReturnsDefaultValueForWrongType() throws Exception { - dataStore.upsert(FEATURES, flagWithValue("key", LDValue.of("wrong"))); + upsertFlag(dataStore, flagWithValue("key", LDValue.of("wrong"))); assertFalse(client.boolVariation("key", user, false)); } @Test public void intVariationReturnsFlagValue() throws Exception { - dataStore.upsert(FEATURES, flagWithValue("key", LDValue.of(2))); + upsertFlag(dataStore, flagWithValue("key", LDValue.of(2))); assertEquals(new Integer(2), client.intVariation("key", user, 1)); } @Test public void intVariationReturnsFlagValueEvenIfEncodedAsDouble() throws Exception { - dataStore.upsert(FEATURES, flagWithValue("key", LDValue.of(2.0))); + upsertFlag(dataStore, flagWithValue("key", LDValue.of(2.0))); assertEquals(new Integer(2), client.intVariation("key", user, 1)); } @Test public void intVariationFromDoubleRoundsTowardZero() throws Exception { - dataStore.upsert(FEATURES, flagWithValue("flag1", LDValue.of(2.25))); - dataStore.upsert(FEATURES, flagWithValue("flag2", LDValue.of(2.75))); - dataStore.upsert(FEATURES, flagWithValue("flag3", LDValue.of(-2.25))); - dataStore.upsert(FEATURES, flagWithValue("flag4", LDValue.of(-2.75))); + upsertFlag(dataStore, flagWithValue("flag1", LDValue.of(2.25))); + upsertFlag(dataStore, flagWithValue("flag2", LDValue.of(2.75))); + upsertFlag(dataStore, flagWithValue("flag3", LDValue.of(-2.25))); + upsertFlag(dataStore, flagWithValue("flag4", LDValue.of(-2.75))); assertEquals(new Integer(2), client.intVariation("flag1", user, 1)); assertEquals(new Integer(2), client.intVariation("flag2", user, 1)); @@ -96,21 +96,21 @@ public void intVariationReturnsDefaultValueForUnknownFlag() throws Exception { @Test public void intVariationReturnsDefaultValueForWrongType() throws Exception { - dataStore.upsert(FEATURES, flagWithValue("key", LDValue.of("wrong"))); + upsertFlag(dataStore, flagWithValue("key", LDValue.of("wrong"))); assertEquals(new Integer(1), client.intVariation("key", user, 1)); } @Test public void doubleVariationReturnsFlagValue() throws Exception { - dataStore.upsert(FEATURES, flagWithValue("key", LDValue.of(2.5d))); + upsertFlag(dataStore, flagWithValue("key", LDValue.of(2.5d))); assertEquals(new Double(2.5d), client.doubleVariation("key", user, 1.0d)); } @Test public void doubleVariationReturnsFlagValueEvenIfEncodedAsInt() throws Exception { - dataStore.upsert(FEATURES, flagWithValue("key", LDValue.of(2))); + upsertFlag(dataStore, flagWithValue("key", LDValue.of(2))); assertEquals(new Double(2.0d), client.doubleVariation("key", user, 1.0d)); } @@ -122,21 +122,21 @@ public void doubleVariationReturnsDefaultValueForUnknownFlag() throws Exception @Test public void doubleVariationReturnsDefaultValueForWrongType() throws Exception { - dataStore.upsert(FEATURES, flagWithValue("key", LDValue.of("wrong"))); + upsertFlag(dataStore, flagWithValue("key", LDValue.of("wrong"))); assertEquals(new Double(1.0d), client.doubleVariation("key", user, 1.0d)); } @Test public void stringVariationReturnsFlagValue() throws Exception { - dataStore.upsert(FEATURES, flagWithValue("key", LDValue.of("b"))); + upsertFlag(dataStore, flagWithValue("key", LDValue.of("b"))); assertEquals("b", client.stringVariation("key", user, "a")); } @Test public void stringVariationWithNullDefaultReturnsFlagValue() throws Exception { - dataStore.upsert(FEATURES, flagWithValue("key", LDValue.of("b"))); + upsertFlag(dataStore, flagWithValue("key", LDValue.of("b"))); assertEquals("b", client.stringVariation("key", user, null)); } @@ -153,7 +153,7 @@ public void stringVariationWithNullDefaultReturnsDefaultValueForUnknownFlag() th @Test public void stringVariationReturnsDefaultValueForWrongType() throws Exception { - dataStore.upsert(FEATURES, flagWithValue("key", LDValue.of(true))); + upsertFlag(dataStore, flagWithValue("key", LDValue.of(true))); assertEquals("a", client.stringVariation("key", user, "a")); } @@ -161,7 +161,7 @@ public void stringVariationReturnsDefaultValueForWrongType() throws Exception { @Test public void jsonValueVariationReturnsFlagValue() throws Exception { LDValue data = LDValue.buildObject().put("thing", LDValue.of("stuff")).build(); - dataStore.upsert(FEATURES, flagWithValue("key", data)); + upsertFlag(dataStore, flagWithValue("key", data)); assertEquals(data, client.jsonValueVariation("key", user, LDValue.of(42))); } @@ -179,18 +179,18 @@ public void canMatchUserBySegment() throws Exception { .version(1) .included(user.getKeyAsString()) .build(); - dataStore.upsert(SEGMENTS, segment); + upsertSegment(dataStore, segment); DataModel.Clause clause = clause("", DataModel.Operator.segmentMatch, LDValue.of("segment1")); DataModel.FeatureFlag feature = booleanFlagWithClauses("feature", clause); - dataStore.upsert(FEATURES, feature); + upsertFlag(dataStore, feature); assertTrue(client.boolVariation("feature", user, false)); } @Test public void canGetDetailsForSuccessfulEvaluation() throws Exception { - dataStore.upsert(FEATURES, flagWithValue("key", LDValue.of(true))); + upsertFlag(dataStore, flagWithValue("key", LDValue.of(true))); EvaluationDetail expectedResult = EvaluationDetail.fromValue(true, 0, EvaluationReason.off()); @@ -200,7 +200,7 @@ public void canGetDetailsForSuccessfulEvaluation() throws Exception { @Test public void variationReturnsDefaultIfFlagEvaluatesToNull() { DataModel.FeatureFlag flag = flagBuilder("key").on(false).offVariation(null).build(); - dataStore.upsert(FEATURES, flag); + upsertFlag(dataStore, flag); assertEquals("default", client.stringVariation("key", user, "default")); } @@ -208,7 +208,7 @@ public void variationReturnsDefaultIfFlagEvaluatesToNull() { @Test public void variationDetailReturnsDefaultIfFlagEvaluatesToNull() { DataModel.FeatureFlag flag = flagBuilder("key").on(false).offVariation(null).build(); - dataStore.upsert(FEATURES, flag); + upsertFlag(dataStore, flag); EvaluationDetail expected = EvaluationDetail.fromValue("default", null, EvaluationReason.off()); @@ -242,7 +242,7 @@ public void appropriateErrorIfFlagDoesNotExist() throws Exception { @Test public void appropriateErrorIfUserNotSpecified() throws Exception { - dataStore.upsert(FEATURES, flagWithValue("key", LDValue.of(true))); + upsertFlag(dataStore, flagWithValue("key", LDValue.of(true))); EvaluationDetail expectedResult = EvaluationDetail.fromValue("default", null, EvaluationReason.error(EvaluationReason.ErrorKind.USER_NOT_SPECIFIED)); @@ -251,7 +251,7 @@ public void appropriateErrorIfUserNotSpecified() throws Exception { @Test public void appropriateErrorIfValueWrongType() throws Exception { - dataStore.upsert(FEATURES, flagWithValue("key", LDValue.of(true))); + upsertFlag(dataStore, flagWithValue("key", LDValue.of(true))); EvaluationDetail expectedResult = EvaluationDetail.fromValue(3, null, EvaluationReason.error(EvaluationReason.ErrorKind.WRONG_TYPE)); @@ -291,8 +291,8 @@ public void allFlagsStateReturnsState() throws Exception { .fallthrough(fallthroughVariation(1)) .variations(LDValue.of("off"), LDValue.of("value2")) .build(); - dataStore.upsert(FEATURES, flag1); - dataStore.upsert(FEATURES, flag2); + upsertFlag(dataStore, flag1); + upsertFlag(dataStore, flag2); FeatureFlagsState state = client.allFlagsState(user); assertTrue(state.isValid()); @@ -319,10 +319,10 @@ public void allFlagsStateCanFilterForOnlyClientSideFlags() { .variations(LDValue.of("value1")).offVariation(0).build(); DataModel.FeatureFlag flag4 = flagBuilder("client-side-2").clientSide(true) .variations(LDValue.of("value2")).offVariation(0).build(); - dataStore.upsert(FEATURES, flag1); - dataStore.upsert(FEATURES, flag2); - dataStore.upsert(FEATURES, flag3); - dataStore.upsert(FEATURES, flag4); + upsertFlag(dataStore, flag1); + upsertFlag(dataStore, flag2); + upsertFlag(dataStore, flag3); + upsertFlag(dataStore, flag4); FeatureFlagsState state = client.allFlagsState(user, FlagsStateOption.CLIENT_SIDE_ONLY); assertTrue(state.isValid()); @@ -348,8 +348,8 @@ public void allFlagsStateReturnsStateWithReasons() { .fallthrough(fallthroughVariation(1)) .variations(LDValue.of("off"), LDValue.of("value2")) .build(); - dataStore.upsert(FEATURES, flag1); - dataStore.upsert(FEATURES, flag2); + upsertFlag(dataStore, flag1); + upsertFlag(dataStore, flag2); FeatureFlagsState state = client.allFlagsState(user, FlagsStateOption.WITH_REASONS); assertTrue(state.isValid()); @@ -393,9 +393,9 @@ public void allFlagsStateCanOmitDetailsForUntrackedFlags() { .offVariation(0) .variations(LDValue.of("value3")) .build(); - dataStore.upsert(FEATURES, flag1); - dataStore.upsert(FEATURES, flag2); - dataStore.upsert(FEATURES, flag3); + upsertFlag(dataStore, flag1); + upsertFlag(dataStore, flag2); + upsertFlag(dataStore, flag3); FeatureFlagsState state = client.allFlagsState(user, FlagsStateOption.WITH_REASONS, FlagsStateOption.DETAILS_ONLY_FOR_TRACKED_FLAGS); assertTrue(state.isValid()); @@ -418,7 +418,7 @@ public void allFlagsStateCanOmitDetailsForUntrackedFlags() { @Test public void allFlagsStateReturnsEmptyStateForNullUser() throws Exception { - dataStore.upsert(FEATURES, flagWithValue("key", LDValue.of("value"))); + upsertFlag(dataStore, flagWithValue("key", LDValue.of("value"))); FeatureFlagsState state = client.allFlagsState(null); assertFalse(state.isValid()); @@ -427,7 +427,7 @@ public void allFlagsStateReturnsEmptyStateForNullUser() throws Exception { @Test public void allFlagsStateReturnsEmptyStateForNullUserKey() throws Exception { - dataStore.upsert(FEATURES, flagWithValue("key", LDValue.of("value"))); + upsertFlag(dataStore, 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 dd5f94820..bdb1096b2 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEventTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEventTest.java @@ -7,7 +7,6 @@ import org.junit.Test; -import static com.launchdarkly.client.DataModel.DataKinds.FEATURES; import static com.launchdarkly.client.ModelBuilders.clauseMatchingUser; import static com.launchdarkly.client.ModelBuilders.clauseNotMatchingUser; import static com.launchdarkly.client.ModelBuilders.fallthroughVariation; @@ -17,6 +16,7 @@ import static com.launchdarkly.client.ModelBuilders.ruleBuilder; import static com.launchdarkly.client.TestUtil.specificDataStore; import static com.launchdarkly.client.TestUtil.specificEventProcessor; +import static com.launchdarkly.client.TestUtil.upsertFlag; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; @@ -117,7 +117,7 @@ public void trackWithUserWithNoKeyDoesNotSendEvent() { @Test public void boolVariationSendsEvent() throws Exception { DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of(true)); - dataStore.upsert(FEATURES, flag); + upsertFlag(dataStore, flag); client.boolVariation("key", user, false); assertEquals(1, eventSink.events.size()); @@ -134,7 +134,7 @@ public void boolVariationSendsEventForUnknownFlag() throws Exception { @Test public void boolVariationDetailSendsEvent() throws Exception { DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of(true)); - dataStore.upsert(FEATURES, flag); + upsertFlag(dataStore, flag); client.boolVariationDetail("key", user, false); assertEquals(1, eventSink.events.size()); @@ -152,7 +152,7 @@ public void boolVariationDetailSendsEventForUnknownFlag() throws Exception { @Test public void intVariationSendsEvent() throws Exception { DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of(2)); - dataStore.upsert(FEATURES, flag); + upsertFlag(dataStore, flag); client.intVariation("key", user, 1); assertEquals(1, eventSink.events.size()); @@ -169,7 +169,7 @@ public void intVariationSendsEventForUnknownFlag() throws Exception { @Test public void intVariationDetailSendsEvent() throws Exception { DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of(2)); - dataStore.upsert(FEATURES, flag); + upsertFlag(dataStore, flag); client.intVariationDetail("key", user, 1); assertEquals(1, eventSink.events.size()); @@ -187,7 +187,7 @@ public void intVariationDetailSendsEventForUnknownFlag() throws Exception { @Test public void doubleVariationSendsEvent() throws Exception { DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of(2.5d)); - dataStore.upsert(FEATURES, flag); + upsertFlag(dataStore, flag); client.doubleVariation("key", user, 1.0d); assertEquals(1, eventSink.events.size()); @@ -204,7 +204,7 @@ public void doubleVariationSendsEventForUnknownFlag() throws Exception { @Test public void doubleVariationDetailSendsEvent() throws Exception { DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of(2.5d)); - dataStore.upsert(FEATURES, flag); + upsertFlag(dataStore, flag); client.doubleVariationDetail("key", user, 1.0d); assertEquals(1, eventSink.events.size()); @@ -222,7 +222,7 @@ public void doubleVariationDetailSendsEventForUnknownFlag() throws Exception { @Test public void stringVariationSendsEvent() throws Exception { DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of("b")); - dataStore.upsert(FEATURES, flag); + upsertFlag(dataStore, flag); client.stringVariation("key", user, "a"); assertEquals(1, eventSink.events.size()); @@ -239,7 +239,7 @@ public void stringVariationSendsEventForUnknownFlag() throws Exception { @Test public void stringVariationDetailSendsEvent() throws Exception { DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of("b")); - dataStore.upsert(FEATURES, flag); + upsertFlag(dataStore, flag); client.stringVariationDetail("key", user, "a"); assertEquals(1, eventSink.events.size()); @@ -258,7 +258,7 @@ public void stringVariationDetailSendsEventForUnknownFlag() throws Exception { public void jsonValueVariationDetailSendsEvent() throws Exception { LDValue data = LDValue.buildObject().put("thing", LDValue.of("stuff")).build(); DataModel.FeatureFlag flag = flagWithValue("key", data); - dataStore.upsert(FEATURES, flag); + upsertFlag(dataStore, flag); LDValue defaultVal = LDValue.of(42); client.jsonValueVariationDetail("key", user, defaultVal); @@ -286,7 +286,7 @@ public void eventTrackingAndReasonCanBeForcedForRule() throws Exception { .offVariation(0) .variations(LDValue.of("off"), LDValue.of("on")) .build(); - dataStore.upsert(FEATURES, flag); + upsertFlag(dataStore, flag); client.stringVariation("flag", user, "default"); @@ -311,7 +311,7 @@ public void eventTrackingAndReasonAreNotForcedIfFlagIsNotSetForMatchingRule() th .offVariation(0) .variations(LDValue.of("off"), LDValue.of("on")) .build(); - dataStore.upsert(FEATURES, flag); + upsertFlag(dataStore, flag); client.stringVariation("flag", user, "default"); @@ -331,7 +331,7 @@ public void eventTrackingAndReasonCanBeForcedForFallthrough() throws Exception { .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .trackEventsFallthrough(true) .build(); - dataStore.upsert(FEATURES, flag); + upsertFlag(dataStore, flag); client.stringVariation("flag", user, "default"); @@ -352,7 +352,7 @@ public void eventTrackingAndReasonAreNotForcedForFallthroughIfFlagIsNotSet() thr .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .trackEventsFallthrough(false) .build(); - dataStore.upsert(FEATURES, flag); + upsertFlag(dataStore, flag); client.stringVariation("flag", user, "default"); @@ -371,7 +371,7 @@ public void eventTrackingAndReasonAreNotForcedForFallthroughIfReasonIsNotFallthr .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .trackEventsFallthrough(true) .build(); - dataStore.upsert(FEATURES, flag); + upsertFlag(dataStore, flag); client.stringVariation("flag", user, "default"); @@ -397,8 +397,8 @@ public void eventIsSentForExistingPrererequisiteFlag() throws Exception { .variations(LDValue.of("nogo"), LDValue.of("go")) .version(2) .build(); - dataStore.upsert(FEATURES, f0); - dataStore.upsert(FEATURES, f1); + upsertFlag(dataStore, f0); + upsertFlag(dataStore, f1); client.stringVariation("feature0", user, "default"); @@ -423,8 +423,8 @@ public void eventIsSentWithReasonForExistingPrererequisiteFlag() throws Exceptio .variations(LDValue.of("nogo"), LDValue.of("go")) .version(2) .build(); - dataStore.upsert(FEATURES, f0); - dataStore.upsert(FEATURES, f1); + upsertFlag(dataStore, f0); + upsertFlag(dataStore, f1); client.stringVariationDetail("feature0", user, "default"); @@ -443,7 +443,7 @@ public void eventIsNotSentForUnknownPrererequisiteFlag() throws Exception { .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .version(1) .build(); - dataStore.upsert(FEATURES, f0); + upsertFlag(dataStore, f0); client.stringVariation("feature0", user, "default"); @@ -461,7 +461,7 @@ public void failureReasonIsGivenForUnknownPrererequisiteFlagIfDetailsWereRequest .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .version(1) .build(); - dataStore.upsert(FEATURES, f0); + upsertFlag(dataStore, f0); client.stringVariationDetail("feature0", user, "default"); diff --git a/src/test/java/com/launchdarkly/client/LDClientExternalUpdatesOnlyTest.java b/src/test/java/com/launchdarkly/client/LDClientExternalUpdatesOnlyTest.java index 04bfbcb1b..c95d6068e 100644 --- a/src/test/java/com/launchdarkly/client/LDClientExternalUpdatesOnlyTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientExternalUpdatesOnlyTest.java @@ -7,9 +7,9 @@ import java.io.IOException; -import static com.launchdarkly.client.DataModel.DataKinds.FEATURES; import static com.launchdarkly.client.ModelBuilders.flagWithValue; import static com.launchdarkly.client.TestUtil.specificDataStore; +import static com.launchdarkly.client.TestUtil.upsertFlag; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; @@ -53,7 +53,7 @@ public void externalUpdatesOnlyClientGetsFlagFromDataStore() throws IOException .dataStore(specificDataStore(testDataStore)) .build(); DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of(true)); - testDataStore.upsert(FEATURES, flag); + upsertFlag(testDataStore, 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 520d0c324..2c92a2e82 100644 --- a/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java @@ -8,10 +8,10 @@ import java.io.IOException; -import static com.launchdarkly.client.DataModel.DataKinds.FEATURES; import static com.launchdarkly.client.ModelBuilders.flagWithValue; import static com.launchdarkly.client.TestUtil.initedDataStore; import static com.launchdarkly.client.TestUtil.specificDataStore; +import static com.launchdarkly.client.TestUtil.upsertFlag; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; @@ -66,7 +66,7 @@ public void offlineClientGetsFlagsStateFromDataStore() throws IOException { .offline(true) .dataStore(specificDataStore(testDataStore)) .build(); - testDataStore.upsert(FEATURES, flagWithValue("key", LDValue.of(true))); + upsertFlag(testDataStore, 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 61f043c01..dc8ed4485 100644 --- a/src/test/java/com/launchdarkly/client/LDClientTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientTest.java @@ -2,18 +2,19 @@ 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 com.launchdarkly.client.DataStoreTestTypes.DataBuilder; import com.launchdarkly.client.interfaces.ClientContext; import com.launchdarkly.client.interfaces.DataSource; import com.launchdarkly.client.interfaces.DataSourceFactory; import com.launchdarkly.client.interfaces.DataStore; +import com.launchdarkly.client.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.client.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.client.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.client.interfaces.DataStoreUpdates; import com.launchdarkly.client.interfaces.Event; import com.launchdarkly.client.interfaces.EventProcessor; import com.launchdarkly.client.interfaces.EventProcessorFactory; -import com.launchdarkly.client.interfaces.VersionedData; -import com.launchdarkly.client.interfaces.VersionedDataKind; import com.launchdarkly.client.value.LDValue; import org.easymock.Capture; @@ -31,8 +32,10 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import static com.google.common.collect.Iterables.transform; import static com.launchdarkly.client.DataModel.DataKinds.FEATURES; import static com.launchdarkly.client.DataModel.DataKinds.SEGMENTS; +import static com.launchdarkly.client.DataStoreTestTypes.toDataMap; import static com.launchdarkly.client.ModelBuilders.flagBuilder; import static com.launchdarkly.client.ModelBuilders.flagWithValue; import static com.launchdarkly.client.ModelBuilders.prerequisite; @@ -41,6 +44,7 @@ import static com.launchdarkly.client.TestUtil.failedDataSource; import static com.launchdarkly.client.TestUtil.initedDataStore; import static com.launchdarkly.client.TestUtil.specificDataStore; +import static com.launchdarkly.client.TestUtil.upsertFlag; import static org.easymock.EasyMock.anyObject; import static org.easymock.EasyMock.capture; import static org.easymock.EasyMock.expect; @@ -309,7 +313,7 @@ public void isFlagKnownReturnsTrueForExistingFlag() throws Exception { client = createMockClient(config); - testDataStore.upsert(FEATURES, flagWithValue("key", LDValue.of(1))); + upsertFlag(testDataStore, flagWithValue("key", LDValue.of(1))); assertTrue(client.isFlagKnown("key")); verifyAll(); } @@ -342,7 +346,7 @@ public void isFlagKnownReturnsFalseIfStoreAndClientAreNotInitialized() throws Ex client = createMockClient(config); - testDataStore.upsert(FEATURES, flagWithValue("key", LDValue.of(1))); + upsertFlag(testDataStore, flagWithValue("key", LDValue.of(1))); assertFalse(client.isFlagKnown("key")); verifyAll(); } @@ -359,7 +363,7 @@ public void isFlagKnownUsesStoreIfStoreIsInitializedButClientIsNot() throws Exce client = createMockClient(config); - testDataStore.upsert(FEATURES, flagWithValue("key", LDValue.of(1))); + upsertFlag(testDataStore, flagWithValue("key", LDValue.of(1))); assertTrue(client.isFlagKnown("key")); verifyAll(); } @@ -377,7 +381,7 @@ public void evaluationUsesStoreIfStoreIsInitializedButClientIsNot() throws Excep client = createMockClient(config); - testDataStore.upsert(FEATURES, flagWithValue("key", LDValue.of(1))); + upsertFlag(testDataStore, flagWithValue("key", LDValue.of(1))); assertEquals(new Integer(1), client.intVariation("key", new LDUser("user"), 0)); verifyAll(); @@ -389,7 +393,7 @@ public void dataSetIsPassedToDataStoreInCorrectOrder() throws Exception { // correct ordering for flag prerequisites, etc. This should work regardless of what kind of // DataSource we're using. - Capture, Map>> captureData = Capture.newInstance(); + Capture> captureData = Capture.newInstance(); DataStore store = createStrictMock(DataStore.class); store.init(EasyMock.capture(captureData)); replay(store); @@ -399,29 +403,29 @@ public void dataSetIsPassedToDataStoreInCorrectOrder() throws Exception { .dataStore(specificDataStore(store)) .events(Components.noEvents()); client = new LDClient(SDK_KEY, config.build()); - - Map, Map> dataMap = captureData.getValue(); + + Map> dataMap = toDataMap(captureData.getValue()); assertEquals(2, dataMap.size()); + Map> inputDataMap = toDataMap(DEPENDENCY_ORDERING_TEST_DATA); // 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()); + assertEquals(inputDataMap.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()); + Map map1 = Iterables.get(dataMap.values(), 1); + List list1 = ImmutableList.copyOf(transform(map1.values(), d -> (DataModel.FeatureFlag)d.getItem())); + assertEquals(inputDataMap.get(FEATURES).size(), map1.size()); for (int itemIndex = 0; itemIndex < list1.size(); itemIndex++) { - DataModel.FeatureFlag item = (DataModel.FeatureFlag)list1.get(itemIndex); + DataModel.FeatureFlag item = list1.get(itemIndex); for (DataModel.Prerequisite prereq: item.getPrerequisites()) { - DataModel.FeatureFlag depFlag = (DataModel.FeatureFlag)map1.get(prereq.getKey()); + DataModel.FeatureFlag depFlag = (DataModel.FeatureFlag)map1.get(prereq.getKey()).getItem(); int depIndex = list1.indexOf(depFlag); if (depIndex > itemIndex) { - Iterable allKeys = Iterables.transform(list1, d -> 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))); + Joiner.on(", ").join(map1.keySet()))); } } } @@ -442,22 +446,18 @@ private LDClientInterface createMockClient(LDConfig.Builder config) { return new LDClient(SDK_KEY, config.build()); } - private static Map, Map> DEPENDENCY_ORDERING_TEST_DATA = - ImmutableMap., Map>of( - FEATURES, - ImmutableMap.builder() - .put("a", flagBuilder("a") - .prerequisites(prerequisite("b", 0), prerequisite("c", 0)).build()) - .put("b", flagBuilder("b") - .prerequisites(prerequisite("c", 0), prerequisite("e", 0)).build()) - .put("c", flagBuilder("c").build()) - .put("d", flagBuilder("d").build()) - .put("e", flagBuilder("e").build()) - .put("f", flagBuilder("f").build()) - .build(), - SEGMENTS, - ImmutableMap.of( - "o", segmentBuilder("o").build() - ) - ); + private static FullDataSet DEPENDENCY_ORDERING_TEST_DATA = + new DataBuilder() + .add(FEATURES, + flagBuilder("a") + .prerequisites(prerequisite("b", 0), prerequisite("c", 0)).build(), + flagBuilder("b") + .prerequisites(prerequisite("c", 0), prerequisite("e", 0)).build(), + flagBuilder("c").build(), + flagBuilder("d").build(), + flagBuilder("e").build(), + flagBuilder("f").build()) + .add(SEGMENTS, + segmentBuilder("o").build()) + .build(); } \ No newline at end of file diff --git a/src/test/java/com/launchdarkly/client/PollingProcessorTest.java b/src/test/java/com/launchdarkly/client/PollingProcessorTest.java index e20095648..c33729c7b 100644 --- a/src/test/java/com/launchdarkly/client/PollingProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/PollingProcessorTest.java @@ -58,7 +58,7 @@ public void testConnectionOk() throws Exception { Future initFuture = pollingProcessor.start(); initFuture.get(1000, TimeUnit.MILLISECONDS); assertTrue(pollingProcessor.isInitialized()); - assertTrue(store.initialized()); + assertTrue(store.isInitialized()); } } @@ -77,7 +77,7 @@ public void testConnectionProblem() throws Exception { } assertFalse(initFuture.isDone()); assertFalse(pollingProcessor.isInitialized()); - assertFalse(store.initialized()); + assertFalse(store.isInitialized()); } } diff --git a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java index 9c17acb3e..04e20f272 100644 --- a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java @@ -2,6 +2,7 @@ import com.launchdarkly.client.integrations.StreamingDataSourceBuilder; import com.launchdarkly.client.interfaces.DataSourceFactory; +import com.launchdarkly.client.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.eventsource.ConnectionErrorHandler; import com.launchdarkly.eventsource.EventHandler; import com.launchdarkly.eventsource.EventSource; @@ -32,6 +33,8 @@ import static com.launchdarkly.client.TestHttpUtil.makeStartedServer; import static com.launchdarkly.client.TestUtil.clientContext; import static com.launchdarkly.client.TestUtil.dataStoreUpdates; +import static com.launchdarkly.client.TestUtil.upsertFlag; +import static com.launchdarkly.client.TestUtil.upsertSegment; import static org.easymock.EasyMock.expect; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; @@ -161,14 +164,14 @@ public void putCausesSegmentToBeStored() throws Exception { @Test public void storeNotInitializedByDefault() throws Exception { createStreamProcessor(STREAM_URI).start(); - assertFalse(dataStore.initialized()); + assertFalse(dataStore.isInitialized()); } @Test public void putCausesStoreToBeInitialized() throws Exception { createStreamProcessor(STREAM_URI).start(); eventHandler.onMessage("put", emptyPutEvent()); - assertTrue(dataStore.initialized()); + assertTrue(dataStore.isInitialized()); } @Test @@ -231,28 +234,28 @@ public void patchUpdatesSegment() throws Exception { public void deleteDeletesFeature() throws Exception { createStreamProcessor(STREAM_URI).start(); eventHandler.onMessage("put", emptyPutEvent()); - dataStore.upsert(FEATURES, FEATURE); + upsertFlag(dataStore, FEATURE); String path = "/flags/" + FEATURE1_KEY; MessageEvent event = new MessageEvent("{\"path\":\"" + path + "\",\"version\":" + (FEATURE1_VERSION + 1) + "}"); eventHandler.onMessage("delete", event); - assertNull(dataStore.get(FEATURES, FEATURE1_KEY)); + assertEquals(ItemDescriptor.deletedItem(FEATURE1_VERSION + 1), dataStore.get(FEATURES, FEATURE1_KEY)); } @Test public void deleteDeletesSegment() throws Exception { createStreamProcessor(STREAM_URI).start(); eventHandler.onMessage("put", emptyPutEvent()); - dataStore.upsert(SEGMENTS, SEGMENT); + upsertSegment(dataStore, SEGMENT); String path = "/segments/" + SEGMENT1_KEY; MessageEvent event = new MessageEvent("{\"path\":\"" + path + "\",\"version\":" + (SEGMENT1_VERSION + 1) + "}"); eventHandler.onMessage("delete", event); - assertNull(dataStore.get(SEGMENTS, SEGMENT1_KEY)); + assertEquals(ItemDescriptor.deletedItem(SEGMENT1_VERSION + 1), dataStore.get(SEGMENTS, SEGMENT1_KEY)); } @Test @@ -274,7 +277,7 @@ public void indirectPutInitializesStore() throws Exception { eventHandler.onMessage("indirect/put", new MessageEvent("")); - assertTrue(dataStore.initialized()); + assertTrue(dataStore.isInitialized()); } @Test @@ -286,7 +289,7 @@ public void indirectPutInitializesProcessor() throws Exception { eventHandler.onMessage("indirect/put", new MessageEvent("")); - assertTrue(dataStore.initialized()); + assertTrue(dataStore.isInitialized()); } @Test diff --git a/src/test/java/com/launchdarkly/client/TestUtil.java b/src/test/java/com/launchdarkly/client/TestUtil.java index bb7f801b6..2b3bace90 100644 --- a/src/test/java/com/launchdarkly/client/TestUtil.java +++ b/src/test/java/com/launchdarkly/client/TestUtil.java @@ -2,18 +2,22 @@ import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.SettableFuture; +import com.launchdarkly.client.DataModel.FeatureFlag; +import com.launchdarkly.client.DataModel.Segment; import com.launchdarkly.client.integrations.EventProcessorBuilder; import com.launchdarkly.client.interfaces.ClientContext; import com.launchdarkly.client.interfaces.DataSource; import com.launchdarkly.client.interfaces.DataSourceFactory; import com.launchdarkly.client.interfaces.DataStore; import com.launchdarkly.client.interfaces.DataStoreFactory; +import com.launchdarkly.client.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.client.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.client.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.client.interfaces.DataStoreTypes.KeyedItems; import com.launchdarkly.client.interfaces.DataStoreUpdates; import com.launchdarkly.client.interfaces.Event; import com.launchdarkly.client.interfaces.EventProcessor; import com.launchdarkly.client.interfaces.EventProcessorFactory; -import com.launchdarkly.client.interfaces.VersionedData; -import com.launchdarkly.client.interfaces.VersionedDataKind; import com.launchdarkly.client.value.LDValue; import com.launchdarkly.client.value.LDValueType; @@ -23,10 +27,7 @@ import java.io.IOException; import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; import java.util.List; -import java.util.Map; import java.util.Set; import java.util.concurrent.Future; @@ -52,10 +53,18 @@ public static DataStoreFactory specificDataStore(final DataStore store) { public static DataStore initedDataStore() { DataStore store = new InMemoryDataStore(); - store.init(Collections., Map>emptyMap()); + store.init(new FullDataSet(null)); return store; } + public static void upsertFlag(DataStore store, FeatureFlag flag) { + store.upsert(DataModel.DataKinds.FEATURES, flag.getKey(), new ItemDescriptor(flag.getVersion(), flag)); + } + + public static void upsertSegment(DataStore store, Segment segment) { + store.upsert(DataModel.DataKinds.SEGMENTS, segment.getKey(), new ItemDescriptor(segment.getVersion(), segment)); + } + public static EventProcessorFactory specificEventProcessor(final EventProcessor ep) { return context -> ep; } @@ -64,7 +73,7 @@ public static DataSourceFactory specificDataSource(final DataSource up) { return (context, dataStoreUpdates) -> up; } - public static DataSourceFactory dataSourceWithData(final Map, Map> data) { + public static DataSourceFactory dataSourceWithData(final FullDataSet data) { return (ClientContext context, final DataStoreUpdates dataStoreUpdates) -> { return new DataSource() { public Future start() { @@ -88,26 +97,25 @@ public static DataStore dataStoreThatThrowsException(final RuntimeException e) { public void close() throws IOException { } @Override - public T get(VersionedDataKind kind, String key) { + public ItemDescriptor get(DataKind kind, String key) { throw e; } @Override - public Map all(VersionedDataKind kind) { + public KeyedItems getAll(DataKind kind) { throw e; } @Override - public void init(Map, Map> allData) { } - - @Override - public void delete(VersionedDataKind kind, String key, int version) { } + public void init(FullDataSet allData) { } @Override - public void upsert(VersionedDataKind kind, T item) { } + public boolean upsert(DataKind kind, String key, ItemDescriptor item) { + return true; + } @Override - public boolean initialized() { + public boolean isInitialized() { return true; } }; @@ -146,34 +154,6 @@ public void sendEvent(Event e) { public void flush() {} } - 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; - } - - // 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 Evaluator.EvalResult simpleEvaluation(int variation, LDValue value) { return new Evaluator.EvalResult(value, variation, EvaluationReason.fallthrough()); } diff --git a/src/test/java/com/launchdarkly/client/integrations/DataLoaderTest.java b/src/test/java/com/launchdarkly/client/integrations/DataLoaderTest.java index 8e69bbb13..9105f4eaa 100644 --- a/src/test/java/com/launchdarkly/client/integrations/DataLoaderTest.java +++ b/src/test/java/com/launchdarkly/client/integrations/DataLoaderTest.java @@ -7,8 +7,8 @@ import com.launchdarkly.client.integrations.FileDataSourceImpl.DataBuilder; import com.launchdarkly.client.integrations.FileDataSourceImpl.DataLoader; import com.launchdarkly.client.integrations.FileDataSourceParsing.FileDataException; -import com.launchdarkly.client.interfaces.VersionedData; -import com.launchdarkly.client.interfaces.VersionedDataKind; +import com.launchdarkly.client.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.client.interfaces.DataStoreTypes.ItemDescriptor; import org.junit.Assert; import org.junit.Test; @@ -17,6 +17,7 @@ import static com.launchdarkly.client.DataModel.DataKinds.FEATURES; import static com.launchdarkly.client.DataModel.DataKinds.SEGMENTS; +import static com.launchdarkly.client.DataStoreTestTypes.toDataMap; 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; @@ -59,8 +60,8 @@ public void flagValueIsConvertedToFlag() throws Exception { "\"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(); + ItemDescriptor flag = toDataMap(builder.build()).get(FEATURES).get(FLAG_VALUE_1_KEY); + JsonObject actual = gson.toJsonTree(flag.getItem()).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()) { @@ -101,10 +102,10 @@ public void duplicateSegmentKeyThrowsException() throws Exception { } } - private void assertDataHasItemsOfKind(VersionedDataKind kind) { - Map items = builder.build().get(kind); + private void assertDataHasItemsOfKind(DataKind kind) { + Map items = toDataMap(builder.build()).get(kind); if (items == null || items.size() == 0) { - Assert.fail("expected at least one item in \"" + kind.getNamespace() + "\", received: " + builder.build()); + Assert.fail("expected at least one item in \"" + kind.getName() + "\", received: " + builder.build()); } } } diff --git a/src/test/java/com/launchdarkly/client/integrations/FileDataSourceTest.java b/src/test/java/com/launchdarkly/client/integrations/FileDataSourceTest.java index 931407a0c..ebff9f21f 100644 --- a/src/test/java/com/launchdarkly/client/integrations/FileDataSourceTest.java +++ b/src/test/java/com/launchdarkly/client/integrations/FileDataSourceTest.java @@ -13,8 +13,10 @@ import java.nio.file.Paths; import java.util.concurrent.Future; +import static com.google.common.collect.Iterables.size; import static com.launchdarkly.client.DataModel.DataKinds.FEATURES; import static com.launchdarkly.client.DataModel.DataKinds.SEGMENTS; +import static com.launchdarkly.client.DataStoreTestTypes.toItemsMap; import static com.launchdarkly.client.TestUtil.clientContext; import static com.launchdarkly.client.TestUtil.dataStoreUpdates; import static com.launchdarkly.client.integrations.FileDataSourceTestData.ALL_FLAG_KEYS; @@ -48,9 +50,9 @@ private DataSource makeDataSource(FileDataSourceBuilder builder) { @Test public void flagsAreNotLoadedUntilStart() throws Exception { try (DataSource fp = makeDataSource(factory)) { - assertThat(store.initialized(), equalTo(false)); - assertThat(store.all(FEATURES).size(), equalTo(0)); - assertThat(store.all(SEGMENTS).size(), equalTo(0)); + assertThat(store.isInitialized(), equalTo(false)); + assertThat(size(store.getAll(FEATURES).getItems()), equalTo(0)); + assertThat(size(store.getAll(SEGMENTS).getItems()), equalTo(0)); } } @@ -58,9 +60,9 @@ public void flagsAreNotLoadedUntilStart() throws Exception { public void flagsAreLoadedOnStart() throws Exception { try (DataSource fp = makeDataSource(factory)) { fp.start(); - assertThat(store.initialized(), equalTo(true)); - assertThat(store.all(FEATURES).keySet(), equalTo(ALL_FLAG_KEYS)); - assertThat(store.all(SEGMENTS).keySet(), equalTo(ALL_SEGMENT_KEYS)); + assertThat(store.isInitialized(), equalTo(true)); + assertThat(toItemsMap(store.getAll(FEATURES)).keySet(), equalTo(ALL_FLAG_KEYS)); + assertThat(toItemsMap(store.getAll(SEGMENTS)).keySet(), equalTo(ALL_SEGMENT_KEYS)); } } @@ -108,8 +110,8 @@ public void modifiedFileIsNotReloadedIfAutoUpdateIsOff() throws Exception { fp.start(); setFileContents(file, getResourceContents("segment-only.json")); Thread.sleep(400); - assertThat(store.all(FEATURES).size(), equalTo(1)); - assertThat(store.all(SEGMENTS).size(), equalTo(0)); + assertThat(toItemsMap(store.getAll(FEATURES)).size(), equalTo(1)); + assertThat(toItemsMap(store.getAll(SEGMENTS)).size(), equalTo(0)); } } finally { file.delete(); @@ -132,7 +134,7 @@ public void modifiedFileIsReloadedIfAutoUpdateIsOn() throws Exception { setFileContents(file, getResourceContents("all-properties.json")); // this file has all the flags long deadline = System.currentTimeMillis() + maxMsToWait; while (System.currentTimeMillis() < deadline) { - if (store.all(FEATURES).size() == ALL_FLAG_KEYS.size()) { + if (toItemsMap(store.getAll(FEATURES)).size() == ALL_FLAG_KEYS.size()) { // success return; } @@ -158,7 +160,7 @@ public void ifFilesAreBadAtStartTimeAutoUpdateCanStillLoadGoodDataLater() throws setFileContents(file, getResourceContents("flag-only.json")); // this file has 1 flag long deadline = System.currentTimeMillis() + maxMsToWait; while (System.currentTimeMillis() < deadline) { - if (store.all(FEATURES).size() > 0) { + if (toItemsMap(store.getAll(FEATURES)).size() > 0) { // success return; } diff --git a/src/test/java/com/launchdarkly/client/integrations/PersistentDataStoreTestBase.java b/src/test/java/com/launchdarkly/client/integrations/PersistentDataStoreTestBase.java index f65aacdcc..6b844dfd9 100644 --- a/src/test/java/com/launchdarkly/client/integrations/PersistentDataStoreTestBase.java +++ b/src/test/java/com/launchdarkly/client/integrations/PersistentDataStoreTestBase.java @@ -1,10 +1,11 @@ package com.launchdarkly.client.integrations; +import com.launchdarkly.client.DataStoreTestTypes.DataBuilder; import com.launchdarkly.client.DataStoreTestTypes.TestItem; -import com.launchdarkly.client.TestUtil.DataBuilder; +import com.launchdarkly.client.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.client.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.client.interfaces.DataStoreTypes.SerializedItemDescriptor; import com.launchdarkly.client.interfaces.PersistentDataStore; -import com.launchdarkly.client.interfaces.VersionedData; -import com.launchdarkly.client.interfaces.VersionedDataKind; import org.junit.After; import org.junit.Assume; @@ -15,6 +16,7 @@ import static com.launchdarkly.client.DataStoreTestTypes.OTHER_TEST_ITEMS; import static com.launchdarkly.client.DataStoreTestTypes.TEST_ITEMS; +import static com.launchdarkly.client.DataStoreTestTypes.toItemsMap; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; @@ -61,6 +63,26 @@ protected boolean setUpdateHook(T storeUnderTest, Runnable hook) { return false; } + private void assertEqualsSerializedItem(TestItem item, SerializedItemDescriptor serializedItemDesc) { + // This allows for the fact that a PersistentDataStore may not be able to get the item version without + // deserializing it, so we allow the version to be zero. + assertEquals(item.toSerializedItemDescriptor().getSerializedItem(), serializedItemDesc.getSerializedItem()); + if (serializedItemDesc.getVersion() != 0) { + assertEquals(item.version, serializedItemDesc.getVersion()); + } + } + + private void assertEqualsDeletedItem(SerializedItemDescriptor expected, SerializedItemDescriptor serializedItemDesc) { + // As above, the PersistentDataStore may not have separate access to the version and deleted state; + // PersistentDataStoreWrapper compensates for this when it deserializes the item. + if (serializedItemDesc.getSerializedItem() == null) { + assertEquals(expected, serializedItemDesc); + } else { + ItemDescriptor itemDesc = TEST_ITEMS.deserialize(serializedItemDesc.getSerializedItem()); + assertEquals(ItemDescriptor.deletedItem(expected.getVersion()), itemDesc); + } + } + @Before public void setup() { store = makeStore(); @@ -74,30 +96,30 @@ public void teardown() throws Exception { @Test public void storeNotInitializedBeforeInit() { clearAllData(); - assertFalse(store.initializedInternal()); + assertFalse(store.isInitialized()); } @Test public void storeInitializedAfterInit() { - store.initInternal(new DataBuilder().buildUnchecked()); - assertTrue(store.initializedInternal()); + store.init(new DataBuilder().buildSerialized()); + assertTrue(store.isInitialized()); } @Test public void initCompletelyReplacesPreviousData() { clearAllData(); - Map, Map> allData = - new DataBuilder().add(TEST_ITEMS, item1, item2).add(OTHER_TEST_ITEMS, otherItem1).buildUnchecked(); - store.initInternal(allData); + FullDataSet allData = + new DataBuilder().add(TEST_ITEMS, item1, item2).add(OTHER_TEST_ITEMS, otherItem1).buildSerialized(); + store.init(allData); TestItem item2v2 = item2.withVersion(item2.version + 1); - allData = new DataBuilder().add(TEST_ITEMS, item2v2).add(OTHER_TEST_ITEMS).buildUnchecked(); - store.initInternal(allData); + allData = new DataBuilder().add(TEST_ITEMS, item2v2).add(OTHER_TEST_ITEMS).buildSerialized(); + store.init(allData); - assertNull(store.getInternal(TEST_ITEMS, item1.key)); - assertEquals(item2v2, store.getInternal(TEST_ITEMS, item2.key)); - assertNull(store.getInternal(OTHER_TEST_ITEMS, otherItem1.key)); + assertNull(store.get(TEST_ITEMS, item1.key)); + assertEqualsSerializedItem(item2v2, store.get(TEST_ITEMS, item2.key)); + assertNull(store.get(OTHER_TEST_ITEMS, otherItem1.key)); } @Test @@ -105,11 +127,11 @@ public void oneInstanceCanDetectIfAnotherInstanceHasInitializedTheStore() { clearAllData(); T store2 = makeStore(); - assertFalse(store.initializedInternal()); + assertFalse(store.isInitialized()); - store2.initInternal(new DataBuilder().add(TEST_ITEMS, item1).buildUnchecked()); + store2.init(new DataBuilder().add(TEST_ITEMS, item1).buildSerialized()); - assertTrue(store.initializedInternal()); + assertTrue(store.isInitialized()); } @Test @@ -117,100 +139,100 @@ public void oneInstanceCanDetectIfAnotherInstanceHasInitializedTheStoreEvenIfEmp clearAllData(); T store2 = makeStore(); - assertFalse(store.initializedInternal()); + assertFalse(store.isInitialized()); - store2.initInternal(new DataBuilder().buildUnchecked()); + store2.init(new DataBuilder().buildSerialized()); - assertTrue(store.initializedInternal()); + assertTrue(store.isInitialized()); } @Test public void getExistingItem() { - store.initInternal(new DataBuilder().add(TEST_ITEMS, item1, item2).buildUnchecked()); - assertEquals(item1, store.getInternal(TEST_ITEMS, item1.key)); + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).buildSerialized()); + assertEqualsSerializedItem(item1, store.get(TEST_ITEMS, item1.key)); } @Test public void getNonexistingItem() { - store.initInternal(new DataBuilder().add(TEST_ITEMS, item1, item2).buildUnchecked()); - assertNull(store.getInternal(TEST_ITEMS, "biz")); + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).buildSerialized()); + assertNull(store.get(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); + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).add(OTHER_TEST_ITEMS, otherItem1).buildSerialized()); + Map items = toItemsMap(store.getAll(TEST_ITEMS)); assertEquals(2, items.size()); - assertEquals(item1, items.get(item1.key)); - assertEquals(item2, items.get(item2.key)); + assertEqualsSerializedItem(item1, items.get(item1.key)); + assertEqualsSerializedItem(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); + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).buildSerialized()); + SerializedItemDescriptor deletedItem = SerializedItemDescriptor.deletedItem(item1.version + 1); + store.upsert(TEST_ITEMS, item1.key, deletedItem); + Map items = toItemsMap(store.getAll(TEST_ITEMS)); assertEquals(2, items.size()); - assertEquals(item2, items.get(item2.key)); - assertEquals(deletedItem, items.get(item1.key)); + assertEqualsSerializedItem(item2, items.get(item2.key)); + assertEqualsDeletedItem(deletedItem, items.get(item1.key)); } @Test public void upsertWithNewerVersion() { - store.initInternal(new DataBuilder().add(TEST_ITEMS, item1, item2).buildUnchecked()); + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).buildSerialized()); TestItem newVer = item1.withVersion(item1.version + 1).withName("modified"); - store.upsertInternal(TEST_ITEMS, newVer); - assertEquals(newVer, store.getInternal(TEST_ITEMS, item1.key)); + store.upsert(TEST_ITEMS, item1.key, newVer.toSerializedItemDescriptor()); + assertEqualsSerializedItem(newVer, store.get(TEST_ITEMS, item1.key)); } @Test public void upsertWithOlderVersion() { - store.initInternal(new DataBuilder().add(TEST_ITEMS, item1, item2).buildUnchecked()); + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).buildSerialized()); TestItem oldVer = item1.withVersion(item1.version - 1).withName("modified"); - store.upsertInternal(TEST_ITEMS, oldVer); - assertEquals(item1, store.getInternal(TEST_ITEMS, oldVer.key)); + store.upsert(TEST_ITEMS, item1.key, oldVer.toSerializedItemDescriptor()); + assertEqualsSerializedItem(item1, store.get(TEST_ITEMS, oldVer.key)); } @Test public void upsertNewItem() { - store.initInternal(new DataBuilder().add(TEST_ITEMS, item1, item2).buildUnchecked()); + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).buildSerialized()); TestItem newItem = new TestItem("new-name", "new-key", 99); - store.upsertInternal(TEST_ITEMS, newItem); - assertEquals(newItem, store.getInternal(TEST_ITEMS, newItem.key)); + store.upsert(TEST_ITEMS, newItem.key, newItem.toSerializedItemDescriptor()); + assertEqualsSerializedItem(newItem, store.get(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)); + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).buildSerialized()); + SerializedItemDescriptor deletedItem = SerializedItemDescriptor.deletedItem(item1.version + 1); + store.upsert(TEST_ITEMS, item1.key, deletedItem); + assertEqualsDeletedItem(deletedItem, store.get(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)); + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).buildSerialized()); + SerializedItemDescriptor deletedItem = SerializedItemDescriptor.deletedItem(item1.version - 1); + store.upsert(TEST_ITEMS, item1.key, deletedItem); + assertEqualsSerializedItem(item1, store.get(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)); + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).buildSerialized()); + SerializedItemDescriptor deletedItem = SerializedItemDescriptor.deletedItem(11); + store.upsert(TEST_ITEMS, "deleted-key", deletedItem); + assertEqualsDeletedItem(deletedItem, store.get(TEST_ITEMS, "deleted-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)); + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).buildSerialized()); + SerializedItemDescriptor deletedItem = SerializedItemDescriptor.deletedItem(item1.version + 1); + store.upsert(TEST_ITEMS, item1.key, deletedItem); + store.upsert(TEST_ITEMS, item1.key, item1.toSerializedItemDescriptor()); + assertEqualsDeletedItem(deletedItem, store.get(TEST_ITEMS, item1.key)); } // The following two tests verify that the update version checking logic works correctly when @@ -232,7 +254,7 @@ public void handlesUpsertRaceConditionAgainstExternalClientWithLowerVersion() th int versionCounter = store2VersionStart; public void run() { if (versionCounter <= store2VersionEnd) { - store2.upsertInternal(TEST_ITEMS, startItem.withVersion(versionCounter)); + store2.upsert(TEST_ITEMS, startItem.key, startItem.withVersion(versionCounter).toSerializedItemDescriptor()); versionCounter++; } } @@ -241,13 +263,13 @@ public void run() { try { assumeTrue(setUpdateHook(store, concurrentModifier)); - store.initInternal(new DataBuilder().add(TEST_ITEMS, startItem).buildUnchecked()); + store.init(new DataBuilder().add(TEST_ITEMS, startItem).buildSerialized()); TestItem store1End = startItem.withVersion(store1VersionEnd); - store.upsertInternal(TEST_ITEMS, store1End); + store.upsert(TEST_ITEMS, startItem.key, store1End.toSerializedItemDescriptor()); - VersionedData result = store.getInternal(TEST_ITEMS, startItem.key); - assertEquals(store1VersionEnd, result.getVersion()); + SerializedItemDescriptor result = store.get(TEST_ITEMS, startItem.key); + assertEqualsSerializedItem(startItem.withVersion(store1VersionEnd), result); } finally { store2.close(); } @@ -265,20 +287,20 @@ public void handlesUpsertRaceConditionAgainstExternalClientWithHigherVersion() t Runnable concurrentModifier = new Runnable() { public void run() { - store2.upsertInternal(TEST_ITEMS, startItem.withVersion(store2Version)); + store2.upsert(TEST_ITEMS, startItem.key, startItem.withVersion(store2Version).toSerializedItemDescriptor()); } }; try { assumeTrue(setUpdateHook(store, concurrentModifier)); - store.initInternal(new DataBuilder().add(TEST_ITEMS, startItem).buildUnchecked()); + store.init(new DataBuilder().add(TEST_ITEMS, startItem).buildSerialized()); TestItem store1End = startItem.withVersion(store1VersionEnd); - store.upsertInternal(TEST_ITEMS, store1End); + store.upsert(TEST_ITEMS, startItem.key, store1End.toSerializedItemDescriptor()); - VersionedData result = store.getInternal(TEST_ITEMS, startItem.key); - assertEquals(store2Version, result.getVersion()); + SerializedItemDescriptor result = store.get(TEST_ITEMS, startItem.key); + assertEqualsSerializedItem(startItem.withVersion(store2Version), result); } finally { store2.close(); } @@ -292,35 +314,35 @@ public void storesWithDifferentPrefixAreIndependent() throws Exception { clearAllData(); try { - assertFalse(store1.initializedInternal()); - assertFalse(store2.initializedInternal()); + assertFalse(store1.isInitialized()); + assertFalse(store2.isInitialized()); 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()); + store1.init(new DataBuilder().add(TEST_ITEMS, item1a, item1b).buildSerialized()); + assertTrue(store1.isInitialized()); + assertFalse(store2.isInitialized()); - store2.initInternal(new DataBuilder().add(TEST_ITEMS, item2a, item2c).buildUnchecked()); - assertTrue(store1.initializedInternal()); - assertTrue(store2.initializedInternal()); + store2.init(new DataBuilder().add(TEST_ITEMS, item2a, item2c).buildSerialized()); + assertTrue(store1.isInitialized()); + assertTrue(store2.isInitialized()); - Map items1 = store1.getAllInternal(TEST_ITEMS); - Map items2 = store2.getAllInternal(TEST_ITEMS); + Map items1 = toItemsMap(store1.getAll(TEST_ITEMS)); + Map items2 = toItemsMap(store2.getAll(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)); + assertEqualsSerializedItem(item1a, items1.get(item1a.key)); + assertEqualsSerializedItem(item1b, items1.get(item1b.key)); + assertEqualsSerializedItem(item2a, items2.get(item2a.key)); + assertEqualsSerializedItem(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)); + assertEqualsSerializedItem(item1a, store1.get(TEST_ITEMS, item1a.key)); + assertEqualsSerializedItem(item1b, store1.get(TEST_ITEMS, item1b.key)); + assertEqualsSerializedItem(item2a, store2.get(TEST_ITEMS, item2a.key)); + assertEqualsSerializedItem(item2c, store2.get(TEST_ITEMS, item2c.key)); } finally { store1.close(); store2.close(); diff --git a/src/test/java/com/launchdarkly/client/integrations/PersistentDataStoreWrapperTest.java b/src/test/java/com/launchdarkly/client/integrations/PersistentDataStoreWrapperTest.java new file mode 100644 index 000000000..4abb31dae --- /dev/null +++ b/src/test/java/com/launchdarkly/client/integrations/PersistentDataStoreWrapperTest.java @@ -0,0 +1,611 @@ +package com.launchdarkly.client.integrations; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.launchdarkly.client.DataStoreTestTypes.DataBuilder; +import com.launchdarkly.client.DataStoreTestTypes.TestItem; +import com.launchdarkly.client.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.client.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.client.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.client.interfaces.DataStoreTypes.KeyedItems; +import com.launchdarkly.client.interfaces.DataStoreTypes.SerializedItemDescriptor; +import com.launchdarkly.client.interfaces.PersistentDataStore; + +import org.junit.Assert; +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.time.Duration; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; + +import static com.launchdarkly.client.DataStoreTestTypes.TEST_ITEMS; +import static com.launchdarkly.client.DataStoreTestTypes.toDataMap; +import static com.launchdarkly.client.DataStoreTestTypes.toItemsMap; +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.Assert.fail; +import static org.junit.Assume.assumeThat; + +@SuppressWarnings("javadoc") +@RunWith(Parameterized.class) +public class PersistentDataStoreWrapperTest { + + private final RuntimeException FAKE_ERROR = new RuntimeException("fake error"); + + private final TestMode testMode; + private final MockCore core; + private final PersistentDataStoreWrapper wrapper; + + static class TestMode { + final boolean cached; + final boolean cachedIndefinitely; + final boolean schemaCompatibilityMode; + + TestMode(boolean cached, boolean cachedIndefinitely, boolean schemaCompatibilityMode) { + this.cached = cached; + this.cachedIndefinitely = cachedIndefinitely; + this.schemaCompatibilityMode = schemaCompatibilityMode; + } + + boolean isCached() { + return cached; + } + + boolean isCachedWithFiniteTtl() { + return cached && !cachedIndefinitely; + } + + boolean isCachedIndefinitely() { + return cached && cachedIndefinitely; + } + + Duration getCacheTtl() { + return cached ? (cachedIndefinitely ? Duration.ofMillis(-1) : Duration.ofSeconds(30)) : Duration.ZERO; + } + + @Override + public String toString() { + return "TestMode(" + + (cached ? (cachedIndefinitely ? "CachedIndefinitely" : "Cached") : "Uncached") + + (schemaCompatibilityMode ? ",schemaCompatibility" : "") + ")"; + } + } + + @Parameters(name="cached={0}") + public static Iterable data() { + return ImmutableList.of( + new TestMode(true, false, false), + new TestMode(true, false, true), + new TestMode(true, true, false), + new TestMode(true, true, true), + new TestMode(false, false, false), + new TestMode(false, false, true) + ); + } + + public PersistentDataStoreWrapperTest(TestMode testMode) { + this.testMode = testMode; + this.core = new MockCore(); + this.core.schemaCompatibilityMode = testMode.schemaCompatibilityMode; + this.wrapper = new PersistentDataStoreWrapper(core, testMode.getCacheTtl(), + PersistentDataStoreBuilder.StaleValuesPolicy.EVICT, null); + } + + @Test + public void get() { + TestItem itemv1 = new TestItem("key", 1); + TestItem itemv2 = itemv1.withVersion(2); + + core.forceSet(TEST_ITEMS, itemv1); + assertThat(wrapper.get(TEST_ITEMS, itemv1.key), equalTo(itemv1.toItemDescriptor())); + + core.forceSet(TEST_ITEMS, itemv2); + + // if cached, we will not see the new underlying value yet + ItemDescriptor result = wrapper.get(TEST_ITEMS, itemv1.key); + ItemDescriptor expected = (testMode.isCached() ? itemv1 : itemv2).toItemDescriptor(); + assertThat(result, equalTo(expected)); + } + + @Test + public void getDeletedItem() { + String key = "key"; + + core.forceSet(TEST_ITEMS, key, SerializedItemDescriptor.deletedItem(1)); + assertThat(wrapper.get(TEST_ITEMS, key), equalTo(ItemDescriptor.deletedItem(1))); + + TestItem itemv2 = new TestItem(key, 2); + core.forceSet(TEST_ITEMS, itemv2); + + // if cached, we will not see the new underlying value yet + ItemDescriptor result = wrapper.get(TEST_ITEMS, key); + ItemDescriptor expected = testMode.isCached() ? ItemDescriptor.deletedItem(1) : itemv2.toItemDescriptor(); + assertThat(result, equalTo(expected)); + } + + @Test + public void getMissingItem() { + String key = "key"; + + assertThat(wrapper.get(TEST_ITEMS, key), nullValue()); + + TestItem item = new TestItem(key, 1); + core.forceSet(TEST_ITEMS, item); + + // if cached, the cache can retain a null result + ItemDescriptor result = wrapper.get(TEST_ITEMS, item.key); + assertThat(result, testMode.isCached() ? nullValue(ItemDescriptor.class) : equalTo(item.toItemDescriptor())); + } + + @Test + public void cachedGetUsesValuesFromInit() { + assumeThat(testMode.isCached(), is(true)); + + TestItem item1 = new TestItem("key1", 1); + TestItem item2 = new TestItem("key2", 1); + wrapper.init(new DataBuilder().add(TEST_ITEMS, item1, item2).build()); + + core.forceRemove(TEST_ITEMS, item1.key); + + assertThat(wrapper.get(TEST_ITEMS, item1.key), equalTo(item1.toItemDescriptor())); + } + + @Test + public void getAll() { + TestItem item1 = new TestItem("key1", 1); + TestItem item2 = new TestItem("key2", 1); + + core.forceSet(TEST_ITEMS, item1); + core.forceSet(TEST_ITEMS, item2); + Map items = toItemsMap(wrapper.getAll(TEST_ITEMS)); + Map expected = ImmutableMap.of( + item1.key, item1.toItemDescriptor(), item2.key, item2.toItemDescriptor()); + assertThat(items, equalTo(expected)); + + core.forceRemove(TEST_ITEMS, item2.key); + items = toItemsMap(wrapper.getAll(TEST_ITEMS)); + if (testMode.isCached()) { + assertThat(items, equalTo(expected)); + } else { + Map expected1 = ImmutableMap.of(item1.key, item1.toItemDescriptor()); + assertThat(items, equalTo(expected1)); + } + } + + @Test + public void getAllDoesNotRemoveDeletedItems() { + String key1 = "key1", key2 = "key2"; + TestItem item1 = new TestItem(key1, 1); + + core.forceSet(TEST_ITEMS, item1); + core.forceSet(TEST_ITEMS, key2, SerializedItemDescriptor.deletedItem(1)); + Map items = toItemsMap(wrapper.getAll(TEST_ITEMS)); + Map expected = ImmutableMap.of( + key1, item1.toItemDescriptor(), key2, ItemDescriptor.deletedItem(1)); + assertThat(items, equalTo(expected)); + } + + @Test + public void cachedAllUsesValuesFromInit() { + assumeThat(testMode.isCached(), is(true)); + + TestItem item1 = new TestItem("key1", 1); + TestItem item2 = new TestItem("key2", 1); + FullDataSet allData = new DataBuilder().add(TEST_ITEMS, item1, item2).build(); + wrapper.init(allData); + + core.forceRemove(TEST_ITEMS, item2.key); + + Map items = toItemsMap(wrapper.getAll(TEST_ITEMS)); + Map expected = toDataMap(allData).get(TEST_ITEMS); + assertThat(items, equalTo(expected)); + } + + @Test + public void cachedStoreWithFiniteTtlDoesNotUpdateCacheIfCoreInitFails() { + assumeThat(testMode.isCachedWithFiniteTtl(), is(true)); + + TestItem item = new TestItem("key", 1); + + core.fakeError = FAKE_ERROR; + try { + wrapper.init(new DataBuilder().add(TEST_ITEMS, item).build()); + fail("expected exception"); + } catch(RuntimeException e) { + assertThat(e, is(FAKE_ERROR)); + } + + core.fakeError = null; + assertThat(toItemsMap(wrapper.getAll(TEST_ITEMS)).size(), equalTo(0)); + } + + @Test + public void cachedStoreWithInfiniteTtlUpdatesCacheEvenIfCoreInitFails() { + assumeThat(testMode.isCachedIndefinitely(), is(true)); + + TestItem item = new TestItem("key", 1); + + core.fakeError = FAKE_ERROR; + try { + wrapper.init(new DataBuilder().add(TEST_ITEMS, item).build()); + fail("expected exception"); + } catch(RuntimeException e) { + assertThat(e, is(FAKE_ERROR)); + } + + core.fakeError = null; + Map expected = ImmutableMap.of(item.key, item.toItemDescriptor()); + assertThat(toItemsMap(wrapper.getAll(TEST_ITEMS)), equalTo(expected)); + } + + @Test + public void upsertSuccessful() { + TestItem itemv1 = new TestItem("key", 1); + TestItem itemv2 = itemv1.withVersion(2); + + wrapper.upsert(TEST_ITEMS, itemv1.key, itemv1.toItemDescriptor()); + assertThat(core.data.get(TEST_ITEMS).get(itemv1.key), equalTo(itemv1.toSerializedItemDescriptor())); + + wrapper.upsert(TEST_ITEMS, itemv1.key, itemv2.toItemDescriptor()); + assertThat(core.data.get(TEST_ITEMS).get(itemv1.key), equalTo(itemv2.toSerializedItemDescriptor())); + + // 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 (testMode.isCached()) { + TestItem itemv3 = itemv1.withVersion(3); + core.forceSet(TEST_ITEMS, itemv3); + } + + assertThat(wrapper.get(TEST_ITEMS, itemv1.key), equalTo(itemv2.toItemDescriptor())); + } + + @Test + public void cachedUpsertUnsuccessful() { + assumeThat(testMode.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 + // 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. + TestItem itemv1 = new TestItem("key", 1); + TestItem itemv2 = itemv1.withVersion(2); + + wrapper.upsert(TEST_ITEMS, itemv1.key, itemv2.toItemDescriptor()); + assertThat(core.data.get(TEST_ITEMS).get(itemv2.key), equalTo(itemv2.toSerializedItemDescriptor())); + + boolean success = wrapper.upsert(TEST_ITEMS, itemv1.key, itemv1.toItemDescriptor()); + assertThat(success, is(false)); + assertThat(core.data.get(TEST_ITEMS).get(itemv1.key), equalTo(itemv2.toSerializedItemDescriptor())); // value in store remains the same + + TestItem itemv3 = itemv1.withVersion(3); + core.forceSet(TEST_ITEMS, itemv3); // bypasses cache so we can verify that itemv2 is in the cache + + assertThat(wrapper.get(TEST_ITEMS, itemv1.key), equalTo(itemv2.toItemDescriptor())); + } + + @Test + public void cachedStoreWithFiniteTtlDoesNotUpdateCacheIfCoreUpdateFails() { + assumeThat(testMode.isCachedWithFiniteTtl(), is(true)); + + TestItem itemv1 = new TestItem("key", 1); + TestItem itemv2 = itemv1.withVersion(2); + + wrapper.init(new DataBuilder().add(TEST_ITEMS, itemv1).build()); + + core.fakeError = FAKE_ERROR; + try { + wrapper.upsert(TEST_ITEMS, itemv1.key, itemv2.toItemDescriptor()); + fail("expected exception"); + } catch(RuntimeException e) { + assertThat(e, is(FAKE_ERROR)); + } + core.fakeError = null; + + // cache still has old item, same as underlying store + assertThat(wrapper.get(TEST_ITEMS, itemv1.key), equalTo(itemv1.toItemDescriptor())); + } + + @Test + public void cachedStoreWithInfiniteTtlUpdatesCacheEvenIfCoreUpdateFails() { + assumeThat(testMode.isCachedIndefinitely(), is(true)); + + TestItem itemv1 = new TestItem("key", 1); + TestItem itemv2 = itemv1.withVersion(2); + + wrapper.init(new DataBuilder().add(TEST_ITEMS, itemv1).build()); + + core.fakeError = FAKE_ERROR; + try { + wrapper.upsert(TEST_ITEMS, itemv1.key, itemv2.toItemDescriptor()); + Assert.fail("expected exception"); + } catch(RuntimeException e) { + assertThat(e, is(FAKE_ERROR)); + } + core.fakeError = null; + + // underlying store has old item but cache has new item + assertThat(wrapper.get(TEST_ITEMS, itemv1.key), equalTo(itemv2.toItemDescriptor())); + } + + @Test + public void cachedStoreWithFiniteTtlRemovesCachedAllDataIfOneItemIsUpdated() { + assumeThat(testMode.isCachedWithFiniteTtl(), is(true)); + + TestItem item1v1 = new TestItem("key1", 1); + TestItem item1v2 = item1v1.withVersion(2); + TestItem item2v1 = new TestItem("key2", 1); + TestItem item2v2 = item2v1.withVersion(2); + + wrapper.init(new DataBuilder().add(TEST_ITEMS, item1v1, item2v1).build()); + wrapper.getAll(TEST_ITEMS); // now the All data is cached + + // do an upsert for item1 - this should drop the previous all() data from the cache + wrapper.upsert(TEST_ITEMS, item1v1.key, item1v2.toItemDescriptor()); + + // modify item2 directly in the underlying data + core.forceSet(TEST_ITEMS, item2v2); + + // now, all() should reread the underlying data so we see both changes + Map expected = ImmutableMap.of( + item1v1.key, item1v2.toItemDescriptor(), item2v1.key, item2v2.toItemDescriptor()); + assertThat(toItemsMap(wrapper.getAll(TEST_ITEMS)), equalTo(expected)); + } + + @Test + public void cachedStoreWithInfiniteTtlUpdatesCachedAllDataIfOneItemIsUpdated() { + assumeThat(testMode.isCachedIndefinitely(), is(true)); + + TestItem item1v1 = new TestItem("key1", 1); + TestItem item1v2 = item1v1.withVersion(2); + TestItem item2v1 = new TestItem("key2", 1); + TestItem item2v2 = item2v1.withVersion(2); + + wrapper.init(new DataBuilder().add(TEST_ITEMS, item1v1, item2v1).build()); + wrapper.getAll(TEST_ITEMS); // now the All data is cached + + // do an upsert for item1 - this should update the underlying data *and* the cached all() data + wrapper.upsert(TEST_ITEMS, item1v1.key, item1v2.toItemDescriptor()); + + // modify item2 directly in the underlying data + core.forceSet(TEST_ITEMS, item2v2); + + // now, all() should *not* reread the underlying data - we should only see the change to item1 + Map expected = ImmutableMap.of( + item1v1.key, item1v2.toItemDescriptor(), item2v1.key, item2v1.toItemDescriptor()); + assertThat(toItemsMap(wrapper.getAll(TEST_ITEMS)), equalTo(expected)); + } + + @Test + public void delete() { + TestItem itemv1 = new TestItem("key", 1); + + core.forceSet(TEST_ITEMS, itemv1); + assertThat(wrapper.get(TEST_ITEMS, itemv1.key), equalTo(itemv1.toItemDescriptor())); + + ItemDescriptor deletedItem = ItemDescriptor.deletedItem(2); + wrapper.upsert(TEST_ITEMS, itemv1.key, deletedItem); + + // in schema compatibility mode, it will store a special placeholder string, otherwise a null + SerializedItemDescriptor serializedDeletedItem = new SerializedItemDescriptor(deletedItem.getVersion(), + testMode.schemaCompatibilityMode ? TEST_ITEMS.serializeDeletedItemPlaceholder(deletedItem.getVersion()) : null); + assertThat(core.data.get(TEST_ITEMS).get(itemv1.key), equalTo(serializedDeletedItem)); + + // make a change that bypasses the cache + TestItem itemv3 = itemv1.withVersion(3); + core.forceSet(TEST_ITEMS, itemv3); + + ItemDescriptor result = wrapper.get(TEST_ITEMS, itemv1.key); + assertThat(result, equalTo(testMode.isCached() ? deletedItem : itemv3.toItemDescriptor())); + } + + @Test + public void initializedCallsInternalMethodOnlyIfNotAlreadyInited() { + assumeThat(testMode.isCached(), is(false)); + + assertThat(wrapper.isInitialized(), is(false)); + assertThat(core.initedQueryCount, equalTo(1)); + + core.inited = true; + assertThat(wrapper.isInitialized(), is(true)); + assertThat(core.initedQueryCount, equalTo(2)); + + core.inited = false; + assertThat(wrapper.isInitialized(), is(true)); + assertThat(core.initedQueryCount, equalTo(2)); + } + + @Test + public void initializedDoesNotCallInternalMethodAfterInitHasBeenCalled() { + assumeThat(testMode.isCached(), is(false)); + + assertThat(wrapper.isInitialized(), is(false)); + assertThat(core.initedQueryCount, equalTo(1)); + + wrapper.init(new DataBuilder().build()); + + assertThat(wrapper.isInitialized(), is(true)); + assertThat(core.initedQueryCount, equalTo(1)); + } + + @Test + public void initializedCanCacheFalseResult() throws Exception { + assumeThat(testMode.isCached(), is(true)); + + // We need to create a different object for this test so we can set a short cache TTL + try (PersistentDataStoreWrapper wrapper1 = new PersistentDataStoreWrapper(core, + Duration.ofMillis(500), PersistentDataStoreBuilder.StaleValuesPolicy.EVICT, null)) { + assertThat(wrapper1.isInitialized(), is(false)); + assertThat(core.initedQueryCount, equalTo(1)); + + core.inited = true; + assertThat(core.initedQueryCount, equalTo(1)); + + Thread.sleep(600); + + assertThat(wrapper1.isInitialized(), is(true)); + assertThat(core.initedQueryCount, equalTo(2)); + + // From this point on it should remain true and the method should not be called + assertThat(wrapper1.isInitialized(), is(true)); + assertThat(core.initedQueryCount, equalTo(2)); + } + } + + @Test + public void canGetCacheStats() throws Exception { + assumeThat(testMode.isCachedWithFiniteTtl(), is(true)); + + CacheMonitor cacheMonitor = new CacheMonitor(); + + try (PersistentDataStoreWrapper w = new PersistentDataStoreWrapper(core, + Duration.ofSeconds(30), PersistentDataStoreBuilder.StaleValuesPolicy.EVICT, cacheMonitor)) { + CacheMonitor.CacheStats stats = cacheMonitor.getCacheStats(); + + assertThat(stats, equalTo(new CacheMonitor.CacheStats(0, 0, 0, 0, 0, 0))); + + // Cause a cache miss + w.get(TEST_ITEMS, "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(TEST_ITEMS, new TestItem("key2", 1)); + w.get(TEST_ITEMS, "key2"); // this one is a cache miss, but causes the item to be loaded and cached + w.get(TEST_ITEMS, "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(TEST_ITEMS, "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)); + } + } + + static class MockCore implements PersistentDataStore { + Map> data = new HashMap<>(); + boolean inited; + int initedQueryCount; + boolean schemaCompatibilityMode; + RuntimeException fakeError; + + @Override + public void close() throws IOException { + } + + @Override + public SerializedItemDescriptor get(DataKind kind, String key) { + maybeThrow(); + if (data.containsKey(kind)) { + SerializedItemDescriptor item = data.get(kind).get(key); + if (item != null) { + if (schemaCompatibilityMode) { + return new SerializedItemDescriptor(0, item.getSerializedItem()); + } else { + return item; + } + } + } + return null; + } + + @Override + public KeyedItems getAll(DataKind kind) { + maybeThrow(); + return data.containsKey(kind) ? new KeyedItems<>(ImmutableList.copyOf(data.get(kind).entrySet())) : new KeyedItems<>(null); + } + + @Override + public void init(FullDataSet allData) { + maybeThrow(); + data.clear(); + for (Map.Entry> entry: allData.getData()) { + DataKind kind = entry.getKey(); + HashMap items = new LinkedHashMap<>(); + for (Map.Entry e: entry.getValue().getItems()) { + items.put(e.getKey(), storableItem(kind, e.getValue())); + } + data.put(kind, items); + } + inited = true; + } + + @Override + public boolean upsert(DataKind kind, String key, SerializedItemDescriptor item) { + maybeThrow(); + if (!data.containsKey(kind)) { + data.put(kind, new HashMap<>()); + } + Map items = data.get(kind); + SerializedItemDescriptor oldItem = items.get(key); + if (oldItem != null && oldItem.getVersion() >= item.getVersion()) { + return false; + } + items.put(key, storableItem(kind, item)); + return true; + } + + @Override + public boolean isInitialized() { + maybeThrow(); + initedQueryCount++; + return inited; + } + + public void forceSet(DataKind kind, TestItem item) { + forceSet(kind, item.key, item.toSerializedItemDescriptor()); + } + + public void forceSet(DataKind kind, String key, SerializedItemDescriptor item) { + if (!data.containsKey(kind)) { + data.put(kind, new HashMap<>()); + } + Map items = data.get(kind); + items.put(key, storableItem(kind, item)); + } + + public void forceRemove(DataKind kind, String key) { + if (data.containsKey(kind)) { + data.get(kind).remove(key); + } + } + + private SerializedItemDescriptor storableItem(DataKind kind, SerializedItemDescriptor item) { + // Here we simulate the behavior of older persistent store implementations where the store does not have + // the ability to track an item's deleted status itself, but must instead store a string that can be + // recognized by the DataKind as a placeholder. + if (item.getSerializedItem() == null && schemaCompatibilityMode) { + return new SerializedItemDescriptor(item.getVersion(), kind.serializeDeletedItemPlaceholder(item.getVersion())); + } + return item; + } + + private void maybeThrow() { + if (fakeError != null) { + throw fakeError; + } + } + } +} diff --git a/src/test/java/com/launchdarkly/client/integrations/RedisDataStoreTest.java b/src/test/java/com/launchdarkly/client/integrations/RedisDataStoreTest.java deleted file mode 100644 index b2f82b1d7..000000000 --- a/src/test/java/com/launchdarkly/client/integrations/RedisDataStoreTest.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.launchdarkly.client.integrations; - -import com.launchdarkly.client.Components; -import com.launchdarkly.client.DataStoreDatabaseTestBase; -import com.launchdarkly.client.integrations.RedisDataStoreImpl.UpdateListener; -import com.launchdarkly.client.interfaces.DataStore; -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 RedisDataStoreTest extends DataStoreDatabaseTestBase { - private static final URI REDIS_URI = URI.create("redis://localhost:6379"); - - public RedisDataStoreTest(boolean cached) { - super(cached); - } - - @BeforeClass - public static void maybeSkipDatabaseTests() { - String skipParam = System.getenv("LD_SKIP_DATABASE_TESTS"); - assumeTrue(skipParam == null || skipParam.equals("")); - } - - @Override - protected DataStore makeStore() { - RedisDataStoreBuilder redisBuilder = Redis.dataStore().uri(REDIS_URI); - PersistentDataStoreBuilder builder = Components.persistentDataStore(redisBuilder); - if (cached) { - builder.cacheSeconds(30); - } else { - builder.noCaching(); - } - return builder.createDataStore(null); - } - - @Override - protected DataStore makeStoreWithPrefix(String prefix) { - return Components.persistentDataStore( - Redis.dataStore().uri(REDIS_URI).prefix(prefix) - ).noCaching().createDataStore(null); - } - - @Override - protected void clearAllData() { - try (Jedis client = new Jedis("localhost")) { - client.flushDB(); - } - } - - @Override - protected boolean setUpdateHook(DataStore 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; - } -} diff --git a/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java b/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java deleted file mode 100644 index 50d440bbf..000000000 --- a/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java +++ /dev/null @@ -1,605 +0,0 @@ -package com.launchdarkly.client.utils; - -import com.google.common.collect.ImmutableMap; -import com.launchdarkly.client.DataStoreCacheConfig; -import com.launchdarkly.client.integrations.CacheMonitor; -import com.launchdarkly.client.interfaces.PersistentDataStore; -import com.launchdarkly.client.interfaces.VersionedData; -import com.launchdarkly.client.interfaces.VersionedDataKind; - -import org.junit.Assert; -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.LinkedHashMap; -import java.util.Map; - -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.Assert.fail; -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 MockCore core; - private final CachingStoreWrapper wrapper; - - static enum CachingMode { - UNCACHED, - CACHED_WITH_FINITE_TTL, - CACHED_INDEFINITELY; - - DataStoreCacheConfig toCacheConfig() { - switch (this) { - case CACHED_WITH_FINITE_TTL: - return DataStoreCacheConfig.enabled().ttlSeconds(30); - case CACHED_INDEFINITELY: - return DataStoreCacheConfig.enabled().ttlSeconds(-1); - default: - return DataStoreCacheConfig.disabled(); - } - } - - boolean isCached() { - return this != UNCACHED; - } - }; - - @Parameters(name="cached={0}") - public static Iterable data() { - return Arrays.asList(CachingMode.values()); - } - - public CachingStoreWrapperTest(CachingMode cachingMode) { - this.cachingMode = cachingMode; - this.core = new MockCore(); - this.wrapper = new CachingStoreWrapper(core, cachingMode.toCacheConfig(), null); - } - - @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(cachingMode.isCached() ? 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, cachingMode.isCached() ? 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, cachingMode.isCached() ? nullValue(MockItem.class) : equalTo(item)); // the cache can retain a null result - } - - @Test - public void cachedGetUsesValuesFromInit() { - assumeThat(cachingMode.isCached(), is(true)); - - 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, item1.key); - - assertThat(wrapper.get(THINGS, item1.key), equalTo(item1)); - } - - @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 (cachingMode.isCached()) { - assertThat(items, equalTo(expected)); - } else { - Map expected1 = ImmutableMap.of(item1.key, item1); - 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() { - assumeThat(cachingMode.isCached(), is(true)); - - 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 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)); - } - - core.fakeError = null; - assertThat(wrapper.all(THINGS).size(), equalTo(0)); - } - - @Test - public void cachedStoreWithInfiniteTtlUpdatesCacheEvenIfCoreInitFails() { - assumeThat(cachingMode, is(CachingMode.CACHED_INDEFINITELY)); - - 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)); - } - - 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); - 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 (cachingMode.isCached()) { - MockItem item1v3 = new MockItem("flag", 3, false); - core.forceSet(THINGS, item1v3); - } - - assertThat(wrapper.get(THINGS, itemv1.key), equalTo(itemv2)); - } - - @Test - public void cachedUpsertUnsuccessful() { - 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 - // 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 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); - 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, cachingMode.isCached() ? nullValue(MockItem.class) : equalTo(itemv3)); - } - - @Test - public void initializedCallsInternalMethodOnlyIfNotAlreadyInited() { - assumeThat(cachingMode.isCached(), 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(cachingMode.isCached(), 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(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, DataStoreCacheConfig.enabled().ttlMillis(500), null)) { - 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)); - } - } - - @Test - public void canGetCacheStats() throws Exception { - assumeThat(cachingMode, is(CachingMode.CACHED_WITH_FINITE_TTL)); - - CacheMonitor cacheMonitor = new CacheMonitor(); - - try (CachingStoreWrapper w = new CachingStoreWrapper(core, DataStoreCacheConfig.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) { - innerMap.put(item.getKey(), item); - } - Map, Map> outerMap = new HashMap<>(); - outerMap.put(THINGS, innerMap); - return outerMap; - } - - static class MockCore implements PersistentDataStore { - Map, Map> data = new HashMap<>(); - boolean inited; - int initedQueryCount; - RuntimeException fakeError; - - @Override - public void close() throws IOException { - } - - @Override - public VersionedData getInternal(VersionedDataKind kind, String key) { - maybeThrow(); - if (data.containsKey(kind)) { - return data.get(kind).get(key); - } - return null; - } - - @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())); - } - inited = true; - } - - @Override - public VersionedData upsertInternal(VersionedDataKind kind, VersionedData item) { - maybeThrow(); - if (!data.containsKey(kind)) { - data.put(kind, new HashMap()); - } - Map items = data.get(kind); - VersionedData oldItem = items.get(item.getKey()); - if (oldItem != null && oldItem.getVersion() >= item.getVersion()) { - return oldItem; - } - items.put(item.getKey(), item); - return item; - } - - @Override - public boolean initializedInternal() { - maybeThrow(); - initedQueryCount++; - return inited; - } - - public void forceSet(VersionedDataKind kind, VersionedData item) { - if (!data.containsKey(kind)) { - data.put(kind, new HashMap()); - } - Map items = data.get(kind); - items.put(item.getKey(), item); - } - - public void forceRemove(VersionedDataKind kind, String key) { - if (data.containsKey(kind)) { - data.get(kind).remove(key); - } - } - - private void maybeThrow() { - if (fakeError != null) { - throw fakeError; - } - } - } - - 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); - } - - @Override - public MockItem deserialize(String serializedData) { - return null; - } - }; -} From 6665e25926a50956d719eaca05342bc8e7109d28 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 4 Mar 2020 00:18:49 -0800 Subject: [PATCH 316/673] fix flag dependency logic --- .../java/com/launchdarkly/client/DataStoreDataSetSorter.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/client/DataStoreDataSetSorter.java b/src/main/java/com/launchdarkly/client/DataStoreDataSetSorter.java index e28f35a97..aef7218d5 100644 --- a/src/main/java/com/launchdarkly/client/DataStoreDataSetSorter.java +++ b/src/main/java/com/launchdarkly/client/DataStoreDataSetSorter.java @@ -71,7 +71,7 @@ private static void addWithDependenciesFirst(DataKind kind, Map remainingItems, ImmutableMap.Builder builder) { remainingItems.remove(key); // we won't need to visit this item again - for (String prereqKey: getDependencyKeys(kind, item)) { + for (String prereqKey: getDependencyKeys(kind, item.getItem())) { ItemDescriptor prereqItem = remainingItems.get(prereqKey); if (prereqItem != null) { addWithDependenciesFirst(kind, prereqKey, prereqItem, remainingItems, builder); @@ -85,6 +85,9 @@ private static boolean isDependencyOrdered(DataKind kind) { } private static Iterable getDependencyKeys(DataKind kind, Object item) { + if (item == null) { + return null; + } if (kind == DataModel.DataKinds.FEATURES) { DataModel.FeatureFlag flag = (DataModel.FeatureFlag)item; if (flag.getPrerequisites() == null || flag.getPrerequisites().isEmpty()) { From 94662ffd266aeac4c6975ae007f3e7a904ce4f5c Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 4 Mar 2020 00:26:48 -0800 Subject: [PATCH 317/673] more efficient transformatio --- .../client/DefaultFeatureRequestor.java | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java b/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java index aa286d936..6d4355920 100644 --- a/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java +++ b/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java @@ -1,11 +1,14 @@ package com.launchdarkly.client; import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Maps; import com.google.common.io.Files; import com.launchdarkly.client.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.client.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.client.interfaces.DataStoreTypes.KeyedItems; +import com.launchdarkly.client.interfaces.VersionedData; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -80,24 +83,23 @@ public AllData getAllData() throws IOException, HttpErrorException { } static FullDataSet toFullDataSet(AllData allData) { - ImmutableMap.Builder flagsBuilder = ImmutableMap.builder(); - ImmutableMap.Builder segmentsBuilder = ImmutableMap.builder(); - if (allData.flags != null) { - for (Map.Entry e: allData.flags.entrySet()) { - flagsBuilder.put(e.getKey(), new ItemDescriptor(e.getValue().getVersion(), e.getValue())); - } - } - if (allData.segments != null) { - for (Map.Entry e: allData.segments.entrySet()) { - segmentsBuilder.put(e.getKey(), new ItemDescriptor(e.getValue().getVersion(), e.getValue())); - } - } return new FullDataSet(ImmutableMap.of( - DataModel.DataKinds.FEATURES, new KeyedItems<>(flagsBuilder.build().entrySet()), - DataModel.DataKinds.SEGMENTS, new KeyedItems<>(segmentsBuilder.build().entrySet()) + DataModel.DataKinds.FEATURES, toKeyedItems(allData.flags), + DataModel.DataKinds.SEGMENTS, toKeyedItems(allData.segments) ).entrySet()); } + static KeyedItems toKeyedItems(Map itemsMap) { + if (itemsMap == null) { + return new KeyedItems<>(null); + } + return new KeyedItems<>( + ImmutableList.copyOf( + Maps.transformValues(itemsMap, item -> new ItemDescriptor(item.getVersion(), item)).entrySet() + ) + ); + } + private String get(String path) throws IOException, HttpErrorException { Request request = new Request.Builder() .url(baseUri.resolve(path).toURL()) From 85cc99703ac607f0602bf5390a24451ea2e2173f Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 4 Mar 2020 00:30:38 -0800 Subject: [PATCH 318/673] deleted items have already been filtered out at this point --- .../com/launchdarkly/client/LDClient.java | 26 +++++++------------ 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index 50bf4b9d4..13293cdc9 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -62,22 +62,14 @@ public LDClient(String sdkKey) { this(sdkKey, LDConfig.DEFAULT); } - private static final DataModel.FeatureFlag getFlagIfNotDeleted(DataStore store, String key) { + private static final DataModel.FeatureFlag getFlag(DataStore store, String key) { ItemDescriptor item = store.get(DataModel.DataKinds.FEATURES, key); - if (item == null) { - return null; - } - DataModel.FeatureFlag flag = (DataModel.FeatureFlag)item.getItem(); - return flag.isDeleted() ? null : flag; + return item == null ? null : (DataModel.FeatureFlag)item.getItem(); } - private static final DataModel.Segment getSegmentIfNotDeleted(DataStore store, String key) { + private static final DataModel.Segment getSegment(DataStore store, String key) { ItemDescriptor item = store.get(DataModel.DataKinds.SEGMENTS, key); - if (item == null) { - return null; - } - DataModel.Segment segment = (DataModel.Segment)item.getItem(); - return segment.isDeleted() ? null : segment; + return item == null ? null : (DataModel.Segment)item.getItem(); } /** @@ -108,11 +100,11 @@ public LDClient(String sdkKey, LDConfig config) { this.evaluator = new Evaluator(new Evaluator.Getters() { public DataModel.FeatureFlag getFlag(String key) { - return getFlagIfNotDeleted(LDClient.this.dataStore, key); + return LDClient.getFlag(LDClient.this.dataStore, key); } public DataModel.Segment getSegment(String key) { - return getSegmentIfNotDeleted(LDClient.this.dataStore, key); + return LDClient.getSegment(LDClient.this.dataStore, key); } }); @@ -208,7 +200,7 @@ public FeatureFlagsState allFlagsState(LDUser user, FlagsStateOption... options) KeyedItems flags = dataStore.getAll(DataModel.DataKinds.FEATURES); for (Map.Entry entry : flags.getItems()) { DataModel.FeatureFlag flag = (DataModel.FeatureFlag)entry.getValue().getItem(); - if (flag.isDeleted() || (clientSideOnly && !flag.isClientSide())) { + if (clientSideOnly && !flag.isClientSide()) { continue; } try { @@ -298,7 +290,7 @@ public boolean isFlagKnown(String featureKey) { } try { - if (getFlagIfNotDeleted(dataStore, featureKey) != null) { + if (getFlag(dataStore, featureKey) != null) { return true; } } catch (Exception e) { @@ -332,7 +324,7 @@ private Evaluator.EvalResult evaluateInternal(String featureKey, LDUser user, LD DataModel.FeatureFlag featureFlag = null; try { - featureFlag = getFlagIfNotDeleted(dataStore, featureKey); + featureFlag = getFlag(dataStore, featureKey); if (featureFlag == null) { logger.info("Unknown feature flag \"{}\"; returning default value", featureKey); sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue, From d04923f1dbac1d0dd82f8c27ed0126b847c0980e Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 4 Mar 2020 09:18:10 -0800 Subject: [PATCH 319/673] misc fixes --- src/main/java/com/launchdarkly/client/StreamProcessor.java | 2 +- .../client/integrations/FileDataSourceParsing.java | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/StreamProcessor.java b/src/main/java/com/launchdarkly/client/StreamProcessor.java index fd507b2c3..d663fb1b1 100644 --- a/src/main/java/com/launchdarkly/client/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/client/StreamProcessor.java @@ -245,7 +245,7 @@ public boolean isInitialized() { } private static String getKeyFromStreamApiPath(DataKind kind, String path) { - String prefix = (kind == SEGMENTS) ? "/segments/" : "/features/"; + String prefix = (kind == SEGMENTS) ? "/segments/" : "/flags/"; return path.startsWith(prefix) ? path.substring(prefix.length()) : null; } diff --git a/src/main/java/com/launchdarkly/client/integrations/FileDataSourceParsing.java b/src/main/java/com/launchdarkly/client/integrations/FileDataSourceParsing.java index a8c461e35..67e27f6be 100644 --- a/src/main/java/com/launchdarkly/client/integrations/FileDataSourceParsing.java +++ b/src/main/java/com/launchdarkly/client/integrations/FileDataSourceParsing.java @@ -202,8 +202,7 @@ static ItemDescriptor flagWithValue(String key, LDValue jsonValue) { .build(); // 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. - Object item = DataModel.DataKinds.FEATURES.deserialize(o.toJsonString()); - return new ItemDescriptor(1, item); + return DataModel.DataKinds.FEATURES.deserialize(o.toJsonString()); } static ItemDescriptor segmentFromJson(String jsonString) { From a52044b4d9052cccc19c19e1d069e9ac76acde7c Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 4 Mar 2020 09:23:36 -0800 Subject: [PATCH 320/673] javadoc fix --- .../com/launchdarkly/client/interfaces/PersistentDataStore.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/client/interfaces/PersistentDataStore.java b/src/main/java/com/launchdarkly/client/interfaces/PersistentDataStore.java index e5fbbc557..672a94421 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/PersistentDataStore.java +++ b/src/main/java/com/launchdarkly/client/interfaces/PersistentDataStore.java @@ -107,7 +107,7 @@ public interface PersistentDataStore extends Closeable { * Returns true if this store has been initialized. *

    * In a shared data store, the implementation should be able to detect this state even if - * {@link #init(FullDataSet)} was called in a different process, i.e. it must query the underlying + * {@link #init} was called in a different process, i.e. it must query the underlying * data store in some way. The method does not need to worry about caching this value; the SDK * will call it rarely. * From 9ca6a5ea947e6e93e327cb605cf384b4e3c5884a Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 4 Mar 2020 09:25:35 -0800 Subject: [PATCH 321/673] clarify comment --- .../launchdarkly/client/interfaces/PersistentDataStore.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/com/launchdarkly/client/interfaces/PersistentDataStore.java b/src/main/java/com/launchdarkly/client/interfaces/PersistentDataStore.java index 672a94421..7c28eb2f2 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/PersistentDataStore.java +++ b/src/main/java/com/launchdarkly/client/interfaces/PersistentDataStore.java @@ -40,6 +40,9 @@ *

  • When inserting or updating items, if the {@link SerializedItemDescriptor} contains * a null, pass its version number to {@link DataKind#serializeDeletedItemPlaceholder(int)} * and store the string that that method returns.
  • + *
  • When updating items, if it's necessary to check the version number of an existing + * item, pass its serialized string to {@link DataKind#deserialize(String)} and look at the + * version number in the returned {@link ItemDescriptor}.
  • * * * @since 5.0.0 From 923ec8c3ed3cc2e607b5fb5a5fc401c806ea3ca9 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 4 Mar 2020 15:29:03 -0800 Subject: [PATCH 322/673] simplify how we handle implementations like Redis that can't store metadata separately --- .../com/launchdarkly/client/DataModel.java | 18 ++-- .../PersistentDataStoreWrapper.java | 18 ++-- .../integrations/RedisDataStoreImpl.java | 10 ++- .../client/interfaces/DataStoreTypes.java | 85 ++++++++++--------- .../interfaces/PersistentDataStore.java | 79 +++++++++-------- .../client/DataStoreTestTypes.java | 23 +++-- .../PersistentDataStoreTestBase.java | 11 +-- .../PersistentDataStoreWrapperTest.java | 38 +++++---- 8 files changed, 146 insertions(+), 136 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/DataModel.java b/src/main/java/com/launchdarkly/client/DataModel.java index 999d79190..aa350fb60 100644 --- a/src/main/java/com/launchdarkly/client/DataModel.java +++ b/src/main/java/com/launchdarkly/client/DataModel.java @@ -27,29 +27,27 @@ public static abstract class DataKinds { */ public static DataKind FEATURES = new DataKind("features", DataKinds::serializeItem, - s -> deserializeItem(s, FeatureFlag.class), - DataKinds::serializeDeletedItemPlaceholder); + s -> deserializeItem(s, FeatureFlag.class)); /** * The {@link DataKind} instance that describes user segment data. */ public static DataKind SEGMENTS = new DataKind("segments", DataKinds::serializeItem, - s -> deserializeItem(s, Segment.class), - DataKinds::serializeDeletedItemPlaceholder); + s -> deserializeItem(s, Segment.class)); - private static String serializeItem(Object o) { - return JsonHelpers.gsonInstance().toJson(o); + private static String serializeItem(ItemDescriptor item) { + Object o = item.getItem(); + if (o != null) { + return JsonHelpers.gsonInstance().toJson(o); + } + return "{\"version\":" + item.getVersion() + ",\"deleted\":true}"; } private static ItemDescriptor deserializeItem(String s, Class itemClass) { VersionedData o = JsonHelpers.gsonInstance().fromJson(s, itemClass); return o.isDeleted() ? ItemDescriptor.deletedItem(o.getVersion()) : new ItemDescriptor(o.getVersion(), o); } - - private static String serializeDeletedItemPlaceholder(int version) { - return "{\"version\":" + version + ",\"deleted\":true}"; - } } // All of these inner data model classes should have package-private scope. They should have only property diff --git a/src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreWrapper.java b/src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreWrapper.java index 41d31a0c1..6db1ab594 100644 --- a/src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreWrapper.java +++ b/src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreWrapper.java @@ -333,19 +333,21 @@ private KeyedItems getAllAndDeserialize(DataKind kind) { } private SerializedItemDescriptor serialize(DataKind kind, ItemDescriptor itemDesc) { - Object item = itemDesc.getItem(); - return new SerializedItemDescriptor(itemDesc.getVersion(), item == null ? null : kind.serialize(item)); + boolean isDeleted = itemDesc.getItem() == null; + return new SerializedItemDescriptor(itemDesc.getVersion(), isDeleted, kind.serialize(itemDesc)); } private ItemDescriptor deserialize(DataKind kind, SerializedItemDescriptor serializedItemDesc) { - String serializedItem = serializedItemDesc.getSerializedItem(); - if (serializedItem == null) { + if (serializedItemDesc.isDeleted() || serializedItemDesc.getSerializedItem() == null) { return ItemDescriptor.deletedItem(serializedItemDesc.getVersion()); } - ItemDescriptor deserializedItem = kind.deserialize(serializedItem); - return (serializedItemDesc.getVersion() == 0 || serializedItemDesc.getVersion() == deserializedItem.getVersion()) - ? deserializedItem - : new ItemDescriptor(serializedItemDesc.getVersion(), deserializedItem.getItem()); + ItemDescriptor deserializedItem = kind.deserialize(serializedItemDesc.getSerializedItem()); + if (serializedItemDesc.getVersion() == 0 || serializedItemDesc.getVersion() == deserializedItem.getVersion() + || deserializedItem.getItem() == null) { + return deserializedItem; + } + // If the store gave us a version number that isn't what was encoded in the object, trust it + return new ItemDescriptor(serializedItemDesc.getVersion(), deserializedItem.getItem()); } private KeyedItems updateSingleItem(KeyedItems items, String key, ItemDescriptor item) { diff --git a/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreImpl.java b/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreImpl.java index 636c89ce9..a2f2e0d36 100644 --- a/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreImpl.java +++ b/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreImpl.java @@ -4,6 +4,7 @@ import com.google.common.collect.Maps; import com.launchdarkly.client.interfaces.DataStoreTypes.DataKind; import com.launchdarkly.client.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.client.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.client.interfaces.DataStoreTypes.KeyedItems; import com.launchdarkly.client.interfaces.DataStoreTypes.SerializedItemDescriptor; import com.launchdarkly.client.interfaces.PersistentDataStore; @@ -71,7 +72,7 @@ final class RedisDataStoreImpl implements PersistentDataStore { public SerializedItemDescriptor get(DataKind kind, String key) { try (Jedis jedis = pool.getResource()) { String item = getRedis(kind, key, jedis); - return item == null ? null : new SerializedItemDescriptor(0, item); + return item == null ? null : new SerializedItemDescriptor(0, false, item); } } @@ -80,7 +81,7 @@ public KeyedItems getAll(DataKind kind) { try (Jedis jedis = pool.getResource()) { Map allJson = jedis.hgetAll(itemsKey(kind)); return new KeyedItems<>( - Maps.transformValues(allJson, itemJson -> new SerializedItemDescriptor(0, itemJson)).entrySet() + Maps.transformValues(allJson, itemJson -> new SerializedItemDescriptor(0, false, itemJson)).entrySet() ); } } @@ -189,7 +190,10 @@ private static String jsonOrPlaceholder(DataKind kind, SerializedItemDescriptor if (s != null) { return s; } - return kind.serializeDeletedItemPlaceholder(serializedItem.getVersion()); + // For backward compatibility with previous implementations of the Redis integration, we must store a + // special placeholder string for deleted items. DataKind.serializeItem() will give us this string if + // we pass a deleted ItemDescriptor. + return kind.serialize(ItemDescriptor.deletedItem(serializedItem.getVersion())); } static interface UpdateListener { diff --git a/src/main/java/com/launchdarkly/client/interfaces/DataStoreTypes.java b/src/main/java/com/launchdarkly/client/interfaces/DataStoreTypes.java index dc707ea5a..85ccd7df9 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/DataStoreTypes.java +++ b/src/main/java/com/launchdarkly/client/interfaces/DataStoreTypes.java @@ -21,9 +21,8 @@ public abstract class DataStoreTypes { */ public static final class DataKind { private final String name; - private final Function serializer; + private final Function serializer; private final Function deserializer; - private final Function deletedItemSerializer; /** * A case-sensitive alphabetic string that uniquely identifies this data kind. @@ -42,14 +41,15 @@ public String getName() { * Returns a serialized representation of an item of this kind. *

    * The SDK uses this function to generate the data that is stored by a {@link PersistentDataStore}. - * Store implementations should not call it. + * Store implementations normally do not need to call it, except in a special case described in the + * documentation for {@link PersistentDataStore} regarding deleted item placeholders. * - * @param o an object which the serializer can assume is of the appropriate class + * @param item an {@link ItemDescriptor} describing the object to be serialized * @return the serialized representation * @exception ClassCastException if the object is of the wrong class */ - public String serialize(Object o) { - return serializer.apply(o); + public String serialize(ItemDescriptor item) { + return serializer.apply(item); } /** @@ -60,9 +60,9 @@ public String serialize(Object o) { * the documentation for {@link PersistentDataStore}, regarding updates. *

    * The returned {@link ItemDescriptor} has two properties: {@link ItemDescriptor#getItem()}, which - * is the deserialized object or a {@code null} value if the serialized string was the value - * produced by {@link #serializeDeletedItemPlaceholder(int)}, and {@link ItemDescriptor#getVersion()}, - * which provides the object's version number regardless of whether it is deleted or not. + * is the deserialized object or a {@code null} value for a deleted item placeholder, and + * {@link ItemDescriptor#getVersion()}, which provides the object's version number regardless of + * whether it is deleted or not. * * @param s the serialized representation * @return an {@link ItemDescriptor} describing the deserialized object @@ -71,33 +71,17 @@ public ItemDescriptor deserialize(String s) { return deserializer.apply(s); } - /** - * Returns a special serialized representation for a deleted item placeholder. - *

    - * This method should be used only by {@link PersistentDataStore} implementations in the special - * case described in the documentation for {@link PersistentDataStore} regarding deleted items. - * - * @param version the version number - * @return the serialized representation - */ - public String serializeDeletedItemPlaceholder(int version) { - return deletedItemSerializer.apply(version); - } - /** * Constructs a DataKind instance. * * @param name the value for {@link #getName()} - * @param serializer the function to use for {@link #serialize(Object)} + * @param serializer the function to use for {@link #serialize(ItemDescriptor)} * @param deserializer the function to use for {@link #deserialize(String)} - * @param deletedItemSerializer the function to use for {@link #serializeDeletedItemPlaceholder(int)} */ - public DataKind(String name, Function serializer, Function deserializer, - Function deletedItemSerializer) { + public DataKind(String name, Function serializer, Function deserializer) { this.name = name; this.serializer = serializer; this.deserializer = deserializer; - this.deletedItemSerializer = deletedItemSerializer; } @Override @@ -127,6 +111,7 @@ public static final class ItemDescriptor { /** * Returns the version number of this data, provided by the SDK. + * * @return the version number */ public int getVersion() { @@ -135,6 +120,7 @@ public int getVersion() { /** * Returns the data item, or null if this is a placeholder for a deleted item. + * * @return an object or null */ public Object getItem() { @@ -143,6 +129,7 @@ public Object getItem() { /** * Constructs a new instance. + * * @param version the version number * @param item an object or null */ @@ -153,6 +140,7 @@ public ItemDescriptor(int version, Object item) { /** * Convenience method for constructing a deleted item placeholder. + * * @param version the version number * @return an ItemDescriptor */ @@ -184,6 +172,7 @@ public String toString() { */ public static final class SerializedItemDescriptor { private final int version; + private final boolean deleted; private final String serializedItem; /** @@ -195,8 +184,21 @@ public int getVersion() { } /** - * Returns the data item's serialized representation, or null if this is a placeholder for a - * deleted item. + * Returns true if this is a placeholder (tombstone) for a deleted item. If so, + * {@link #getSerializedItem()} will still contain a string representing the deleted item, but + * the persistent store implementation has the option of not storing it if it can represent the + * placeholder in a more efficient way. + * + * @return true if this is a deleted item placeholder + */ + public boolean isDeleted() { + return deleted; + } + + /** + * Returns the data item's serialized representation. This will never be null; for a deleted item + * placeholder, it will contain a special value that can be stored if necessary (see {@link #isDeleted()}). + * * @return the serialized data or null */ public String getSerializedItem() { @@ -205,35 +207,30 @@ public String getSerializedItem() { /** * Constructs a new instance. + * * @param version the version number - * @param serializedItem the serialized data or null + * @param deleted true if this is a deleted item placeholder + * @param serializedItem the serialized data (will not be null) */ - public SerializedItemDescriptor(int version, String serializedItem) { + public SerializedItemDescriptor(int version, boolean deleted, String serializedItem) { this.version = version; + this.deleted = deleted; this.serializedItem = serializedItem; } - /** - * Convenience method for constructing a deleted item placeholder. - * @param version the version number - * @return a SerializedItemDescriptor - */ - public static SerializedItemDescriptor deletedItem(int version) { - return new SerializedItemDescriptor(version, null); - } - @Override public boolean equals(Object o) { if (o instanceof SerializedItemDescriptor) { SerializedItemDescriptor other = (SerializedItemDescriptor)o; - return version == other.version && Objects.equal(serializedItem, other.serializedItem); + return version == other.version && deleted == other.deleted && + Objects.equal(serializedItem, other.serializedItem); } return false; } @Override public String toString() { - return "SerializedItemDescriptor(" + version + "," + serializedItem + ")"; + return "SerializedItemDescriptor(" + version + "," + deleted + "," + serializedItem + ")"; } } @@ -252,6 +249,7 @@ public static final class FullDataSet { /** * Returns the wrapped data set. + * * @return an enumeration of key-value pairs; may be empty, but will not be null */ public Iterable>> getData() { @@ -260,6 +258,7 @@ public Iterable>> getData() { /** * Constructs a new instance. + * * @param data the data set */ public FullDataSet(Iterable>> data) { @@ -278,6 +277,7 @@ public static final class KeyedItems { /** * Returns the wrapped data set. + * * @return an enumeration of key-value pairs; may be empty, but will not be null */ public Iterable> getItems() { @@ -286,6 +286,7 @@ public Iterable> getItems() { /** * Constructs a new instance. + * * @param items the data set */ public KeyedItems(Iterable> items) { diff --git a/src/main/java/com/launchdarkly/client/interfaces/PersistentDataStore.java b/src/main/java/com/launchdarkly/client/interfaces/PersistentDataStore.java index 7c28eb2f2..f59e8fdbc 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/PersistentDataStore.java +++ b/src/main/java/com/launchdarkly/client/interfaces/PersistentDataStore.java @@ -20,30 +20,23 @@ *

    * Implementations must be thread-safe. *

    - * Conceptually, each item in the store is a {@link SerializedItemDescriptor} consisting of a - * version number plus either a string of serialized data or a null; the null represents a - * placeholder (tombstone) indicating that the item was deleted. - *

    - * Preferably, the store implementation should store the version number as a separate property - * from the string, and store a null or empty string for deleted items, so that no - * deserialization is required to simply determine the version (for updates) or the deleted - * state. - *

    - * However, due to how persistent stores were implemented in earlier SDK versions, for - * interoperability it may be necessary for a store to use a somewhat different model in - * which the version number and deleted state are encoded inside the serialized string. In - * this case, to avoid unnecessary extra parsing, the store should work as follows: - *

      - *
    • When querying items, set the {@link SerializedItemDescriptor} to have a version - * number of zero; the SDK will be able to determine the version number, and to filter out - * any items that were actually deleted, after it deserializes the item.
    • - *
    • When inserting or updating items, if the {@link SerializedItemDescriptor} contains - * a null, pass its version number to {@link DataKind#serializeDeletedItemPlaceholder(int)} - * and store the string that that method returns.
    • - *
    • When updating items, if it's necessary to check the version number of an existing - * item, pass its serialized string to {@link DataKind#deserialize(String)} and look at the - * version number in the returned {@link ItemDescriptor}.
    • - *
    + * Conceptually, each item in the store is a {@link SerializedItemDescriptor} which always has + * a version number, and can represent either a serialized object or a placeholder (tombstone) + * for a deleted item. There are two approaches a persistent store implementation can use for + * persisting this data: + * + * 1. Preferably, it should store the version number and the {@link SerializedItemDescriptor#isDeleted()} + * state separately so that the object does not need to be fully deserialized to read them. In + * this case, deleted item placeholders can ignore the value of {@link SerializedItemDescriptor#getSerializedItem()} + * on writes and can set it to null on reads. The store should never call {@link DataKind#deserialize(String)} + * or {@link DataKind#serialize(ItemDescriptor)}. + * + * 2. If that isn't possible, then the store should simply persist the exact string from + * {@link SerializedItemDescriptor#getSerializedItem()} on writes, and return the persisted + * string on reads (returning zero for the version and false for {@link SerializedItemDescriptor#isDeleted()}). + * The string is guaranteed to provide the SDK with enough information to infer the version and + * the deleted state. On updates, the store must call {@link DataKind#deserialize(String)} in + * order to inspect the version number of the existing item if any. * * @since 5.0.0 */ @@ -64,13 +57,16 @@ public interface PersistentDataStore extends Closeable { /** * Retrieves an item from the specified collection, if available. *

    - * If the item has been deleted and the store contains a placeholder, it should return a - * {@link SerializedItemDescriptor} for that placeholder rather than returning null. + * If the key is not known at all, the method should return null. Otherwise, it should return + * a {@link SerializedItemDescriptor} as follows: + *

    + * 1. If the version number and deletion state can be determined without fully deserializing + * the item, then the store should set those properties in the {@link SerializedItemDescriptor} + * (and can set {@link SerializedItemDescriptor#getSerializedItem()} to null for deleted items). *

    - * If it is possible for the data store to know the version number of the data item without - * deserializing it, then it should return that number in the version property of the - * {@link SerializedItemDescriptor}. If not, then it should just return zero for the version - * and it will be parsed out later. + * 2. Otherwise, it should simply set {@link SerializedItemDescriptor#getSerializedItem()} to + * the exact string that was persisted, and can leave the other properties as zero/false. See + * comments on {@link PersistentDataStore} for more about this. * * @param kind specifies which collection to use * @param key the unique key of the item within that collection @@ -82,8 +78,9 @@ public interface PersistentDataStore extends Closeable { /** * Retrieves all items from the specified collection. *

    - * If the store contains placeholders for deleted items, it should include them in - * the results, not filter them out. + * If the store contains placeholders for deleted items, it should include them in the results, + * not filter them out. See {@link #get(DataKind, String)} for how to set the properties of the + * {@link SerializedItemDescriptor} for each item. * * @param kind specifies which collection to use * @return a collection of key-value pairs; the ordering is not significant @@ -91,12 +88,20 @@ public interface PersistentDataStore extends Closeable { KeyedItems getAll(DataKind kind); /** - * Updates or inserts an item in the specified collection. For updates, the object will only be - * updated if the existing version is less than the new version. + * Updates or inserts an item in the specified collection. + *

    + * If the given key already exists in that collection, the store must check the version number + * of the existing item (even if it is a deleted item placeholder); if that version is greater + * than or equal to the version of the new item, the update fails and the method returns false. + * If the store is not able to determine the version number of an existing item without fully + * deserializing the existing item, then it is allowed to call {@link DataKind#deserialize(String)} + * for that purpose. *

    - * The SDK may pass an {@link ItemDescriptor} that contains a null, to represent a placeholder - * for a deleted item. In that case, assuming the version is greater than any existing version of - * that item, the store should retain that placeholder rather than simply not storing anything. + * If the item's {@link SerializedItemDescriptor#isDeleted()} method returns true, this is a + * deleted item placeholder. The store must persist this, rather than simply removing the key + * from the store. The SDK will provide a string in {@link SerializedItemDescriptor#getSerializedItem()} + * which the store can persist for this purpose; or, if the store is capable of persisting the + * version number and deleted state without storing anything else, it should do so. * * @param kind specifies which collection to use * @param key the unique key for the item within that collection diff --git a/src/test/java/com/launchdarkly/client/DataStoreTestTypes.java b/src/test/java/com/launchdarkly/client/DataStoreTestTypes.java index 97ff630bc..6f01e56bc 100644 --- a/src/test/java/com/launchdarkly/client/DataStoreTestTypes.java +++ b/src/test/java/com/launchdarkly/client/DataStoreTestTypes.java @@ -28,8 +28,8 @@ public static Map toItemsMap(KeyedItems data) { } public static SerializedItemDescriptor toSerialized(DataKind kind, ItemDescriptor item) { - return item.getItem() == null ? SerializedItemDescriptor.deletedItem(item.getVersion()) : - new SerializedItemDescriptor(item.getVersion(), kind.serialize(item.getItem())); + boolean isDeleted = item.getItem() == null; + return new SerializedItemDescriptor(item.getVersion(), isDeleted, kind.serialize(item)); } public static class TestItem implements VersionedData { @@ -80,7 +80,7 @@ public ItemDescriptor toItemDescriptor() { } public SerializedItemDescriptor toSerializedItemDescriptor() { - return new SerializedItemDescriptor(version, TEST_ITEMS.serialize(this)); + return toSerialized(TEST_ITEMS, toItemDescriptor()); } @Override @@ -107,16 +107,17 @@ public String toString() { public static final DataKind TEST_ITEMS = new DataKind("test-items", DataStoreTestTypes::serializeTestItem, - DataStoreTestTypes::deserializeTestItem, - DataStoreTestTypes::serializeDeletedItemPlaceholder); + DataStoreTestTypes::deserializeTestItem); public static final DataKind OTHER_TEST_ITEMS = new DataKind("other-test-items", DataStoreTestTypes::serializeTestItem, - DataStoreTestTypes::deserializeTestItem, - DataStoreTestTypes::serializeDeletedItemPlaceholder); + DataStoreTestTypes::deserializeTestItem); - private static String serializeTestItem(Object o) { - return JsonHelpers.gsonInstance().toJson(o); + private static String serializeTestItem(ItemDescriptor item) { + if (item.getItem() == null) { + return "DELETED:" + item.getVersion(); + } + return JsonHelpers.gsonInstance().toJson(item.getItem()); } private static ItemDescriptor deserializeTestItem(String s) { @@ -127,10 +128,6 @@ private static ItemDescriptor deserializeTestItem(String s) { return new ItemDescriptor(ti.version, ti); } - private static String serializeDeletedItemPlaceholder(int version) { - return "DELETED:" + version; - } - public static class DataBuilder { private Map> data = new HashMap<>(); diff --git a/src/test/java/com/launchdarkly/client/integrations/PersistentDataStoreTestBase.java b/src/test/java/com/launchdarkly/client/integrations/PersistentDataStoreTestBase.java index 6b844dfd9..e0debfac9 100644 --- a/src/test/java/com/launchdarkly/client/integrations/PersistentDataStoreTestBase.java +++ b/src/test/java/com/launchdarkly/client/integrations/PersistentDataStoreTestBase.java @@ -17,6 +17,7 @@ import static com.launchdarkly.client.DataStoreTestTypes.OTHER_TEST_ITEMS; import static com.launchdarkly.client.DataStoreTestTypes.TEST_ITEMS; import static com.launchdarkly.client.DataStoreTestTypes.toItemsMap; +import static com.launchdarkly.client.DataStoreTestTypes.toSerialized; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; @@ -170,7 +171,7 @@ public void getAll() { @Test public void getAllWithDeletedItem() { store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).buildSerialized()); - SerializedItemDescriptor deletedItem = SerializedItemDescriptor.deletedItem(item1.version + 1); + SerializedItemDescriptor deletedItem = toSerialized(TEST_ITEMS, ItemDescriptor.deletedItem(item1.version + 1)); store.upsert(TEST_ITEMS, item1.key, deletedItem); Map items = toItemsMap(store.getAll(TEST_ITEMS)); assertEquals(2, items.size()); @@ -205,7 +206,7 @@ public void upsertNewItem() { @Test public void deleteWithNewerVersion() { store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).buildSerialized()); - SerializedItemDescriptor deletedItem = SerializedItemDescriptor.deletedItem(item1.version + 1); + SerializedItemDescriptor deletedItem = toSerialized(TEST_ITEMS, ItemDescriptor.deletedItem(item1.version + 1)); store.upsert(TEST_ITEMS, item1.key, deletedItem); assertEqualsDeletedItem(deletedItem, store.get(TEST_ITEMS, item1.key)); } @@ -213,7 +214,7 @@ public void deleteWithNewerVersion() { @Test public void deleteWithOlderVersion() { store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).buildSerialized()); - SerializedItemDescriptor deletedItem = SerializedItemDescriptor.deletedItem(item1.version - 1); + SerializedItemDescriptor deletedItem = toSerialized(TEST_ITEMS, ItemDescriptor.deletedItem(item1.version - 1)); store.upsert(TEST_ITEMS, item1.key, deletedItem); assertEqualsSerializedItem(item1, store.get(TEST_ITEMS, item1.key)); } @@ -221,7 +222,7 @@ public void deleteWithOlderVersion() { @Test public void deleteUnknownItem() { store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).buildSerialized()); - SerializedItemDescriptor deletedItem = SerializedItemDescriptor.deletedItem(11); + SerializedItemDescriptor deletedItem = toSerialized(TEST_ITEMS, ItemDescriptor.deletedItem(11)); store.upsert(TEST_ITEMS, "deleted-key", deletedItem); assertEqualsDeletedItem(deletedItem, store.get(TEST_ITEMS, "deleted-key")); } @@ -229,7 +230,7 @@ public void deleteUnknownItem() { @Test public void upsertOlderVersionAfterDelete() { store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).buildSerialized()); - SerializedItemDescriptor deletedItem = SerializedItemDescriptor.deletedItem(item1.version + 1); + SerializedItemDescriptor deletedItem = toSerialized(TEST_ITEMS, ItemDescriptor.deletedItem(item1.version + 1)); store.upsert(TEST_ITEMS, item1.key, deletedItem); store.upsert(TEST_ITEMS, item1.key, item1.toSerializedItemDescriptor()); assertEqualsDeletedItem(deletedItem, store.get(TEST_ITEMS, item1.key)); diff --git a/src/test/java/com/launchdarkly/client/integrations/PersistentDataStoreWrapperTest.java b/src/test/java/com/launchdarkly/client/integrations/PersistentDataStoreWrapperTest.java index 4abb31dae..4af8085a8 100644 --- a/src/test/java/com/launchdarkly/client/integrations/PersistentDataStoreWrapperTest.java +++ b/src/test/java/com/launchdarkly/client/integrations/PersistentDataStoreWrapperTest.java @@ -26,6 +26,7 @@ import static com.launchdarkly.client.DataStoreTestTypes.TEST_ITEMS; import static com.launchdarkly.client.DataStoreTestTypes.toDataMap; import static com.launchdarkly.client.DataStoreTestTypes.toItemsMap; +import static com.launchdarkly.client.DataStoreTestTypes.toSerialized; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; @@ -46,12 +47,12 @@ public class PersistentDataStoreWrapperTest { static class TestMode { final boolean cached; final boolean cachedIndefinitely; - final boolean schemaCompatibilityMode; + final boolean persistOnlyAsString; - TestMode(boolean cached, boolean cachedIndefinitely, boolean schemaCompatibilityMode) { + TestMode(boolean cached, boolean cachedIndefinitely, boolean persistOnlyAsString) { this.cached = cached; this.cachedIndefinitely = cachedIndefinitely; - this.schemaCompatibilityMode = schemaCompatibilityMode; + this.persistOnlyAsString = persistOnlyAsString; } boolean isCached() { @@ -74,7 +75,7 @@ Duration getCacheTtl() { public String toString() { return "TestMode(" + (cached ? (cachedIndefinitely ? "CachedIndefinitely" : "Cached") : "Uncached") + - (schemaCompatibilityMode ? ",schemaCompatibility" : "") + ")"; + (persistOnlyAsString ? ",persistOnlyAsString" : "") + ")"; } } @@ -93,7 +94,7 @@ public static Iterable data() { public PersistentDataStoreWrapperTest(TestMode testMode) { this.testMode = testMode; this.core = new MockCore(); - this.core.schemaCompatibilityMode = testMode.schemaCompatibilityMode; + this.core.persistOnlyAsString = testMode.persistOnlyAsString; this.wrapper = new PersistentDataStoreWrapper(core, testMode.getCacheTtl(), PersistentDataStoreBuilder.StaleValuesPolicy.EVICT, null); } @@ -118,7 +119,7 @@ public void get() { public void getDeletedItem() { String key = "key"; - core.forceSet(TEST_ITEMS, key, SerializedItemDescriptor.deletedItem(1)); + core.forceSet(TEST_ITEMS, key, toSerialized(TEST_ITEMS, ItemDescriptor.deletedItem(1))); assertThat(wrapper.get(TEST_ITEMS, key), equalTo(ItemDescriptor.deletedItem(1))); TestItem itemv2 = new TestItem(key, 2); @@ -185,7 +186,7 @@ public void getAllDoesNotRemoveDeletedItems() { TestItem item1 = new TestItem(key1, 1); core.forceSet(TEST_ITEMS, item1); - core.forceSet(TEST_ITEMS, key2, SerializedItemDescriptor.deletedItem(1)); + core.forceSet(TEST_ITEMS, key2, toSerialized(TEST_ITEMS, ItemDescriptor.deletedItem(1))); Map items = toItemsMap(wrapper.getAll(TEST_ITEMS)); Map expected = ImmutableMap.of( key1, item1.toItemDescriptor(), key2, ItemDescriptor.deletedItem(1)); @@ -392,9 +393,10 @@ public void delete() { ItemDescriptor deletedItem = ItemDescriptor.deletedItem(2); wrapper.upsert(TEST_ITEMS, itemv1.key, deletedItem); - // in schema compatibility mode, it will store a special placeholder string, otherwise a null - SerializedItemDescriptor serializedDeletedItem = new SerializedItemDescriptor(deletedItem.getVersion(), - testMode.schemaCompatibilityMode ? TEST_ITEMS.serializeDeletedItemPlaceholder(deletedItem.getVersion()) : null); + // some stores will persist a special placeholder string, others will store the metadata separately + SerializedItemDescriptor serializedDeletedItem = testMode.persistOnlyAsString ? + toSerialized(TEST_ITEMS, ItemDescriptor.deletedItem(deletedItem.getVersion())) : + new SerializedItemDescriptor(deletedItem.getVersion(), true, null); assertThat(core.data.get(TEST_ITEMS).get(itemv1.key), equalTo(serializedDeletedItem)); // make a change that bypasses the cache @@ -508,7 +510,7 @@ static class MockCore implements PersistentDataStore { Map> data = new HashMap<>(); boolean inited; int initedQueryCount; - boolean schemaCompatibilityMode; + boolean persistOnlyAsString; RuntimeException fakeError; @Override @@ -521,8 +523,9 @@ public SerializedItemDescriptor get(DataKind kind, String key) { if (data.containsKey(kind)) { SerializedItemDescriptor item = data.get(kind).get(key); if (item != null) { - if (schemaCompatibilityMode) { - return new SerializedItemDescriptor(0, item.getSerializedItem()); + if (persistOnlyAsString) { + // This simulates the kind of store implementation that can't track metadata separately + return new SerializedItemDescriptor(0, false, item.getSerializedItem()); } else { return item; } @@ -593,11 +596,10 @@ public void forceRemove(DataKind kind, String key) { } private SerializedItemDescriptor storableItem(DataKind kind, SerializedItemDescriptor item) { - // Here we simulate the behavior of older persistent store implementations where the store does not have - // the ability to track an item's deleted status itself, but must instead store a string that can be - // recognized by the DataKind as a placeholder. - if (item.getSerializedItem() == null && schemaCompatibilityMode) { - return new SerializedItemDescriptor(item.getVersion(), kind.serializeDeletedItemPlaceholder(item.getVersion())); + if (item.isDeleted() && !persistOnlyAsString) { + // This simulates the kind of store implementation that *can* track metadata separately, so we don't + // have to persist the placeholder string for deleted items + return new SerializedItemDescriptor(item.getVersion(), true, null); } return item; } From acdcda8aabd71cf6e74491f780a9e17ea9fc0850 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 4 Mar 2020 19:03:04 -0800 Subject: [PATCH 323/673] javadoc fixes --- .../com/launchdarkly/client/interfaces/DataStoreTypes.java | 2 +- .../launchdarkly/client/interfaces/PersistentDataStore.java | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/interfaces/DataStoreTypes.java b/src/main/java/com/launchdarkly/client/interfaces/DataStoreTypes.java index 85ccd7df9..777d2dc98 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/DataStoreTypes.java +++ b/src/main/java/com/launchdarkly/client/interfaces/DataStoreTypes.java @@ -75,7 +75,7 @@ public ItemDescriptor deserialize(String s) { * Constructs a DataKind instance. * * @param name the value for {@link #getName()} - * @param serializer the function to use for {@link #serialize(ItemDescriptor)} + * @param serializer the function to use for {@link #serialize(DataStoreTypes.ItemDescriptor)} * @param deserializer the function to use for {@link #deserialize(String)} */ public DataKind(String name, Function serializer, Function deserializer) { diff --git a/src/main/java/com/launchdarkly/client/interfaces/PersistentDataStore.java b/src/main/java/com/launchdarkly/client/interfaces/PersistentDataStore.java index f59e8fdbc..1797e92af 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/PersistentDataStore.java +++ b/src/main/java/com/launchdarkly/client/interfaces/PersistentDataStore.java @@ -2,7 +2,6 @@ import com.launchdarkly.client.interfaces.DataStoreTypes.DataKind; import com.launchdarkly.client.interfaces.DataStoreTypes.FullDataSet; -import com.launchdarkly.client.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.client.interfaces.DataStoreTypes.KeyedItems; import com.launchdarkly.client.interfaces.DataStoreTypes.SerializedItemDescriptor; @@ -29,7 +28,7 @@ * state separately so that the object does not need to be fully deserialized to read them. In * this case, deleted item placeholders can ignore the value of {@link SerializedItemDescriptor#getSerializedItem()} * on writes and can set it to null on reads. The store should never call {@link DataKind#deserialize(String)} - * or {@link DataKind#serialize(ItemDescriptor)}. + * or {@link DataKind#serialize(DataStoreTypes.ItemDescriptor)}. * * 2. If that isn't possible, then the store should simply persist the exact string from * {@link SerializedItemDescriptor#getSerializedItem()} on writes, and return the persisted @@ -79,7 +78,7 @@ public interface PersistentDataStore extends Closeable { * Retrieves all items from the specified collection. *

    * If the store contains placeholders for deleted items, it should include them in the results, - * not filter them out. See {@link #get(DataKind, String)} for how to set the properties of the + * not filter them out. See {@link #get(DataStoreTypes.DataKind, String)} for how to set the properties of the * {@link SerializedItemDescriptor} for each item. * * @param kind specifies which collection to use From 4a52bb8f38bdcffadb67327cd8b2af102cf05db1 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 4 Mar 2020 19:03:12 -0800 Subject: [PATCH 324/673] test cleanup --- .../launchdarkly/client/DataStoreTestBase.java | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/src/test/java/com/launchdarkly/client/DataStoreTestBase.java b/src/test/java/com/launchdarkly/client/DataStoreTestBase.java index ee87122ba..71fcf8bab 100644 --- a/src/test/java/com/launchdarkly/client/DataStoreTestBase.java +++ b/src/test/java/com/launchdarkly/client/DataStoreTestBase.java @@ -28,25 +28,15 @@ public abstract class DataStoreTestBase { protected DataStore store; - protected boolean cached; - + protected TestItem item1 = new TestItem("key1", "first", 10); protected TestItem item2 = new TestItem("key2", "second", 10); protected TestItem otherItem1 = new TestItem("key1", "other-first", 11); - - public DataStoreTestBase() { - this(false); - } - - public DataStoreTestBase(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. + * Test subclasses must override this method to create an instance of the feature store class. * @return */ protected abstract DataStore makeStore(); From 87ade0b4f634c0eec06bc3af2b1d8ca178e1dd8c Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 4 Mar 2020 19:24:00 -0800 Subject: [PATCH 325/673] InMemoryDataStore is no longer public --- src/main/java/com/launchdarkly/client/InMemoryDataStore.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/client/InMemoryDataStore.java b/src/main/java/com/launchdarkly/client/InMemoryDataStore.java index 632fa692b..6dcf992fe 100644 --- a/src/main/java/com/launchdarkly/client/InMemoryDataStore.java +++ b/src/main/java/com/launchdarkly/client/InMemoryDataStore.java @@ -17,8 +17,11 @@ /** * A thread-safe, versioned store for feature flags and related data based on a * {@link HashMap}. This is the default implementation of {@link DataStore}. + * + * As of version 5.0.0, this is package-private; applications must use the factory method + * {@link Components#inMemoryDataStore()}. */ -public class InMemoryDataStore implements DataStore, DiagnosticDescription { +class InMemoryDataStore implements DataStore, DiagnosticDescription { private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); private final Map> allData = new HashMap<>(); private volatile boolean initialized = false; From dbfea9b9baca1e704242ac90420e9f4acdae5713 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 4 Mar 2020 19:28:09 -0800 Subject: [PATCH 326/673] fix test --- src/test/java/com/launchdarkly/client/TestUtil.java | 4 ++++ .../launchdarkly/client/integrations/FileDataSourceTest.java | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/launchdarkly/client/TestUtil.java b/src/test/java/com/launchdarkly/client/TestUtil.java index 2b3bace90..c1fafc0b6 100644 --- a/src/test/java/com/launchdarkly/client/TestUtil.java +++ b/src/test/java/com/launchdarkly/client/TestUtil.java @@ -51,6 +51,10 @@ public static DataStoreFactory specificDataStore(final DataStore store) { return context -> store; } + public static DataStore inMemoryDataStore() { + return new InMemoryDataStore(); // this is for tests in other packages which can't see this concrete class + } + public static DataStore initedDataStore() { DataStore store = new InMemoryDataStore(); store.init(new FullDataSet(null)); diff --git a/src/test/java/com/launchdarkly/client/integrations/FileDataSourceTest.java b/src/test/java/com/launchdarkly/client/integrations/FileDataSourceTest.java index ebff9f21f..23d5c14b8 100644 --- a/src/test/java/com/launchdarkly/client/integrations/FileDataSourceTest.java +++ b/src/test/java/com/launchdarkly/client/integrations/FileDataSourceTest.java @@ -1,7 +1,7 @@ package com.launchdarkly.client.integrations; -import com.launchdarkly.client.InMemoryDataStore; import com.launchdarkly.client.LDConfig; +import com.launchdarkly.client.TestUtil; import com.launchdarkly.client.interfaces.DataSource; import com.launchdarkly.client.interfaces.DataStore; @@ -31,7 +31,7 @@ public class FileDataSourceTest { private static final Path badFilePath = Paths.get("no-such-file.json"); - private final DataStore store = new InMemoryDataStore(); + private final DataStore store = TestUtil.inMemoryDataStore(); private final LDConfig config = new LDConfig.Builder().build(); private final FileDataSourceBuilder factory; From 32cfa53ed1d0f1ab5f12c99170c65c3f463e80e5 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 4 Mar 2020 19:38:58 -0800 Subject: [PATCH 327/673] make Gson an internal shaded dependency --- README.md | 4 ++-- build.gradle | 3 +-- packaging-test/Makefile | 11 ++++++----- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 1b01e0f55..2a2ac92f3 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,8 @@ Distributions Three variants of the SDK jar are published to Maven: -* 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 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 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 SLF4J is also bundled, without shading (and is exported in OSGi). * The "thin" jar - add `thin` in Maven, or `:thin` in Gradle. This contains _only_ the SDK classes. Getting started diff --git a/build.gradle b/build.gradle index c7891f267..7bc48348c 100644 --- a/build.gradle +++ b/build.gradle @@ -58,6 +58,7 @@ ext.libraries = [:] // in the other two SDK jars. libraries.internal = [ "commons-codec:commons-codec:1.10", + "com.google.code.gson:gson:2.7", "com.google.guava:guava:28.2-jre", "com.launchdarkly:okhttp-eventsource:2.0.1", "org.yaml:snakeyaml:1.19", @@ -67,7 +68,6 @@ libraries.internal = [ // 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" ] @@ -124,7 +124,6 @@ shadowJar { 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 diff --git a/packaging-test/Makefile b/packaging-test/Makefile index e69d2cf40..9982ce37f 100644 --- a/packaging-test/Makefile +++ b/packaging-test/Makefile @@ -94,11 +94,11 @@ 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)) @$(call classes_should_contain,'^org/slf4j/',SLF4j (unshaded)) @$(call classes_should_contain,'^com/launchdarkly/shaded/',shaded dependency jars) + @$(call classes_should_contain,'^com/launchdarkly/shaded/com/google/gson',shaded Gson) + @$(call classes_should_not_contain,'^com/google/gson/',Gson (unshaded)) @$(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) @@ -106,8 +106,9 @@ test-default-jar-classes: $(SDK_DEFAULT_JAR) $(TEMP_DIR) @$(call classes_prepare,$<) @$(call verify_sdk_classes) @$(call classes_should_contain,'^com/launchdarkly/shaded/',shaded dependency jars) + @$(call classes_should_contain,'^com/launchdarkly/shaded/com/google/gson',shaded Gson) @$(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,'^com/google/gson/',Gson (unshaded)) @$(call classes_should_not_contain,'org/slf4j/',SLF4j (shaded or unshaded)) test-thin-jar-classes: $(SDK_THIN_JAR) $(TEMP_DIR) @@ -137,12 +138,12 @@ $(TEMP_DIR)/dependencies-all: | $(TEMP_DIR) $(TEMP_DIR)/dependencies-external: $(TEMP_DIR)/dependencies-all [ -d $@ ] || mkdir -p $@ - cp $(TEMP_DIR)/dependencies-all/gson*.jar $(TEMP_DIR)/dependencies-all/slf4j*.jar $@ + cp $(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 + rm $@/slf4j*.jar $(SLF4J_SIMPLE_JAR): | $(TEMP_DIR) curl -f -L $(SLF4J_SIMPLE_JAR_URL) >$@ From 6ec0440d059f645f3663ef1607c0605f843e8b3b Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 4 Mar 2020 19:43:43 -0800 Subject: [PATCH 328/673] fix test code --- packaging-test/test-app/src/main/java/testapp/TestApp.java | 6 ------ 1 file changed, 6 deletions(-) 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 4c762db05..0637292f9 100644 --- a/packaging-test/test-app/src/main/java/testapp/TestApp.java +++ b/packaging-test/test-app/src/main/java/testapp/TestApp.java @@ -2,7 +2,6 @@ import com.launchdarkly.client.*; import com.launchdarkly.client.integrations.*; -import com.google.gson.*; import org.slf4j.*; public class TestApp { @@ -20,11 +19,6 @@ public static void main(String[] args) throws Exception { .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"); - // Also do a flag evaluation, to ensure that it calls NewRelicReflector.annotateTransaction() client.boolVariation("flag-key", new LDUser("user-key"), false); From 7bb14c2489a340e73ba594f16b7514ab365844d3 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 5 Mar 2020 16:23:30 -0800 Subject: [PATCH 329/673] rewrite LDUser for better performance & isolation, add UserAttribute --- .../com/launchdarkly/client/Components.java | 2 +- .../com/launchdarkly/client/DataModel.java | 18 +- .../client/DefaultEventProcessor.java | 2 +- .../com/launchdarkly/client/Evaluator.java | 6 +- .../client/EvaluatorBucketing.java | 8 +- .../client/EventOutputFormatter.java | 4 +- .../client/EventUserSerialization.java | 109 ++++ .../client/EventsConfiguration.java | 6 +- .../com/launchdarkly/client/JsonHelpers.java | 2 +- .../com/launchdarkly/client/LDClient.java | 16 +- .../java/com/launchdarkly/client/LDUser.java | 440 +++++++------- .../launchdarkly/client/UserAttribute.java | 193 +++++-- .../integrations/EventProcessorBuilder.java | 29 +- .../client/DefaultEventProcessorTest.java | 10 +- .../client/EvaluatorBucketingTest.java | 12 +- .../client/EvaluatorClauseTest.java | 16 +- .../client/EvaluatorRuleTest.java | 12 +- .../client/EvaluatorSegmentMatchTest.java | 14 +- .../launchdarkly/client/EvaluatorTest.java | 4 +- .../launchdarkly/client/EventOutputTest.java | 8 +- .../client/EventUserSerializationTest.java | 147 +++++ .../client/LDClientEvaluationTest.java | 4 +- .../com/launchdarkly/client/LDUserTest.java | 546 +++++++----------- .../launchdarkly/client/ModelBuilders.java | 12 +- .../com/launchdarkly/client/TestUtil.java | 4 +- .../client/UserAttributeTest.java | 70 +++ 26 files changed, 978 insertions(+), 716 deletions(-) create mode 100644 src/main/java/com/launchdarkly/client/EventUserSerialization.java create mode 100644 src/test/java/com/launchdarkly/client/EventUserSerializationTest.java create mode 100644 src/test/java/com/launchdarkly/client/UserAttributeTest.java diff --git a/src/main/java/com/launchdarkly/client/Components.java b/src/main/java/com/launchdarkly/client/Components.java index 951114fd5..369f25334 100644 --- a/src/main/java/com/launchdarkly/client/Components.java +++ b/src/main/java/com/launchdarkly/client/Components.java @@ -401,7 +401,7 @@ public EventProcessor createEventProcessor(ClientContext context) { baseURI == null ? LDConfig.DEFAULT_EVENTS_URI : baseURI, flushInterval, inlineUsersInEvents, - privateAttrNames, + privateAttributes, 0, // deprecated samplingInterval isn't supported in new builder userKeysCapacity, userKeysFlushInterval, diff --git a/src/main/java/com/launchdarkly/client/DataModel.java b/src/main/java/com/launchdarkly/client/DataModel.java index aa350fb60..8d1cd9fc6 100644 --- a/src/main/java/com/launchdarkly/client/DataModel.java +++ b/src/main/java/com/launchdarkly/client/DataModel.java @@ -268,7 +268,7 @@ void setRuleMatchReason(EvaluationReason.RuleMatch ruleMatchReason) { } static class Clause { - private String attribute; + private UserAttribute attribute; private Operator op; private List values; //interpreted as an OR of values private boolean negate; @@ -276,14 +276,14 @@ static class Clause { Clause() { } - Clause(String attribute, Operator op, List values, boolean negate) { + Clause(UserAttribute attribute, Operator op, List values, boolean negate) { this.attribute = attribute; this.op = op; this.values = values; this.negate = negate; } - String getAttribute() { + UserAttribute getAttribute() { return attribute; } @@ -302,11 +302,11 @@ boolean isNegate() { static final class Rollout { private List variations; - private String bucketBy; + private UserAttribute bucketBy; Rollout() {} - Rollout(List variations, String bucketBy) { + Rollout(List variations, UserAttribute bucketBy) { this.variations = variations; this.bucketBy = bucketBy; } @@ -315,7 +315,7 @@ List getVariations() { return variations; } - String getBucketBy() { + UserAttribute getBucketBy() { return bucketBy; } } @@ -417,9 +417,9 @@ public boolean isDeleted() { static final class SegmentRule { private final List clauses; private final Integer weight; - private final String bucketBy; + private final UserAttribute bucketBy; - SegmentRule(List clauses, Integer weight, String bucketBy) { + SegmentRule(List clauses, Integer weight, UserAttribute bucketBy) { this.clauses = clauses; this.weight = weight; this.bucketBy = bucketBy; @@ -433,7 +433,7 @@ Integer getWeight() { return weight; } - String getBucketBy() { + UserAttribute getBucketBy() { return bucketBy; } } diff --git a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java index d94ed1a90..b8c69c361 100644 --- a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java @@ -415,7 +415,7 @@ private boolean noticeUser(LDUser user, SimpleLRUCache userKeys) if (user == null || user.getKey() == null) { return false; } - String key = user.getKeyAsString(); + String key = user.getKey(); return userKeys.put(key, key) != null; } diff --git a/src/main/java/com/launchdarkly/client/Evaluator.java b/src/main/java/com/launchdarkly/client/Evaluator.java index a4b5c3a4c..5f3cd15b8 100644 --- a/src/main/java/com/launchdarkly/client/Evaluator.java +++ b/src/main/java/com/launchdarkly/client/Evaluator.java @@ -140,7 +140,7 @@ private EvalResult evaluateInternal(DataModel.FeatureFlag flag, LDUser user, Eve if (targets != null) { for (DataModel.Target target: targets) { for (String v : target.getValues()) { - if (v.equals(user.getKey().stringValue())) { + if (v.equals(user.getKey())) { return getVariation(flag, target.getVariation(), EvaluationReason.targetMatch()); } } @@ -257,7 +257,7 @@ private boolean clauseMatchesUser(DataModel.Clause clause, LDUser user) { } private boolean clauseMatchesUserNoSegments(DataModel.Clause clause, LDUser user) { - LDValue userValue = user.getValueForEvaluation(clause.getAttribute()); + LDValue userValue = user.getAttribute(clause.getAttribute()); if (userValue.isNull()) { return false; } @@ -298,7 +298,7 @@ private boolean maybeNegate(DataModel.Clause clause, boolean b) { } private boolean segmentMatchesUser(DataModel.Segment segment, LDUser user) { - String userKey = user.getKeyAsString(); + String userKey = user.getKey(); if (userKey == null) { return false; } diff --git a/src/main/java/com/launchdarkly/client/EvaluatorBucketing.java b/src/main/java/com/launchdarkly/client/EvaluatorBucketing.java index d6856c82d..429557d11 100644 --- a/src/main/java/com/launchdarkly/client/EvaluatorBucketing.java +++ b/src/main/java/com/launchdarkly/client/EvaluatorBucketing.java @@ -38,12 +38,12 @@ static Integer variationIndexForUser(DataModel.VariationOrRollout vr, LDUser use return null; } - static float bucketUser(LDUser user, String key, String attr, String salt) { - LDValue userValue = user.getValueForEvaluation(attr == null ? "key" : attr); + static float bucketUser(LDUser user, String key, UserAttribute attr, String salt) { + LDValue userValue = user.getAttribute(attr == null ? UserAttribute.KEY : attr); String idHash = getBucketableStringValue(userValue); if (idHash != null) { - if (!user.getSecondary().isNull()) { - idHash = idHash + "." + user.getSecondary().stringValue(); + if (user.getSecondary() != null) { + idHash = idHash + "." + user.getSecondary(); } String hash = DigestUtils.sha1Hex(key + "." + salt + "." + idHash).substring(0, 15); long longVal = Long.parseLong(hash, 16); diff --git a/src/main/java/com/launchdarkly/client/EventOutputFormatter.java b/src/main/java/com/launchdarkly/client/EventOutputFormatter.java index ca6146f17..93535620d 100644 --- a/src/main/java/com/launchdarkly/client/EventOutputFormatter.java +++ b/src/main/java/com/launchdarkly/client/EventOutputFormatter.java @@ -64,7 +64,7 @@ private boolean writeOutputEvent(Event event, JsonWriter jw) throws IOException writeEvaluationReason("reason", fe.getReason(), jw); jw.endObject(); } else if (event instanceof Event.Identify) { - startEvent(event, "identify", event.getUser() == null ? null : event.getUser().getKeyAsString(), jw); + startEvent(event, "identify", event.getUser() == null ? null : event.getUser().getKey(), jw); writeUser(event.getUser(), jw); jw.endObject(); } else if (event instanceof Event.Custom) { @@ -175,7 +175,7 @@ private void writeUserOrKey(Event event, boolean forceInline, JsonWriter jw) thr writeUser(user, jw); } else { jw.name("userKey"); - jw.value(user.getKeyAsString()); + jw.value(user.getKey()); } } } diff --git a/src/main/java/com/launchdarkly/client/EventUserSerialization.java b/src/main/java/com/launchdarkly/client/EventUserSerialization.java new file mode 100644 index 000000000..f2cb098a5 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/EventUserSerialization.java @@ -0,0 +1,109 @@ +package com.launchdarkly.client; + +import com.google.gson.TypeAdapter; +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.Set; +import java.util.TreeSet; + +class EventUserSerialization { + + // Used internally when including users in analytics events, to ensure that private attributes are stripped out. + static class UserAdapterWithPrivateAttributeBehavior extends TypeAdapter { + private static final UserAttribute[] BUILT_IN_OPTIONAL_STRING_ATTRIBUTES = new UserAttribute[] { + UserAttribute.SECONDARY_KEY, + UserAttribute.IP, + UserAttribute.EMAIL, + UserAttribute.NAME, + UserAttribute.AVATAR, + UserAttribute.FIRST_NAME, + UserAttribute.LAST_NAME, + UserAttribute.COUNTRY + }; + + private final EventsConfiguration config; + + public UserAdapterWithPrivateAttributeBehavior(EventsConfiguration config) { + this.config = config; + } + + @Override + public void write(JsonWriter out, LDUser user) throws IOException { + if (user == null) { + out.value((String)null); + return; + } + + // Collect the private attribute names (use TreeSet to make ordering predictable for tests) + Set privateAttributeNames = new TreeSet(); + + out.beginObject(); + // The key can never be private + out.name("key").value(user.getKey()); + + for (UserAttribute attr: BUILT_IN_OPTIONAL_STRING_ATTRIBUTES) { + LDValue value = user.getAttribute(attr); + if (!value.isNull()) { + if (!checkAndAddPrivate(attr, user, privateAttributeNames)) { + out.name(attr.getName()).value(value.stringValue()); + } + } + } + if (!user.getAttribute(UserAttribute.ANONYMOUS).isNull()) { + out.name("anonymous").value(user.isAnonymous()); + } + writeCustomAttrs(out, user, privateAttributeNames); + writePrivateAttrNames(out, privateAttributeNames); + + out.endObject(); + } + + private void writePrivateAttrNames(JsonWriter out, Set names) throws IOException { + if (names.isEmpty()) { + return; + } + out.name("privateAttrs"); + out.beginArray(); + for (String name : names) { + out.value(name); + } + out.endArray(); + } + + private boolean checkAndAddPrivate(UserAttribute attribute, LDUser user, Set privateAttrs) { + boolean result = config.allAttributesPrivate || config.privateAttributes.contains(attribute) || user.isAttributePrivate(attribute); + if (result) { + privateAttrs.add(attribute.getName()); + } + return result; + } + + private void writeCustomAttrs(JsonWriter out, LDUser user, Set privateAttributeNames) throws IOException { + boolean beganObject = false; + for (UserAttribute attribute: user.getCustomAttributes()) { + if (!checkAndAddPrivate(attribute, user, privateAttributeNames)) { + if (!beganObject) { + out.name("custom"); + out.beginObject(); + beganObject = true; + } + out.name(attribute.getName()); + LDValue value = user.getAttribute(attribute); + JsonHelpers.gsonInstance().toJson(value, LDValue.class, out); + } + } + if (beganObject) { + out.endObject(); + } + } + + @Override + public LDUser read(JsonReader in) throws IOException { + // We never need to unmarshal user objects, so there's no need to implement this + return null; + } + } +} diff --git a/src/main/java/com/launchdarkly/client/EventsConfiguration.java b/src/main/java/com/launchdarkly/client/EventsConfiguration.java index 6f2dce3ee..c0e38fc54 100644 --- a/src/main/java/com/launchdarkly/client/EventsConfiguration.java +++ b/src/main/java/com/launchdarkly/client/EventsConfiguration.java @@ -13,14 +13,14 @@ final class EventsConfiguration { final URI eventsUri; final Duration flushInterval; final boolean inlineUsersInEvents; - final ImmutableSet privateAttrNames; + final ImmutableSet privateAttributes; final int samplingInterval; final int userKeysCapacity; final Duration userKeysFlushInterval; final Duration diagnosticRecordingInterval; EventsConfiguration(boolean allAttributesPrivate, int capacity, URI eventsUri, Duration flushInterval, - boolean inlineUsersInEvents, Set privateAttrNames, int samplingInterval, + boolean inlineUsersInEvents, Set privateAttributes, int samplingInterval, int userKeysCapacity, Duration userKeysFlushInterval, Duration diagnosticRecordingInterval) { super(); this.allAttributesPrivate = allAttributesPrivate; @@ -28,7 +28,7 @@ final class EventsConfiguration { this.eventsUri = eventsUri == null ? LDConfig.DEFAULT_EVENTS_URI : eventsUri; this.flushInterval = flushInterval; this.inlineUsersInEvents = inlineUsersInEvents; - this.privateAttrNames = privateAttrNames == null ? ImmutableSet.of() : ImmutableSet.copyOf(privateAttrNames); + this.privateAttributes = privateAttributes == null ? ImmutableSet.of() : ImmutableSet.copyOf(privateAttributes); this.samplingInterval = samplingInterval; this.userKeysCapacity = userKeysCapacity; this.userKeysFlushInterval = userKeysFlushInterval; diff --git a/src/main/java/com/launchdarkly/client/JsonHelpers.java b/src/main/java/com/launchdarkly/client/JsonHelpers.java index 97f4c95a5..8c9fb0145 100644 --- a/src/main/java/com/launchdarkly/client/JsonHelpers.java +++ b/src/main/java/com/launchdarkly/client/JsonHelpers.java @@ -26,7 +26,7 @@ static Gson gsonInstance() { */ static Gson gsonInstanceForEventsSerialization(EventsConfiguration config) { return new GsonBuilder() - .registerTypeAdapter(LDUser.class, new LDUser.UserAdapterWithPrivateAttributeBehavior(config)) + .registerTypeAdapter(LDUser.class, new EventUserSerialization.UserAdapterWithPrivateAttributeBehavior(config)) .create(); } diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index 13293cdc9..cbd0d7d32 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -144,7 +144,7 @@ public void track(String eventName, LDUser user) { @Override public void trackData(String eventName, LDUser user, LDValue data) { - if (user == null || user.getKeyAsString() == null) { + 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, null)); @@ -153,7 +153,7 @@ public void trackData(String eventName, LDUser user, LDValue data) { @Override public void trackMetric(String eventName, LDUser user, LDValue data, double metricValue) { - if (user == null || user.getKeyAsString() == null) { + 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)); @@ -162,7 +162,7 @@ public void trackMetric(String eventName, LDUser user, LDValue data, double metr @Override public void identify(LDUser user) { - if (user == null || user.getKeyAsString() == null) { + if (user == null || user.getKey() == null) { logger.warn("Identify called with null user or null user key!"); } else { eventProcessor.sendEvent(EventFactory.DEFAULT.newIdentifyEvent(user)); @@ -191,7 +191,7 @@ public FeatureFlagsState allFlagsState(LDUser user, FlagsStateOption... options) } } - if (user == null || user.getKeyAsString() == null) { + if (user == null || user.getKey() == null) { logger.warn("allFlagsState() was called with null user or null user key! returning no data"); return builder.valid(false).build(); } @@ -331,13 +331,13 @@ private Evaluator.EvalResult evaluateInternal(String featureKey, LDUser user, LD EvaluationReason.ErrorKind.FLAG_NOT_FOUND)); return errorResult(EvaluationReason.ErrorKind.FLAG_NOT_FOUND, defaultValue); } - if (user == null || user.getKeyAsString() == null) { + if (user == null || user.getKey() == 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)); return errorResult(EvaluationReason.ErrorKind.USER_NOT_SPECIFIED, defaultValue); } - if (user.getKeyAsString().isEmpty()) { + if (user.getKey().isEmpty()) { logger.warn("User key is blank. Flag evaluation will proceed, but the user will not be stored in LaunchDarkly"); } Evaluator.EvalResult evalResult = evaluator.evaluate(featureFlag, user, eventFactory); @@ -391,13 +391,13 @@ public boolean isOffline() { @Override public String secureModeHash(LDUser user) { - if (user == null || user.getKeyAsString() == null) { + if (user == null || user.getKey() == null) { return null; } try { Mac mac = Mac.getInstance(HMAC_ALGORITHM); mac.init(new SecretKeySpec(sdkKey.getBytes(), HMAC_ALGORITHM)); - return Hex.encodeHexString(mac.doFinal(user.getKeyAsString().getBytes("UTF8"))); + return Hex.encodeHexString(mac.doFinal(user.getKey().getBytes("UTF8"))); } catch (InvalidKeyException | UnsupportedEncodingException | NoSuchAlgorithmException e) { logger.error("Could not generate secure mode hash: {}", e.toString()); logger.debug(e.toString(), e); diff --git a/src/main/java/com/launchdarkly/client/LDUser.java b/src/main/java/com/launchdarkly/client/LDUser.java index 685b728df..8c052540c 100644 --- a/src/main/java/com/launchdarkly/client/LDUser.java +++ b/src/main/java/com/launchdarkly/client/LDUser.java @@ -1,55 +1,49 @@ 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.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; -import java.io.IOException; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Objects; import java.util.Set; -import java.util.TreeSet; /** - * A {@code LDUser} object contains specific attributes of a user browsing your site. The only mandatory property property is the {@code key}, - * which must uniquely identify each user. For authenticated users, this may be a username or e-mail address. For anonymous users, - * this could be an IP address or session ID. + * A {@code LDUser} object contains specific attributes of a user browsing your site. *

    - * Besides the mandatory {@code key}, {@code LDUser} supports two kinds of optional attributes: interpreted attributes (e.g. {@code ip} and {@code country}) - * and custom attributes. LaunchDarkly can parse interpreted attributes and attach meaning to them. For example, from an {@code ip} address, LaunchDarkly can - * do a geo IP lookup and determine the user's country. + * The only mandatory property is the {@code key}, which must uniquely identify each user; this could be a username + * or email address for authenticated users, or a session ID for anonymous users. All other built-in properties are + * optional. You may also define custom properties with arbitrary names and values. *

    - * 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 {@code Gson.toJson()} or - * {@code Gson.toJsonTree()} on it. + * For a fuller description of user attributes and how they can be referenced in feature flag rules, see the reference + * guides on Setting user attributes + * and Targeting users. + *

    + * If you want to pass an LDUser object to the front end to be used with the JavaScript SDK, cal */ public class LDUser { private static final Logger logger = LoggerFactory.getLogger(LDUser.class); // 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; + final LDValue key; + final LDValue secondary; + final LDValue ip; + final LDValue email; + final LDValue name; + final LDValue avatar; + final LDValue firstName; + final LDValue lastName; + final LDValue anonymous; + final LDValue country; + final Map custom; + Set privateAttributeNames; protected LDUser(Builder builder) { if (builder.key == null || builder.key.equals("")) { @@ -66,7 +60,7 @@ protected LDUser(Builder builder) { 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); + this.privateAttributeNames = builder.privateAttributes == null ? null : ImmutableSet.copyOf(builder.privateAttributes); } /** @@ -82,67 +76,158 @@ public LDUser(String key) { this.privateAttributeNames = null; } - 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)) { - return builtIn.get(this); - } - } - return getCustom(attribute); + /** + * Returns the user's unique key. + * + * @return the user key as a string + */ + public String getKey() { + return key.stringValue(); } - - LDValue getKey() { - return key; + + /** + * Returns the value of the secondary key property for the user, if set. + * + * @return a string or null + */ + public String getSecondary() { + return secondary.stringValue(); } - String getKeyAsString() { - return key.stringValue(); + /** + * Returns the value of the IP property for the user, if set. + * + * @return a string or null + */ + public String getIp() { + return ip.stringValue(); } - // All of the LDValue getters are guaranteed not to return null (although the LDValue may *be* a JSON null). - - LDValue getIp() { - return ip; + /** + * Returns the value of the country property for the user, if set. + * + * @return a string or null + */ + public String getCountry() { + return country.stringValue(); } - LDValue getCountry() { - return country; + /** + * Returns the value of the full name property for the user, if set. + * + * @return a string or null + */ + public String getName() { + return name.stringValue(); } - LDValue getSecondary() { - return secondary; + /** + * Returns the value of the first name property for the user, if set. + * + * @return a string or null + */ + public String getFirstName() { + return firstName.stringValue(); } - LDValue getName() { - return name; + /** + * Returns the value of the last name property for the user, if set. + * + * @return a string or null + */ + public String getLastName() { + return lastName.stringValue(); } - LDValue getFirstName() { - return firstName; + /** + * Returns the value of the email property for the user, if set. + * + * @return a string or null + */ + public String getEmail() { + return email.stringValue(); } - LDValue getLastName() { - return lastName; + /** + * Returns the value of the avatar property for the user, if set. + * + * @return a string or null + */ + public String getAvatar() { + return avatar.stringValue(); } - LDValue getEmail() { - return email; + /** + * Returns true if this user was marked anonymous. + * + * @return true for an anonymous user + */ + public boolean isAnonymous() { + return anonymous.booleanValue(); } - - LDValue getAvatar() { - return avatar; + + /** + * Gets the value of a user attribute, if present. + *

    + * This can be either a built-in attribute or a custom one. It returns the value using the {@link LDValue} + * type, which can have any type that is supported in JSON. If the attribute does not exist, it returns + * {@link LDValue#ofNull()}. + * + * @param attribute the attribute to get + * @return the attribute value or {@link LDValue#ofNull()}; will never be an actual null reference + */ + public LDValue getAttribute(UserAttribute attribute) { + if (attribute.isBuiltIn()) { + return attribute.builtInGetter.apply(this); + } else { + return custom == null ? LDValue.ofNull() : LDValue.normalize(custom.get(attribute)); + } } - LDValue getAnonymous() { - return anonymous; + /** + * Returns an enumeration of all custom attribute names that were set for this user. + * + * @return the custom attribute names + */ + public Iterable getCustomAttributes() { + return custom == null ? ImmutableList.of() : custom.keySet(); + } + + /** + * Returns an enumeration of all attributes that were marked private for this user. + *

    + * This does not include any attributes that were globally marked private in {@link LDConfig.Builder}. + * + * @return the names of private attributes for this user + */ + public Iterable getPrivateAttributes() { + return privateAttributeNames == null ? ImmutableList.of() : privateAttributeNames; + } + + /** + * Tests whether an attribute has been marked private for this user. + * + * @param attribute a built-in or custom attribute + * @return true if the attribute was marked private on a per-user level + */ + public boolean isAttributePrivate(UserAttribute attribute) { + return privateAttributeNames != null && privateAttributeNames.contains(attribute); } - LDValue getCustom(String key) { - if (custom != null) { - return LDValue.normalize(custom.get(key)); - } - return LDValue.ofNull(); + /** + * Converts the user data to its standard JSON representation. + *

    + * This is the same format that the LaunchDarkly JavaScript browser SDK uses to represent users, so + * it is the simplest way to pass user data to front-end code. + *

    + * Do not pass the {@link LDUser} object to a reflection-based JSON encoder such as Gson. Although the + * SDK uses Gson internally, it uses shading so that the Gson types are not exposed, so an external + * instance of Gson will not recognize the type adapters that provide the correct format. + * + * @return a JSON representation of the user + */ + public String toJsonString() { + return JsonHelpers.gsonInstance().toJson(this); } @Override @@ -171,125 +256,6 @@ public int hashCode() { return Objects.hash(key, secondary, ip, email, name, avatar, firstName, lastName, anonymous, country, custom, privateAttributeNames); } - // Used internally when including users in analytics events, to ensure that private attributes are stripped out. - static class UserAdapterWithPrivateAttributeBehavior extends TypeAdapter { - private final EventsConfiguration config; - - public UserAdapterWithPrivateAttributeBehavior(EventsConfiguration config) { - this.config = config; - } - - @Override - public void write(JsonWriter out, LDUser user) throws IOException { - if (user == null) { - out.value((String)null); - return; - } - - // 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 - out.name("key").value(user.getKeyAsString()); - - if (!user.getSecondary().isNull()) { - if (!checkAndAddPrivate("secondary", user, privateAttributeNames)) { - out.name("secondary").value(user.getSecondary().stringValue()); - } - } - if (!user.getIp().isNull()) { - if (!checkAndAddPrivate("ip", user, privateAttributeNames)) { - out.name("ip").value(user.getIp().stringValue()); - } - } - if (!user.getEmail().isNull()) { - if (!checkAndAddPrivate("email", user, privateAttributeNames)) { - out.name("email").value(user.getEmail().stringValue()); - } - } - if (!user.getName().isNull()) { - if (!checkAndAddPrivate("name", user, privateAttributeNames)) { - out.name("name").value(user.getName().stringValue()); - } - } - if (!user.getAvatar().isNull()) { - if (!checkAndAddPrivate("avatar", user, privateAttributeNames)) { - out.name("avatar").value(user.getAvatar().stringValue()); - } - } - if (!user.getFirstName().isNull()) { - if (!checkAndAddPrivate("firstName", user, privateAttributeNames)) { - out.name("firstName").value(user.getFirstName().stringValue()); - } - } - if (!user.getLastName().isNull()) { - if (!checkAndAddPrivate("lastName", user, privateAttributeNames)) { - out.name("lastName").value(user.getLastName().stringValue()); - } - } - if (!user.getAnonymous().isNull()) { - out.name("anonymous").value(user.getAnonymous().booleanValue()); - } - if (!user.getCountry().isNull()) { - if (!checkAndAddPrivate("country", user, privateAttributeNames)) { - out.name("country").value(user.getCountry().stringValue()); - } - } - writeCustomAttrs(out, user, privateAttributeNames); - writePrivateAttrNames(out, privateAttributeNames); - - out.endObject(); - } - - private void writePrivateAttrNames(JsonWriter out, Set names) throws IOException { - if (names.isEmpty()) { - return; - } - out.name("privateAttrs"); - out.beginArray(); - for (String name : names) { - out.value(name); - } - out.endArray(); - } - - private boolean checkAndAddPrivate(String key, LDUser user, Set privateAttrs) { - boolean result = config.allAttributesPrivate || config.privateAttrNames.contains(key) || (user.privateAttributeNames != null && user.privateAttributeNames.contains(key)); - if (result) { - privateAttrs.add(key); - } - return result; - } - - private void writeCustomAttrs(JsonWriter out, LDUser user, Set privateAttributeNames) throws IOException { - boolean beganObject = false; - if (user.custom == null) { - return; - } - for (Map.Entry entry : user.custom.entrySet()) { - if (!checkAndAddPrivate(entry.getKey(), user, privateAttributeNames)) { - if (!beganObject) { - out.name("custom"); - out.beginObject(); - beganObject = true; - } - out.name(entry.getKey()); - JsonHelpers.gsonInstance().toJson(entry.getValue(), LDValue.class, out); - } - } - if (beganObject) { - out.endObject(); - } - } - - @Override - public LDUser read(JsonReader in) throws IOException { - // We never need to unmarshal user objects, so there's no need to implement this - return null; - } - } - /** * A builder that helps construct {@link LDUser} objects. Builder * calls can be chained, enabling the following pattern: @@ -311,8 +277,8 @@ public static class Builder { private String avatar; private Boolean anonymous; private String country; - private Map custom; - private Set privateAttrNames; + private Map custom; + private Set privateAttributes; /** * Creates a builder with the specified key. @@ -329,18 +295,18 @@ public Builder(String key) { * @param user an existing {@code LDUser} */ public Builder(LDUser user) { - 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().stringValue(); + this.key = user.key.stringValue(); + this.secondary = user.secondary.stringValue(); + this.ip = user.ip.stringValue(); + this.firstName = user.firstName.stringValue(); + this.lastName = user.lastName.stringValue(); + this.email = user.email.stringValue(); + this.name = user.name.stringValue(); + this.avatar = user.avatar.stringValue(); + this.anonymous = user.anonymous.isNull() ? null : user.anonymous.booleanValue(); + this.country = user.country.stringValue(); this.custom = user.custom == null ? null : new HashMap<>(user.custom); - this.privateAttrNames = user.privateAttributeNames == null ? null : new HashSet<>(user.privateAttributeNames); + this.privateAttributes = user.privateAttributeNames == null ? null : new HashSet<>(user.privateAttributeNames); } /** @@ -361,7 +327,7 @@ public Builder ip(String s) { * @return the builder */ public Builder privateIp(String s) { - addPrivate("ip"); + addPrivate(UserAttribute.IP); return ip(s); } @@ -385,7 +351,7 @@ public Builder secondary(String s) { * @return the builder */ public Builder privateSecondary(String s) { - addPrivate("secondary"); + addPrivate(UserAttribute.SECONDARY_KEY); return secondary(s); } @@ -412,7 +378,7 @@ public Builder country(String s) { * @return the builder */ public Builder privateCountry(String s) { - addPrivate("country"); + addPrivate(UserAttribute.COUNTRY); return country(s); } @@ -435,7 +401,7 @@ public Builder firstName(String firstName) { * @return the builder */ public Builder privateFirstName(String firstName) { - addPrivate("firstName"); + addPrivate(UserAttribute.FIRST_NAME); return firstName(firstName); } @@ -469,7 +435,7 @@ public Builder lastName(String lastName) { * @return the builder */ public Builder privateLastName(String lastName) { - addPrivate("lastName"); + addPrivate(UserAttribute.LAST_NAME); return lastName(lastName); } @@ -492,7 +458,7 @@ public Builder name(String name) { * @return the builder */ public Builder privateName(String name) { - addPrivate("name"); + addPrivate(UserAttribute.NAME); return name(name); } @@ -514,7 +480,7 @@ public Builder avatar(String avatar) { * @return the builder */ public Builder privateAvatar(String avatar) { - addPrivate("avatar"); + addPrivate(UserAttribute.AVATAR); return avatar(avatar); } @@ -537,7 +503,7 @@ public Builder email(String email) { * @return the builder */ public Builder privateEmail(String email) { - addPrivate("email"); + addPrivate(UserAttribute.EMAIL); return email(email); } @@ -604,13 +570,20 @@ public Builder custom(String k, boolean b) { * @since 4.8.0 */ public Builder custom(String k, LDValue v) { - checkCustomAttribute(k); - if (k != null && v != null) { - if (custom == null) { - custom = new HashMap<>(); - } - custom.put(k, v); + if (k != null) { + return customInternal(UserAttribute.forName(k), v); + } + return this; + } + + private Builder customInternal(UserAttribute a, LDValue v) { + if (a.isBuiltIn()) { + logger.warn("Built-in attribute key: " + a.getName() + " added as custom attribute! This custom attribute will be ignored during feature flag evaluation"); + } + if (custom == null) { + custom = new HashMap<>(); } + custom.put(a, LDValue.normalize(v)); return this; } @@ -625,8 +598,7 @@ public Builder custom(String k, LDValue v) { * @return the builder */ public Builder privateCustom(String k, String v) { - addPrivate(k); - return custom(k, v); + return privateCustom(k, LDValue.of(v)); } /** @@ -640,8 +612,7 @@ public Builder privateCustom(String k, String v) { * @return the builder */ public Builder privateCustom(String k, int n) { - addPrivate(k); - return custom(k, n); + return privateCustom(k, LDValue.of(n)); } /** @@ -655,8 +626,7 @@ public Builder privateCustom(String k, int n) { * @return the builder */ public Builder privateCustom(String k, double n) { - addPrivate(k); - return custom(k, n); + return privateCustom(k, LDValue.of(n)); } /** @@ -670,8 +640,7 @@ public Builder privateCustom(String k, double n) { * @return the builder */ public Builder privateCustom(String k, boolean b) { - addPrivate(k); - return custom(k, b); + return privateCustom(k, LDValue.of(b)); } /** @@ -686,30 +655,25 @@ public Builder privateCustom(String k, boolean b) { * @since 4.8.0 */ public Builder privateCustom(String k, LDValue v) { - addPrivate(k); - return custom(k, v); - } - - private void checkCustomAttribute(String key) { - for (UserAttribute a : UserAttribute.values()) { - if (a.name().equals(key)) { - logger.warn("Built-in attribute key: " + key + " added as custom attribute! This custom attribute will be ignored during Feature Flag evaluation"); - return; - } + if (k != null) { + UserAttribute a = UserAttribute.forName(k); + addPrivate(a); + return customInternal(a, v); } + return this; } - private void addPrivate(String key) { - if (privateAttrNames == null) { - privateAttrNames = new HashSet<>(); + private void addPrivate(UserAttribute attribute) { + if (privateAttributes == null) { + privateAttributes = new HashSet<>(); } - privateAttrNames.add(key); + privateAttributes.add(attribute); } /** - * Builds the configured {@link com.launchdarkly.client.LDUser} object. + * Builds the configured {@link LDUser} object. * - * @return the {@link com.launchdarkly.client.LDUser} configured by this builder + * @return the {@link LDUser} configured by this builder */ public LDUser build() { return new LDUser(this); diff --git a/src/main/java/com/launchdarkly/client/UserAttribute.java b/src/main/java/com/launchdarkly/client/UserAttribute.java index 1da2e02a5..698dfc0f8 100644 --- a/src/main/java/com/launchdarkly/client/UserAttribute.java +++ b/src/main/java/com/launchdarkly/client/UserAttribute.java @@ -1,64 +1,145 @@ package com.launchdarkly.client; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Maps; +import com.google.gson.TypeAdapter; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; import com.launchdarkly.client.value.LDValue; -enum UserAttribute { - key { - LDValue get(LDUser user) { - return user.getKey(); - } - }, - secondary { - LDValue get(LDUser user) { - return null; //Not used for evaluation. - } - }, - ip { - LDValue get(LDUser user) { - return user.getIp(); - } - }, - email { - LDValue get(LDUser user) { - return user.getEmail(); - } - }, - avatar { - LDValue get(LDUser user) { - return user.getAvatar(); - } - }, - firstName { - LDValue get(LDUser user) { - return user.getFirstName(); - } - }, - lastName { - LDValue get(LDUser user) { - return user.getLastName(); - } - }, - name { - LDValue get(LDUser user) { - return user.getName(); - } - }, - country { - LDValue get(LDUser user) { - return user.getCountry(); - } - }, - anonymous { - LDValue get(LDUser user) { - return user.getAnonymous(); - } - }; +import java.io.IOException; +import java.util.Map; +import java.util.function.Function; +/** + * Represents a built-in or custom attribute name supported by {@link LDUser}. + *

    + * This abstraction helps to distinguish attribute names from other {@link String} values, and also + * improves efficiency in feature flag data structures and evaluations because built-in attributes + * always reuse the same instances. + * + * @since 5.0.0 + */ +@JsonAdapter(UserAttribute.UserAttributeTypeAdapter.class) +public final class UserAttribute { + /** + * Represents the user key attribute. + */ + public static final UserAttribute KEY = new UserAttribute("key", u -> u.key); + /** + * Represents the secondary key attribute. + */ + public static final UserAttribute SECONDARY_KEY = new UserAttribute("secondary", u -> u.secondary); + /** + * Represents the IP address attribute. + */ + public static final UserAttribute IP = new UserAttribute("ip", u -> u.ip); + /** + * Represents the user key attribute. + */ + public static final UserAttribute EMAIL = new UserAttribute("email", u -> u.email); + /** + * Represents the full name attribute. + */ + public static final UserAttribute NAME = new UserAttribute("name", u -> u.name); + /** + * Represents the avatar URL attribute. + */ + public static final UserAttribute AVATAR = new UserAttribute("avatar", u -> u.avatar); + /** + * Represents the first name attribute. + */ + public static final UserAttribute FIRST_NAME = new UserAttribute("firstName", u -> u.firstName); /** - * Gets value for Rule evaluation for a user. - * - * @param user - * @return + * Represents the last name attribute. */ - abstract LDValue get(LDUser user); + public static final UserAttribute LAST_NAME = new UserAttribute("lastName", u -> u.lastName); + /** + * Represents the country attribute. + */ + public static final UserAttribute COUNTRY = new UserAttribute("country", u -> u.country); + /** + * Represents the anonymous attribute. + */ + public static final UserAttribute ANONYMOUS = new UserAttribute("anonymous", u -> u.anonymous); + + private static final Map BUILTINS = Maps.uniqueIndex( + ImmutableList.of(KEY, SECONDARY_KEY, IP, EMAIL, NAME, AVATAR, FIRST_NAME, LAST_NAME, COUNTRY, ANONYMOUS), + a -> a.getName()); + + private final String name; + final Function builtInGetter; + + private UserAttribute(String name, Function builtInGetter) { + this.name = name; + this.builtInGetter = builtInGetter; + } + + /** + * Returns a UserAttribute instance for the specified attribute name. + *

    + * For built-in attributes, the same instances are always reused and {@link #isBuiltIn()} will + * return true. For custom attributes, a new instance is created and {@link #isBuiltIn()} will + * return false. + * + * @param name the attribute name + * @return a {@link UserAttribute} + */ + public static UserAttribute forName(String name) { + UserAttribute a = BUILTINS.get(name); + return a != null ? a : new UserAttribute(name, null); + } + + /** + * Returns the case-sensitive attribute name. + * + * @return the attribute name + */ + public String getName() { + return name; + } + + /** + * Returns true for a built-in attribute or false for a custom attribute. + * + * @return true if it is a built-in attribute + */ + public boolean isBuiltIn() { + return builtInGetter != null; + } + + @Override + public boolean equals(Object other) { + if (other instanceof UserAttribute) { + UserAttribute o = (UserAttribute)other; + if (isBuiltIn() || o.isBuiltIn()) { + return this == o; // faster comparison since built-in instances are interned + } + return name.equals(o.name); + } + return false; + } + + @Override + public int hashCode() { + return isBuiltIn() ? super.hashCode() : name.hashCode(); + } + + @Override + public String toString() { + return name; + } + + private static final class UserAttributeTypeAdapter extends TypeAdapter{ + @Override + public UserAttribute read(JsonReader reader) throws IOException { + return UserAttribute.forName(reader.nextString()); + } + + @Override + public void write(JsonWriter writer, UserAttribute value) throws IOException { + writer.value(value.getName()); + } + } } diff --git a/src/main/java/com/launchdarkly/client/integrations/EventProcessorBuilder.java b/src/main/java/com/launchdarkly/client/integrations/EventProcessorBuilder.java index a21671a47..d1e6a587b 100644 --- a/src/main/java/com/launchdarkly/client/integrations/EventProcessorBuilder.java +++ b/src/main/java/com/launchdarkly/client/integrations/EventProcessorBuilder.java @@ -1,6 +1,7 @@ package com.launchdarkly.client.integrations; import com.launchdarkly.client.Components; +import com.launchdarkly.client.UserAttribute; import com.launchdarkly.client.interfaces.EventProcessorFactory; import java.net.URI; @@ -62,7 +63,7 @@ public abstract class EventProcessorBuilder implements EventProcessorFactory { protected Duration diagnosticRecordingInterval = DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL; protected Duration flushInterval = DEFAULT_FLUSH_INTERVAL; protected boolean inlineUsersInEvents = false; - protected Set privateAttrNames; + protected Set privateAttributes; protected int userKeysCapacity = DEFAULT_USER_KEYS_CAPACITY; protected Duration userKeysFlushInterval = DEFAULT_USER_KEYS_FLUSH_INTERVAL; @@ -175,6 +176,9 @@ public EventProcessorBuilder inlineUsersInEvents(boolean inlineUsersInEvents) { * 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. + *

    + * Using {@link #privateAttributes(UserAttribute...)} is preferable to avoid the possibility of + * misspelling a built-in attribute. * * @param attributeNames a set of names that will be removed from user data set to LaunchDarkly * @return the builder @@ -182,7 +186,28 @@ public EventProcessorBuilder inlineUsersInEvents(boolean inlineUsersInEvents) { * @see com.launchdarkly.client.LDUser.Builder */ public EventProcessorBuilder privateAttributeNames(String... attributeNames) { - this.privateAttrNames = new HashSet<>(Arrays.asList(attributeNames)); + privateAttributes = new HashSet<>(); + for (String a: attributeNames) { + privateAttributes.add(UserAttribute.forName(a)); + } + 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 attributes a set of attributes that will be removed from user data set to LaunchDarkly + * @return the builder + * @see #allAttributesPrivate(boolean) + * @see com.launchdarkly.client.LDUser.Builder + * @see #privateAttributeNames + */ + public EventProcessorBuilder privateAttributes(UserAttribute... attributes) { + privateAttributes = new HashSet<>(Arrays.asList(attributes)); return this; } diff --git a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java index d275c9b30..78743286f 100644 --- a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java @@ -86,7 +86,7 @@ public void builderHasDefaultConfiguration() throws Exception { assertThat(ec.eventsUri, equalTo(LDConfig.DEFAULT_EVENTS_URI)); assertThat(ec.flushInterval, equalTo(EventProcessorBuilder.DEFAULT_FLUSH_INTERVAL)); assertThat(ec.inlineUsersInEvents, is(false)); - assertThat(ec.privateAttrNames, equalTo(ImmutableSet.of())); + assertThat(ec.privateAttributes, equalTo(ImmutableSet.of())); assertThat(ec.samplingInterval, equalTo(0)); assertThat(ec.userKeysCapacity, equalTo(EventProcessorBuilder.DEFAULT_USER_KEYS_CAPACITY)); assertThat(ec.userKeysFlushInterval, equalTo(EventProcessorBuilder.DEFAULT_USER_KEYS_FLUSH_INTERVAL)); @@ -102,7 +102,7 @@ public void builderCanSpecifyConfiguration() throws Exception { .capacity(3333) .diagnosticRecordingInterval(Duration.ofSeconds(480)) .flushInterval(Duration.ofSeconds(99)) - .privateAttributeNames("cats", "dogs") + .privateAttributeNames("name", "dogs") .userKeysCapacity(555) .userKeysFlushInterval(Duration.ofSeconds(101)); try (DefaultEventProcessor ep = (DefaultEventProcessor)epf.createEventProcessor(clientContext(SDK_KEY, LDConfig.DEFAULT))) { @@ -113,7 +113,7 @@ public void builderCanSpecifyConfiguration() throws Exception { assertThat(ec.eventsUri, equalTo(uri)); assertThat(ec.flushInterval, equalTo(Duration.ofSeconds(99))); assertThat(ec.inlineUsersInEvents, is(false)); // will test this separately below - assertThat(ec.privateAttrNames, equalTo(ImmutableSet.of("cats", "dogs"))); + assertThat(ec.privateAttributes, equalTo(ImmutableSet.of(UserAttribute.NAME, UserAttribute.forName("dogs")))); assertThat(ec.samplingInterval, equalTo(0)); // can only set this with the deprecated config API assertThat(ec.userKeysCapacity, equalTo(555)); assertThat(ec.userKeysFlushInterval, equalTo(Duration.ofSeconds(101))); @@ -941,7 +941,7 @@ private Matcher isFeatureEvent(Event.FeatureRequest sourceEvent, DataMo hasJsonProperty("version", (double)flag.getVersion()), hasJsonProperty("variation", sourceEvent.getVariation()), hasJsonProperty("value", sourceEvent.getValue()), - hasJsonProperty("userKey", inlineUser == null ? LDValue.of(sourceEvent.getUser().getKeyAsString()) : LDValue.ofNull()), + hasJsonProperty("userKey", inlineUser == null ? LDValue.of(sourceEvent.getUser().getKey()) : LDValue.ofNull()), hasJsonProperty("user", inlineUser == null ? LDValue.ofNull() : inlineUser), hasJsonProperty("reason", reason == null ? LDValue.ofNull() : LDValue.parse(gson.toJson(reason))) ); @@ -953,7 +953,7 @@ private Matcher isCustomEvent(Event.Custom sourceEvent, LDValue inlineU hasJsonProperty("kind", "custom"), hasJsonProperty("creationDate", (double)sourceEvent.getCreationDate()), hasJsonProperty("key", "eventkey"), - hasJsonProperty("userKey", inlineUser == null ? LDValue.of(sourceEvent.getUser().getKeyAsString()) : LDValue.ofNull()), + hasJsonProperty("userKey", inlineUser == null ? LDValue.of(sourceEvent.getUser().getKey()) : LDValue.ofNull()), hasJsonProperty("user", inlineUser == null ? LDValue.ofNull() : inlineUser), hasJsonProperty("data", sourceEvent.getData()), hasJsonProperty("metricValue", sourceEvent.getMetricValue() == null ? LDValue.ofNull() : LDValue.of(sourceEvent.getMetricValue())) diff --git a/src/test/java/com/launchdarkly/client/EvaluatorBucketingTest.java b/src/test/java/com/launchdarkly/client/EvaluatorBucketingTest.java index 9660f7aaa..06db2c3ba 100644 --- a/src/test/java/com/launchdarkly/client/EvaluatorBucketingTest.java +++ b/src/test/java/com/launchdarkly/client/EvaluatorBucketingTest.java @@ -24,7 +24,7 @@ public void variationIndexIsReturnedForBucket() { // 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)(EvaluatorBucketing.bucketUser(user, flagKey, "key", salt) * 100000); + int bucketValue = (int)(EvaluatorBucketing.bucketUser(user, flagKey, UserAttribute.KEY, salt) * 100000); assertThat(bucketValue, greaterThanOrEqualTo(1)); assertThat(bucketValue, Matchers.lessThan(100000)); @@ -46,7 +46,7 @@ public void lastBucketIsUsedIfBucketValueEqualsTotalWeight() { String salt = "salt"; // We'll construct a list of variations that stops right at the target bucket value - int bucketValue = (int)(EvaluatorBucketing.bucketUser(user, flagKey, "key", salt) * 100000); + int bucketValue = (int)(EvaluatorBucketing.bucketUser(user, flagKey, UserAttribute.KEY, salt) * 100000); List variations = Arrays.asList(new WeightedVariation(0, bucketValue)); VariationOrRollout vr = new VariationOrRollout(null, new Rollout(variations, null)); @@ -61,8 +61,8 @@ public void canBucketByIntAttributeSameAsString() { .custom("stringattr", "33333") .custom("intattr", 33333) .build(); - float resultForString = EvaluatorBucketing.bucketUser(user, "key", "stringattr", "salt"); - float resultForInt = EvaluatorBucketing.bucketUser(user, "key", "intattr", "salt"); + float resultForString = EvaluatorBucketing.bucketUser(user, "key", UserAttribute.forName("stringattr"), "salt"); + float resultForInt = EvaluatorBucketing.bucketUser(user, "key", UserAttribute.forName("intattr"), "salt"); assertEquals(resultForString, resultForInt, Float.MIN_VALUE); } @@ -71,7 +71,7 @@ public void cannotBucketByFloatAttribute() { LDUser user = new LDUser.Builder("key") .custom("floatattr", 33.5f) .build(); - float result = EvaluatorBucketing.bucketUser(user, "key", "floatattr", "salt"); + float result = EvaluatorBucketing.bucketUser(user, "key", UserAttribute.forName("floatattr"), "salt"); assertEquals(0f, result, Float.MIN_VALUE); } @@ -80,7 +80,7 @@ public void cannotBucketByBooleanAttribute() { LDUser user = new LDUser.Builder("key") .custom("boolattr", true) .build(); - float result = EvaluatorBucketing.bucketUser(user, "key", "boolattr", "salt"); + float result = EvaluatorBucketing.bucketUser(user, "key", UserAttribute.forName("boolattr"), "salt"); assertEquals(0f, result, Float.MIN_VALUE); } } diff --git a/src/test/java/com/launchdarkly/client/EvaluatorClauseTest.java b/src/test/java/com/launchdarkly/client/EvaluatorClauseTest.java index ef0ad8a71..54753fd44 100644 --- a/src/test/java/com/launchdarkly/client/EvaluatorClauseTest.java +++ b/src/test/java/com/launchdarkly/client/EvaluatorClauseTest.java @@ -22,7 +22,7 @@ public class EvaluatorClauseTest { @Test public void clauseCanMatchBuiltInAttribute() throws Exception { - DataModel.Clause clause = clause("name", DataModel.Operator.in, LDValue.of("Bob")); + DataModel.Clause clause = clause(UserAttribute.NAME, DataModel.Operator.in, LDValue.of("Bob")); DataModel.FeatureFlag f = booleanFlagWithClauses("flag", clause); LDUser user = new LDUser.Builder("key").name("Bob").build(); @@ -31,7 +31,7 @@ public void clauseCanMatchBuiltInAttribute() throws Exception { @Test public void clauseCanMatchCustomAttribute() throws Exception { - DataModel.Clause clause = clause("legs", DataModel.Operator.in, LDValue.of(4)); + DataModel.Clause clause = clause(UserAttribute.forName("legs"), DataModel.Operator.in, LDValue.of(4)); DataModel.FeatureFlag f = booleanFlagWithClauses("flag", clause); LDUser user = new LDUser.Builder("key").custom("legs", 4).build(); @@ -40,7 +40,7 @@ public void clauseCanMatchCustomAttribute() throws Exception { @Test public void clauseReturnsFalseForMissingAttribute() throws Exception { - DataModel.Clause clause = clause("legs", DataModel.Operator.in, LDValue.of(4)); + DataModel.Clause clause = clause(UserAttribute.forName("legs"), DataModel.Operator.in, LDValue.of(4)); DataModel.FeatureFlag f = booleanFlagWithClauses("flag", clause); LDUser user = new LDUser.Builder("key").name("Bob").build(); @@ -49,7 +49,7 @@ public void clauseReturnsFalseForMissingAttribute() throws Exception { @Test public void clauseCanBeNegated() throws Exception { - DataModel.Clause clause = clause("name", DataModel.Operator.in, true, LDValue.of("Bob")); + DataModel.Clause clause = clause(UserAttribute.NAME, DataModel.Operator.in, true, LDValue.of("Bob")); DataModel.FeatureFlag f = booleanFlagWithClauses("flag", clause); LDUser user = new LDUser.Builder("key").name("Bob").build(); @@ -73,7 +73,7 @@ public void clauseWithUnsupportedOperatorStringIsUnmarshalledWithNullOperator() @Test public void clauseWithNullOperatorDoesNotMatch() throws Exception { - DataModel.Clause badClause = clause("name", null, LDValue.of("Bob")); + DataModel.Clause badClause = clause(UserAttribute.NAME, null, LDValue.of("Bob")); DataModel.FeatureFlag f = booleanFlagWithClauses("flag", badClause); LDUser user = new LDUser.Builder("key").name("Bob").build(); @@ -82,9 +82,9 @@ public void clauseWithNullOperatorDoesNotMatch() throws Exception { @Test public void clauseWithNullOperatorDoesNotStopSubsequentRuleFromMatching() throws Exception { - DataModel.Clause badClause = clause("name", null, LDValue.of("Bob")); + DataModel.Clause badClause = clause(UserAttribute.NAME, null, LDValue.of("Bob")); DataModel.Rule badRule = ruleBuilder().id("rule1").clauses(badClause).variation(1).build(); - DataModel.Clause goodClause = clause("name", DataModel.Operator.in, LDValue.of("Bob")); + DataModel.Clause goodClause = clause(UserAttribute.NAME, DataModel.Operator.in, LDValue.of("Bob")); DataModel.Rule goodRule = ruleBuilder().id("rule2").clauses(goodClause).variation(1).build(); DataModel.FeatureFlag f = flagBuilder("feature") .on(true) @@ -125,7 +125,7 @@ public void testSegmentMatchClauseFallsThroughIfSegmentNotFound() throws Excepti } private DataModel.FeatureFlag segmentMatchBooleanFlag(String segmentKey) { - DataModel.Clause clause = clause("", DataModel.Operator.segmentMatch, LDValue.of(segmentKey)); + DataModel.Clause clause = clause(null, DataModel.Operator.segmentMatch, LDValue.of(segmentKey)); return booleanFlagWithClauses("flag", clause); } } diff --git a/src/test/java/com/launchdarkly/client/EvaluatorRuleTest.java b/src/test/java/com/launchdarkly/client/EvaluatorRuleTest.java index 2ae15bcbe..899b32691 100644 --- a/src/test/java/com/launchdarkly/client/EvaluatorRuleTest.java +++ b/src/test/java/com/launchdarkly/client/EvaluatorRuleTest.java @@ -19,8 +19,8 @@ public class EvaluatorRuleTest { @Test public void ruleMatchReasonInstanceIsReusedForSameRule() { - DataModel.Clause clause0 = clause("key", DataModel.Operator.in, LDValue.of("wrongkey")); - DataModel.Clause clause1 = clause("key", DataModel.Operator.in, LDValue.of("userkey")); + DataModel.Clause clause0 = clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of("wrongkey")); + DataModel.Clause clause1 = clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of("userkey")); DataModel.Rule rule0 = ruleBuilder().id("ruleid0").clauses(clause0).variation(2).build(); DataModel.Rule rule1 = ruleBuilder().id("ruleid1").clauses(clause1).variation(2).build(); DataModel.FeatureFlag f = featureFlagWithRules("feature", rule0, rule1); @@ -39,7 +39,7 @@ public void ruleMatchReasonInstanceIsReusedForSameRule() { @Test public void ruleWithTooHighVariationReturnsMalformedFlagError() { - DataModel.Clause clause = clause("key", DataModel.Operator.in, LDValue.of("userkey")); + DataModel.Clause clause = clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of("userkey")); DataModel.Rule rule = ruleBuilder().id("ruleid").clauses(clause).variation(999).build(); DataModel.FeatureFlag f = featureFlagWithRules("feature", rule); LDUser user = new LDUser.Builder("userkey").build(); @@ -51,7 +51,7 @@ public void ruleWithTooHighVariationReturnsMalformedFlagError() { @Test public void ruleWithNegativeVariationReturnsMalformedFlagError() { - DataModel.Clause clause = clause("key", DataModel.Operator.in, LDValue.of("userkey")); + DataModel.Clause clause = clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of("userkey")); DataModel.Rule rule = ruleBuilder().id("ruleid").clauses(clause).variation(-1).build(); DataModel.FeatureFlag f = featureFlagWithRules("feature", rule); LDUser user = new LDUser.Builder("userkey").build(); @@ -63,7 +63,7 @@ public void ruleWithNegativeVariationReturnsMalformedFlagError() { @Test public void ruleWithNoVariationOrRolloutReturnsMalformedFlagError() { - DataModel.Clause clause = clause("key", DataModel.Operator.in, LDValue.of("userkey")); + DataModel.Clause clause = clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of("userkey")); DataModel.Rule rule = ruleBuilder().id("ruleid").clauses(clause).build(); DataModel.FeatureFlag f = featureFlagWithRules("feature", rule); LDUser user = new LDUser.Builder("userkey").build(); @@ -75,7 +75,7 @@ public void ruleWithNoVariationOrRolloutReturnsMalformedFlagError() { @Test public void ruleWithRolloutWithEmptyVariationsListReturnsMalformedFlagError() { - DataModel.Clause clause = clause("key", DataModel.Operator.in, LDValue.of("userkey")); + DataModel.Clause clause = clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of("userkey")); DataModel.Rule rule = ruleBuilder().id("ruleid").clauses(clause).rollout(emptyRollout()).build(); DataModel.FeatureFlag f = featureFlagWithRules("feature", rule); LDUser user = new LDUser.Builder("userkey").build(); diff --git a/src/test/java/com/launchdarkly/client/EvaluatorSegmentMatchTest.java b/src/test/java/com/launchdarkly/client/EvaluatorSegmentMatchTest.java index 931dea809..87f7b9c27 100644 --- a/src/test/java/com/launchdarkly/client/EvaluatorSegmentMatchTest.java +++ b/src/test/java/com/launchdarkly/client/EvaluatorSegmentMatchTest.java @@ -56,7 +56,7 @@ public void explicitIncludeHasPrecedence() { @Test public void matchingRuleWithFullRollout() { - DataModel.Clause clause = clause("email", DataModel.Operator.in, LDValue.of("test@example.com")); + DataModel.Clause clause = clause(UserAttribute.EMAIL, DataModel.Operator.in, LDValue.of("test@example.com")); DataModel.SegmentRule rule = segmentRuleBuilder().clauses(clause).weight(maxWeight).build(); DataModel.Segment s = segmentBuilder("test") .salt("abcdef") @@ -69,7 +69,7 @@ public void matchingRuleWithFullRollout() { @Test public void matchingRuleWithZeroRollout() { - DataModel.Clause clause = clause("email", DataModel.Operator.in, LDValue.of("test@example.com")); + DataModel.Clause clause = clause(UserAttribute.EMAIL, DataModel.Operator.in, LDValue.of("test@example.com")); DataModel.SegmentRule rule = segmentRuleBuilder().clauses(clause).weight(0).build(); DataModel.Segment s = segmentBuilder("test") .salt("abcdef") @@ -82,8 +82,8 @@ public void matchingRuleWithZeroRollout() { @Test public void matchingRuleWithMultipleClauses() { - DataModel.Clause clause1 = clause("email", DataModel.Operator.in, LDValue.of("test@example.com")); - DataModel.Clause clause2 = clause("name", DataModel.Operator.in, LDValue.of("bob")); + DataModel.Clause clause1 = clause(UserAttribute.EMAIL, DataModel.Operator.in, LDValue.of("test@example.com")); + DataModel.Clause clause2 = clause(UserAttribute.NAME, DataModel.Operator.in, LDValue.of("bob")); DataModel.SegmentRule rule = segmentRuleBuilder().clauses(clause1, clause2).build(); DataModel.Segment s = segmentBuilder("test") .salt("abcdef") @@ -96,8 +96,8 @@ public void matchingRuleWithMultipleClauses() { @Test public void nonMatchingRuleWithMultipleClauses() { - DataModel.Clause clause1 = clause("email", DataModel.Operator.in, LDValue.of("test@example.com")); - DataModel.Clause clause2 = clause("name", DataModel.Operator.in, LDValue.of("bill")); + DataModel.Clause clause1 = clause(UserAttribute.EMAIL, DataModel.Operator.in, LDValue.of("test@example.com")); + DataModel.Clause clause2 = clause(UserAttribute.NAME, DataModel.Operator.in, LDValue.of("bill")); DataModel.SegmentRule rule = segmentRuleBuilder().clauses(clause1, clause2).build(); DataModel.Segment s = segmentBuilder("test") .salt("abcdef") @@ -109,7 +109,7 @@ public void nonMatchingRuleWithMultipleClauses() { } private static boolean segmentMatchesUser(DataModel.Segment segment, LDUser user) { - DataModel.Clause clause = clause("", DataModel.Operator.segmentMatch, LDValue.of(segment.getKey())); + DataModel.Clause clause = clause(null, DataModel.Operator.segmentMatch, LDValue.of(segment.getKey())); DataModel.FeatureFlag flag = booleanFlagWithClauses("flag", clause); Evaluator e = evaluatorBuilder().withStoredSegments(segment).build(); return e.evaluate(flag, user, EventFactory.DEFAULT).getValue().booleanValue(); diff --git a/src/test/java/com/launchdarkly/client/EvaluatorTest.java b/src/test/java/com/launchdarkly/client/EvaluatorTest.java index a98ff2a6e..633f08d56 100644 --- a/src/test/java/com/launchdarkly/client/EvaluatorTest.java +++ b/src/test/java/com/launchdarkly/client/EvaluatorTest.java @@ -336,8 +336,8 @@ public void flagMatchesUserFromTargets() throws Exception { @Test public void flagMatchesUserFromRules() { - DataModel.Clause clause0 = clause("key", DataModel.Operator.in, LDValue.of("wrongkey")); - DataModel.Clause clause1 = clause("key", DataModel.Operator.in, LDValue.of("userkey")); + DataModel.Clause clause0 = clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of("wrongkey")); + DataModel.Clause clause1 = clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of("userkey")); DataModel.Rule rule0 = ruleBuilder().id("ruleid0").clauses(clause0).variation(2).build(); DataModel.Rule rule1 = ruleBuilder().id("ruleid1").clauses(clause1).variation(2).build(); DataModel.FeatureFlag f = featureFlagWithRules("feature", rule0, rule1); diff --git a/src/test/java/com/launchdarkly/client/EventOutputTest.java b/src/test/java/com/launchdarkly/client/EventOutputTest.java index 51f799c97..a2aa9e0be 100644 --- a/src/test/java/com/launchdarkly/client/EventOutputTest.java +++ b/src/test/java/com/launchdarkly/client/EventOutputTest.java @@ -79,7 +79,7 @@ public void userKeyIsSetInsteadOfUserWhenNotInlined() throws Exception { LDValue.ofNull()); LDValue outputEvent = getSingleOutputEvent(f, featureEvent); assertEquals(LDValue.ofNull(), outputEvent.get("user")); - assertEquals(user.getKey(), outputEvent.get("userKey")); + assertEquals(LDValue.of(user.getKey()), outputEvent.get("userKey")); Event.Identify identifyEvent = EventFactory.DEFAULT.newIdentifyEvent(user); outputEvent = getSingleOutputEvent(f, identifyEvent); @@ -89,7 +89,7 @@ public void userKeyIsSetInsteadOfUserWhenNotInlined() throws Exception { 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")); + assertEquals(LDValue.of(user.getKey()), outputEvent.get("userKey")); Event.Index indexEvent = new Event.Index(0, user); outputEvent = getSingleOutputEvent(f, indexEvent); @@ -108,7 +108,7 @@ public void allAttributesPrivateMakesAttributesPrivate() throws Exception { public void globalPrivateAttributeNamesMakeAttributesPrivate() throws Exception { LDUser user = userBuilderWithAllAttributes.build(); for (String attrName: attributesThatCanBePrivate) { - EventsConfiguration config = TestUtil.makeEventsConfig(false, false, ImmutableSet.of(attrName)); + EventsConfiguration config = TestUtil.makeEventsConfig(false, false, ImmutableSet.of(UserAttribute.forName(attrName))); testPrivateAttributes(config, user, attrName); } } @@ -384,7 +384,7 @@ private LDValue getSingleOutputEvent(EventOutputFormatter f, Event event) throws } private void testInlineUserSerialization(LDUser user, LDValue expectedJsonValue, EventsConfiguration baseConfig) throws IOException { - EventsConfiguration config = TestUtil.makeEventsConfig(baseConfig.allAttributesPrivate, true, baseConfig.privateAttrNames); + EventsConfiguration config = TestUtil.makeEventsConfig(baseConfig.allAttributesPrivate, true, baseConfig.privateAttributes); EventOutputFormatter f = new EventOutputFormatter(config); Event.FeatureRequest featureEvent = EventFactory.DEFAULT.newFeatureRequestEvent( diff --git a/src/test/java/com/launchdarkly/client/EventUserSerializationTest.java b/src/test/java/com/launchdarkly/client/EventUserSerializationTest.java new file mode 100644 index 000000000..3d0f606b7 --- /dev/null +++ b/src/test/java/com/launchdarkly/client/EventUserSerializationTest.java @@ -0,0 +1,147 @@ +package com.launchdarkly.client; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.reflect.TypeToken; + +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.JsonHelpers.gsonInstance; +import static com.launchdarkly.client.JsonHelpers.gsonInstanceForEventsSerialization; +import static com.launchdarkly.client.TestUtil.defaultEventsConfig; +import static com.launchdarkly.client.TestUtil.makeEventsConfig; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +@SuppressWarnings("javadoc") +public class EventUserSerializationTest { + + @Test + public void testAllPropertiesInPrivateAttributeEncoding() { + for (Map.Entry e: getUserPropertiesJsonMap().entrySet()) { + JsonElement expected = gsonInstance().fromJson(e.getValue(), JsonElement.class); + JsonElement actual = gsonInstance().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("value").build(), + "{\"key\":\"userkey\",\"country\":\"value\"}"); + 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").build(); + String expected = "{\"key\":\"userkey\",\"name\":\"x\",\"privateAttributeNames\":[\"name\"]}"; + assertEquals(gsonInstance().fromJson(expected, JsonElement.class), gsonInstance().toJsonTree(user)); + } + + @Test + public void privateAttributeEncodingRedactsAllPrivateAttributes() { + EventsConfiguration config = makeEventsConfig(true, false, null); + LDUser user = new LDUser.Builder("userkey") + .secondary("s") + .ip("i") + .email("e") + .name("n") + .avatar("a") + .firstName("f") + .lastName("l") + .anonymous(true) + .country("USA") + .custom("thing", "value") + .build(); + Set redacted = ImmutableSet.of("secondary", "ip", "email", "name", "avatar", "firstName", "lastName", "country", "thing"); + + JsonObject o = gsonInstanceForEventsSerialization(config).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 privateAttributeEncodingRedactsSpecificPerUserPrivateAttributes() { + LDUser user = new LDUser.Builder("userkey") + .email("e") + .privateName("n") + .custom("bar", 43) + .privateCustom("foo", 42) + .build(); + + 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()); + assertNull(o.get("custom").getAsJsonObject().get("foo")); + assertEquals(ImmutableSet.of("name", "foo"), getPrivateAttrs(o)); + } + + @Test + public void privateAttributeEncodingRedactsSpecificGlobalPrivateAttributes() { + EventsConfiguration config = makeEventsConfig(false, false, + ImmutableSet.of(UserAttribute.NAME, UserAttribute.forName("foo"))); + LDUser user = new LDUser.Builder("userkey") + .email("e") + .name("n") + .custom("bar", 43) + .custom("foo", 42) + .build(); + + 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()); + assertNull(o.get("custom").getAsJsonObject().get("foo")); + assertEquals(ImmutableSet.of("name", "foo"), getPrivateAttrs(o)); + } + + @Test + public void privateAttributeEncodingWorksForMinimalUser() { + EventsConfiguration config = makeEventsConfig(true, false, null); + LDUser user = new LDUser("userkey"); + + JsonObject o = gsonInstanceForEventsSerialization(config).toJsonTree(user).getAsJsonObject(); + JsonObject expected = new JsonObject(); + expected.addProperty("key", "userkey"); + assertEquals(expected, o); + } + + private Set getPrivateAttrs(JsonObject o) { + Type type = new TypeToken>(){}.getType(); + return new HashSet(gsonInstance().>fromJson(o.get("privateAttrs"), type)); + } +} diff --git a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java index bc5c591f6..742168b15 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java @@ -177,11 +177,11 @@ public void canMatchUserBySegment() throws Exception { // This is similar to one of the tests in FeatureFlagTest, but more end-to-end DataModel.Segment segment = segmentBuilder("segment1") .version(1) - .included(user.getKeyAsString()) + .included(user.getKey()) .build(); upsertSegment(dataStore, segment); - DataModel.Clause clause = clause("", DataModel.Operator.segmentMatch, LDValue.of("segment1")); + DataModel.Clause clause = clause(null, DataModel.Operator.segmentMatch, LDValue.of("segment1")); DataModel.FeatureFlag feature = booleanFlagWithClauses("feature", clause); upsertFlag(dataStore, feature); diff --git a/src/test/java/com/launchdarkly/client/LDUserTest.java b/src/test/java/com/launchdarkly/client/LDUserTest.java index 65d064658..750be7209 100644 --- a/src/test/java/com/launchdarkly/client/LDUserTest.java +++ b/src/test/java/com/launchdarkly/client/LDUserTest.java @@ -1,49 +1,180 @@ package com.launchdarkly.client; -import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; -import com.google.gson.Gson; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.reflect.TypeToken; +import com.google.common.collect.Iterables; import com.launchdarkly.client.value.LDValue; 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 java.util.function.BiFunction; +import java.util.function.Function; -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.makeEventsConfig; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.emptyIterable; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; @SuppressWarnings("javadoc") public class LDUserTest { - private static final Gson defaultGson = new Gson(); + private static enum OptionalStringAttributes { + secondary(LDUser::getSecondary, LDUser.Builder::secondary, LDUser.Builder::privateSecondary), + ip(LDUser::getIp, LDUser.Builder::ip, LDUser.Builder::privateIp), + firstName(LDUser::getFirstName, LDUser.Builder::firstName, LDUser.Builder::privateFirstName), + lastName(LDUser::getLastName, LDUser.Builder::lastName, LDUser.Builder::privateLastName), + email(LDUser::getEmail, LDUser.Builder::email, LDUser.Builder::privateEmail), + name(LDUser::getName, LDUser.Builder::name, LDUser.Builder::privateName), + avatar(LDUser::getAvatar, LDUser.Builder::avatar, LDUser.Builder::privateAvatar), + country(LDUser::getCountry, LDUser.Builder::country, LDUser.Builder::privateCountry); + + final UserAttribute attribute; + final Function getter; + final BiFunction setter; + final BiFunction privateSetter; + + OptionalStringAttributes( + Function getter, + BiFunction setter, + BiFunction privateSetter + ) { + this.attribute = UserAttribute.forName(this.name()); + this.getter = getter; + this.setter = setter; + this.privateSetter = privateSetter; + } + }; @Test - public void simpleConstructorSetsAttributes() { + public void simpleConstructorSetsKey() { 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")); + assertEquals("key", user.getKey()); + assertEquals(LDValue.of("key"), user.getAttribute(UserAttribute.KEY)); + for (OptionalStringAttributes a: OptionalStringAttributes.values()) { + assertNull(a.toString(), a.getter.apply(user)); + assertEquals(a.toString(), LDValue.ofNull(), user.getAttribute(a.attribute)); + } + assertThat(user.isAnonymous(), is(false)); + assertThat(user.getAttribute(UserAttribute.ANONYMOUS), equalTo(LDValue.ofNull())); + assertThat(user.getAttribute(UserAttribute.forName("custom-attr")), equalTo(LDValue.ofNull())); + assertThat(user.getCustomAttributes(), emptyIterable()); + assertThat(user.getPrivateAttributes(), emptyIterable()); } + @Test + public void builderSetsOptionalStringAttribute() { + for (OptionalStringAttributes a: OptionalStringAttributes.values()) { + String value = "value-of-" + a.name(); + LDUser.Builder builder = new LDUser.Builder("key"); + a.setter.apply(builder, value); + LDUser user = builder.build(); + for (OptionalStringAttributes a1: OptionalStringAttributes.values()) { + if (a1 == a) { + assertEquals(a.toString(), value, a1.getter.apply(user)); + assertEquals(a.toString(), LDValue.of(value), user.getAttribute(a1.attribute)); + } else { + assertNull(a.toString(), a1.getter.apply(user)); + assertEquals(a.toString(), LDValue.ofNull(), user.getAttribute(a1.attribute)); + } + } + assertThat(user.isAnonymous(), is(false)); + assertThat(user.getAttribute(UserAttribute.ANONYMOUS), equalTo(LDValue.ofNull())); + assertThat(user.getAttribute(UserAttribute.forName("custom-attr")), equalTo(LDValue.ofNull())); + assertThat(user.getCustomAttributes(), emptyIterable()); + assertThat(user.getPrivateAttributes(), emptyIterable()); + assertFalse(user.isAttributePrivate(a.attribute)); + } + } + + @Test + public void builderSetsPrivateOptionalStringAttribute() { + for (OptionalStringAttributes a: OptionalStringAttributes.values()) { + String value = "value-of-" + a.name(); + LDUser.Builder builder = new LDUser.Builder("key"); + a.privateSetter.apply(builder, value); + LDUser user = builder.build(); + for (OptionalStringAttributes a1: OptionalStringAttributes.values()) { + if (a1 == a) { + assertEquals(a.toString(), value, a1.getter.apply(user)); + assertEquals(a.toString(), LDValue.of(value), user.getAttribute(a1.attribute)); + } else { + assertNull(a.toString(), a1.getter.apply(user)); + assertEquals(a.toString(), LDValue.ofNull(), user.getAttribute(a1.attribute)); + } + } + assertThat(user.isAnonymous(), is(false)); + assertThat(user.getAttribute(UserAttribute.ANONYMOUS), equalTo(LDValue.ofNull())); + assertThat(user.getAttribute(UserAttribute.forName("custom-attr")), equalTo(LDValue.ofNull())); + assertThat(user.getCustomAttributes(), emptyIterable()); + assertThat(user.getPrivateAttributes(), contains(a.attribute)); + assertTrue(user.isAttributePrivate(a.attribute)); + } + } + + @Test + public void builderSetsCustomAttributes() { + LDValue boolValue = LDValue.of(true), + intValue = LDValue.of(2), + floatValue = LDValue.of(2.5), + stringValue = LDValue.of("x"), + jsonValue = LDValue.buildArray().build(); + LDUser user = new LDUser.Builder("key") + .custom("custom-bool", boolValue.booleanValue()) + .custom("custom-int", intValue.intValue()) + .custom("custom-float", floatValue.floatValue()) + .custom("custom-double", floatValue.doubleValue()) + .custom("custom-string", stringValue.stringValue()) + .custom("custom-json", jsonValue) + .build(); + Iterable names = ImmutableList.of("custom-bool", "custom-int", "custom-float", "custom-double", "custom-string", "custom-json"); + assertThat(user.getAttribute(UserAttribute.forName("custom-bool")), equalTo(boolValue)); + assertThat(user.getAttribute(UserAttribute.forName("custom-int")), equalTo(intValue)); + assertThat(user.getAttribute(UserAttribute.forName("custom-float")), equalTo(floatValue)); + assertThat(user.getAttribute(UserAttribute.forName("custom-double")), equalTo(floatValue)); + assertThat(user.getAttribute(UserAttribute.forName("custom-string")), equalTo(stringValue)); + assertThat(user.getAttribute(UserAttribute.forName("custom-json")), equalTo(jsonValue)); + assertThat(ImmutableSet.copyOf(user.getCustomAttributes()), + equalTo(ImmutableSet.copyOf(Iterables.transform(names, UserAttribute::forName)))); + assertThat(user.getPrivateAttributes(), emptyIterable()); + for (String name: names) { + assertThat(name, user.isAttributePrivate(UserAttribute.forName(name)), is(false)); + } + } + + @Test + public void builderSetsPrivateCustomAttributes() { + LDValue boolValue = LDValue.of(true), + intValue = LDValue.of(2), + floatValue = LDValue.of(2.5), + stringValue = LDValue.of("x"), + jsonValue = LDValue.buildArray().build(); + LDUser user = new LDUser.Builder("key") + .privateCustom("custom-bool", boolValue.booleanValue()) + .privateCustom("custom-int", intValue.intValue()) + .privateCustom("custom-float", floatValue.floatValue()) + .privateCustom("custom-double", floatValue.doubleValue()) + .privateCustom("custom-string", stringValue.stringValue()) + .privateCustom("custom-json", jsonValue) + .build(); + Iterable names = ImmutableList.of("custom-bool", "custom-int", "custom-float", "custom-double", "custom-string", "custom-json"); + assertThat(user.getAttribute(UserAttribute.forName("custom-bool")), equalTo(boolValue)); + assertThat(user.getAttribute(UserAttribute.forName("custom-int")), equalTo(intValue)); + assertThat(user.getAttribute(UserAttribute.forName("custom-float")), equalTo(floatValue)); + assertThat(user.getAttribute(UserAttribute.forName("custom-double")), equalTo(floatValue)); + assertThat(user.getAttribute(UserAttribute.forName("custom-string")), equalTo(stringValue)); + assertThat(user.getAttribute(UserAttribute.forName("custom-json")), equalTo(jsonValue)); + assertThat(ImmutableSet.copyOf(user.getCustomAttributes()), + equalTo(ImmutableSet.copyOf(Iterables.transform(names, UserAttribute::forName)))); + assertThat(ImmutableSet.copyOf(user.getPrivateAttributes()), equalTo(ImmutableSet.copyOf(user.getCustomAttributes()))); + for (String name: names) { + assertThat(name, user.isAttributePrivate(UserAttribute.forName(name)), is(true)); + } + } + @Test public void canCopyUserWithBuilder() { LDUser user = new LDUser.Builder("key") @@ -62,230 +193,35 @@ public void canCopyUserWithBuilder() { assert(user.equals(new LDUser.Builder(user).build())); } - @Test - 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().stringValue()); - } - - @Test - public void canSetPrivateSecondary() { - LDUser user = new LDUser.Builder("key").privateSecondary("s").build(); - 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().stringValue()); - } - - @Test - public void canSetPrivateIp() { - LDUser user = new LDUser.Builder("key").privateIp("i").build(); - 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().stringValue()); - } - - @Test - public void canSetPrivateEmail() { - LDUser user = new LDUser.Builder("key").privateEmail("e").build(); - 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().stringValue()); - } - - @Test - public void canSetPrivateName() { - LDUser user = new LDUser.Builder("key").privateName("n").build(); - 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().stringValue()); - } - - @Test - public void canSetPrivateAvatar() { - LDUser user = new LDUser.Builder("key").privateAvatar("a").build(); - 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().stringValue()); - } - - @Test - public void canSetPrivateFirstName() { - LDUser user = new LDUser.Builder("key").privateFirstName("f").build(); - 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().stringValue()); - } - - @Test - public void canSetPrivateLastName() { - LDUser user = new LDUser.Builder("key").privateLastName("l").build(); - 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().booleanValue()); - } - - @Test - public void canSetCountry() { - LDUser user = new LDUser.Builder("key").country("u").build(); - assertEquals("u", user.getCountry().stringValue()); - } - - @Test - public void canSetPrivateCountry() { - LDUser user = new LDUser.Builder("key").privateCountry("u").build(); - assertEquals("u", 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").stringValue()); - } - - @Test - public void canSetPrivateCustomString() { - LDUser user = new LDUser.Builder("key").privateCustom("thing", "value").build(); - 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").intValue()); - } - - @Test - public void canSetPrivateCustomInt() { - LDUser user = new LDUser.Builder("key").privateCustom("thing", 1).build(); - 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").booleanValue()); - } - - @Test - public void canSetPrivateCustomBoolean() { - LDUser user = new LDUser.Builder("key").privateCustom("thing", true).build(); - assertEquals(true, user.getCustom("thing").booleanValue()); - assertEquals(ImmutableSet.of("thing"), user.privateAttributeNames); - } - - @Test - public void canSetCustomJsonValue() { - 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() { - 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); + LDUser user1 = new LDUser.Builder("key").anonymous(true).build(); + assertThat(user1.isAnonymous(), is(true)); + assertThat(user1.getAttribute(UserAttribute.ANONYMOUS), equalTo(LDValue.of(true))); + + LDUser user2 = new LDUser.Builder("key").anonymous(false).build(); + assertThat(user2.isAnonymous(), is(false)); + assertThat(user2.getAttribute(UserAttribute.ANONYMOUS), equalTo(LDValue.of(false))); } @Test - public void testAllPropertiesInDefaultEncoding() { - for (Map.Entry e: getUserPropertiesJsonMap().entrySet()) { - JsonElement expected = gsonInstance().fromJson(e.getValue(), JsonElement.class); - JsonElement actual = gsonInstance().toJsonTree(e.getKey()); - assertEquals(expected, actual); - } + public void getAttributeGetsBuiltInAttributeEvenIfCustomAttrHasSameName() { + LDUser user = new LDUser.Builder("key") + .name("Jane") + .custom("name", "Joan") + .build(); + assertEquals(LDValue.of("Jane"), user.getAttribute(UserAttribute.forName("name"))); } @Test - public void testAllPropertiesInPrivateAttributeEncoding() { - for (Map.Entry e: getUserPropertiesJsonMap().entrySet()) { - JsonElement expected = gsonInstance().fromJson(e.getValue(), JsonElement.class); - JsonElement actual = gsonInstance().toJsonTree(e.getKey()); - assertEquals(expected, actual); - } + public void testMinimalJsonEncoding() { + LDUser user = new LDUser("userkey"); + String json = user.toJsonString(); + assertThat(json, equalTo("{\"key\":\"userkey\"}")); } - 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("value").build(), - "{\"key\":\"userkey\",\"country\":\"value\"}"); - 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 privateAttributeEncodingRedactsAllPrivateAttributes() { - EventsConfiguration config = makeEventsConfig(true, false, null); + public void testDefaultJsonEncodingWithoutPrivateAttributes() { LDUser user = new LDUser.Builder("userkey") .secondary("s") .ip("i") @@ -294,112 +230,42 @@ public void privateAttributeEncodingRedactsAllPrivateAttributes() { .avatar("a") .firstName("f") .lastName("l") + .country("c") .anonymous(true) - .country("USA") - .custom("thing", "value") + .custom("c1", "v1") .build(); - Set redacted = ImmutableSet.of("secondary", "ip", "email", "name", "avatar", "firstName", "lastName", "country", "thing"); - - JsonObject o = gsonInstanceForEventsSerialization(config).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 privateAttributeEncodingRedactsSpecificPerUserPrivateAttributes() { - LDUser user = new LDUser.Builder("userkey") - .email("e") - .privateName("n") - .custom("bar", 43) - .privateCustom("foo", 42) - .build(); - - 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()); - assertNull(o.get("custom").getAsJsonObject().get("foo")); - assertEquals(ImmutableSet.of("name", "foo"), getPrivateAttrs(o)); + LDValue json = LDValue.parse(user.toJsonString()); + assertThat(json, equalTo( + LDValue.buildObject() + .put("key", "userkey") + .put("secondary", "s") + .put("ip", "i") + .put("email", "e") + .put("name", "n") + .put("avatar", "a") + .put("firstName", "f") + .put("lastName", "l") + .put("country", "c") + .put("anonymous", true) + .put("custom", LDValue.buildObject().put("c1", "v1").build()) + .build() + )); } @Test - public void privateAttributeEncodingRedactsSpecificGlobalPrivateAttributes() { - EventsConfiguration config = makeEventsConfig(false, false, ImmutableSet.of("name", "foo")); + public void testDefaultJsonEncodingWithPrivateAttributes() { LDUser user = new LDUser.Builder("userkey") .email("e") - .name("n") - .custom("bar", 43) - .custom("foo", 42) - .build(); - - 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()); - assertNull(o.get("custom").getAsJsonObject().get("foo")); - assertEquals(ImmutableSet.of("name", "foo"), getPrivateAttrs(o)); - } - - @Test - public void privateAttributeEncodingWorksForMinimalUser() { - EventsConfiguration config = makeEventsConfig(true, false, null); - LDUser user = new LDUser("userkey"); - - JsonObject o = gsonInstanceForEventsSerialization(config).toJsonTree(user).getAsJsonObject(); - JsonObject expected = new JsonObject(); - expected.addProperty("key", "userkey"); - assertEquals(expected, o); - } - - @Test - public void getValueGetsBuiltInAttribute() { - LDUser user = new LDUser.Builder("key") - .name("Jane") - .build(); - assertEquals(LDValue.of("Jane"), user.getValueForEvaluation("name")); - } - - @Test - public void getValueGetsCustomAttribute() { - LDUser user = new LDUser.Builder("key") - .custom("height", 5) - .build(); - assertEquals(LDValue.of(5), user.getValueForEvaluation("height")); - } - - @Test - public void getValueGetsBuiltInAttributeEvenIfCustomAttrHasSameName() { - LDUser user = new LDUser.Builder("key") - .name("Jane") - .custom("name", "Joan") - .build(); - assertEquals(LDValue.of("Jane"), user.getValueForEvaluation("name")); - } - - @Test - 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") + .privateName("n") .build(); - assertEquals(LDValue.ofNull(), user.getValueForEvaluation("height")); - } - - private Set getPrivateAttrs(JsonObject o) { - Type type = new TypeToken>(){}.getType(); - return new HashSet(defaultGson.>fromJson(o.get("privateAttrs"), type)); + LDValue json = LDValue.parse(user.toJsonString()); + assertThat(json, equalTo( + LDValue.buildObject() + .put("key", "userkey") + .put("email", "e") + .put("name", "n") + .put("privateAttributeNames", LDValue.buildArray().add("name").build()) + .build() + )); } } diff --git a/src/test/java/com/launchdarkly/client/ModelBuilders.java b/src/test/java/com/launchdarkly/client/ModelBuilders.java index 019a5b8ca..d55fe1b60 100644 --- a/src/test/java/com/launchdarkly/client/ModelBuilders.java +++ b/src/test/java/com/launchdarkly/client/ModelBuilders.java @@ -45,20 +45,20 @@ public static RuleBuilder ruleBuilder() { return new RuleBuilder(); } - public static DataModel.Clause clause(String attribute, DataModel.Operator op, boolean negate, LDValue... values) { + public static DataModel.Clause clause(UserAttribute attribute, DataModel.Operator op, boolean negate, LDValue... values) { return new DataModel.Clause(attribute, op, Arrays.asList(values), negate); } - public static DataModel.Clause clause(String attribute, DataModel.Operator op, LDValue... values) { + public static DataModel.Clause clause(UserAttribute attribute, DataModel.Operator op, LDValue... values) { return clause(attribute, op, false, values); } public static DataModel.Clause clauseMatchingUser(LDUser user) { - return clause("key", DataModel.Operator.in, user.getKey()); + return clause(UserAttribute.KEY, DataModel.Operator.in, user.getAttribute(UserAttribute.KEY)); } public static DataModel.Clause clauseNotMatchingUser(LDUser user) { - return clause("key", DataModel.Operator.in, LDValue.of("not-" + user.getKeyAsString())); + return clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of("not-" + user.getKey())); } public static DataModel.Target target(int variation, String... userKeys) { @@ -301,7 +301,7 @@ public SegmentBuilder deleted(boolean deleted) { public static class SegmentRuleBuilder { private List clauses = new ArrayList<>(); private Integer weight; - private String bucketBy; + private UserAttribute bucketBy; private SegmentRuleBuilder() { } @@ -320,7 +320,7 @@ public SegmentRuleBuilder weight(Integer weight) { return this; } - public SegmentRuleBuilder bucketBy(String bucketBy) { + public SegmentRuleBuilder bucketBy(UserAttribute bucketBy) { this.bucketBy = bucketBy; return this; } diff --git a/src/test/java/com/launchdarkly/client/TestUtil.java b/src/test/java/com/launchdarkly/client/TestUtil.java index c1fafc0b6..67cd166e3 100644 --- a/src/test/java/com/launchdarkly/client/TestUtil.java +++ b/src/test/java/com/launchdarkly/client/TestUtil.java @@ -228,12 +228,12 @@ protected boolean matchesSafely(LDValue item, Description mismatchDescription) { } static EventsConfiguration makeEventsConfig(boolean allAttributesPrivate, boolean inlineUsersInEvents, - Set privateAttrNames) { + Set privateAttributes) { return new EventsConfiguration( allAttributesPrivate, 0, null, EventProcessorBuilder.DEFAULT_FLUSH_INTERVAL, inlineUsersInEvents, - privateAttrNames, + privateAttributes, 0, 0, EventProcessorBuilder.DEFAULT_USER_KEYS_FLUSH_INTERVAL, EventProcessorBuilder.DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL); } diff --git a/src/test/java/com/launchdarkly/client/UserAttributeTest.java b/src/test/java/com/launchdarkly/client/UserAttributeTest.java new file mode 100644 index 000000000..bebd622bc --- /dev/null +++ b/src/test/java/com/launchdarkly/client/UserAttributeTest.java @@ -0,0 +1,70 @@ +package com.launchdarkly.client; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +@SuppressWarnings("javadoc") +public class UserAttributeTest { + @Test + public void keyAttribute() { + assertEquals("key", UserAttribute.KEY.getName()); + assertTrue(UserAttribute.KEY.isBuiltIn()); + } + + @Test + public void secondaryKeyAttribute() { + assertEquals("secondary", UserAttribute.SECONDARY_KEY.getName()); + assertTrue(UserAttribute.SECONDARY_KEY.isBuiltIn()); + } + + @Test + public void ipAttribute() { + assertEquals("ip", UserAttribute.IP.getName()); + assertTrue(UserAttribute.IP.isBuiltIn()); + } + + @Test + public void emailAttribute() { + assertEquals("email", UserAttribute.EMAIL.getName()); + assertTrue(UserAttribute.EMAIL.isBuiltIn()); + } + + @Test + public void nameAttribute() { + assertEquals("name", UserAttribute.NAME.getName()); + assertTrue(UserAttribute.NAME.isBuiltIn()); + } + + @Test + public void avatarAttribute() { + assertEquals("avatar", UserAttribute.AVATAR.getName()); + assertTrue(UserAttribute.AVATAR.isBuiltIn()); + } + + @Test + public void firstNameAttribute() { + assertEquals("firstName", UserAttribute.FIRST_NAME.getName()); + assertTrue(UserAttribute.FIRST_NAME.isBuiltIn()); + } + + @Test + public void lastNameAttribute() { + assertEquals("lastName", UserAttribute.LAST_NAME.getName()); + assertTrue(UserAttribute.LAST_NAME.isBuiltIn()); + } + + @Test + public void anonymousAttribute() { + assertEquals("anonymous", UserAttribute.ANONYMOUS.getName()); + assertTrue(UserAttribute.ANONYMOUS.isBuiltIn()); + } + + @Test + public void customAttribute() { + assertEquals("things", UserAttribute.forName("things").getName()); + assertFalse(UserAttribute.forName("things").isBuiltIn()); + } +} From 2b87ed48a344f9b4989c6570e956468937e22c0b Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 5 Mar 2020 16:32:23 -0800 Subject: [PATCH 330/673] fix inner class scope --- src/main/java/com/launchdarkly/client/UserAttribute.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/client/UserAttribute.java b/src/main/java/com/launchdarkly/client/UserAttribute.java index 698dfc0f8..a931e6fd9 100644 --- a/src/main/java/com/launchdarkly/client/UserAttribute.java +++ b/src/main/java/com/launchdarkly/client/UserAttribute.java @@ -131,7 +131,7 @@ public String toString() { return name; } - private static final class UserAttributeTypeAdapter extends TypeAdapter{ + static final class UserAttributeTypeAdapter extends TypeAdapter{ @Override public UserAttribute read(JsonReader reader) throws IOException { return UserAttribute.forName(reader.nextString()); From 9ff8bb756c28dd5b0e91a0f3fd84ba8fc43b2e6e Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 5 Mar 2020 16:51:31 -0800 Subject: [PATCH 331/673] massive rename of all SDK packages --- build.gradle | 4 +- packaging-test/Makefile | 12 ++-- .../src/main/java/testapp/TestApp.java | 5 +- .../com/launchdarkly/client/package-info.java | 8 --- .../client/value/package-info.java | 4 -- .../{client/value => sdk}/ArrayBuilder.java | 2 +- .../{client => sdk}/EvaluationDetail.java | 13 +++- .../{client => sdk}/EvaluationReason.java | 4 +- .../launchdarkly/{client => sdk}/LDUser.java | 9 +-- .../{client/value => sdk}/LDValue.java | 5 +- .../{client/value => sdk}/LDValueArray.java | 2 +- .../{client/value => sdk}/LDValueBool.java | 2 +- .../{client/value => sdk}/LDValueNull.java | 2 +- .../{client/value => sdk}/LDValueNumber.java | 2 +- .../{client/value => sdk}/LDValueObject.java | 2 +- .../{client/value => sdk}/LDValueString.java | 2 +- .../{client/value => sdk}/LDValueType.java | 2 +- .../value => sdk}/LDValueTypeAdapter.java | 2 +- .../{client/value => sdk}/ObjectBuilder.java | 2 +- .../{client => sdk}/UserAttribute.java | 3 +- .../com/launchdarkly/sdk/package-info.java | 4 ++ .../server}/ClientContextImpl.java | 4 +- .../{client => sdk/server}/Components.java | 38 +++++------ .../{client => sdk/server}/DataModel.java | 14 ++-- .../server}/DataStoreDataSetSorter.java | 14 ++-- .../server}/DataStoreUpdatesImpl.java | 12 ++-- .../server}/DefaultEventProcessor.java | 19 +++--- .../server}/DefaultFeatureRequestor.java | 18 +++--- .../server}/DiagnosticAccumulator.java | 2 +- .../server}/DiagnosticEvent.java | 10 +-- .../{client => sdk/server}/DiagnosticId.java | 2 +- .../{client => sdk/server}/Evaluator.java | 11 ++-- .../server}/EvaluatorBucketing.java | 6 +- .../server}/EvaluatorOperators.java | 4 +- .../{client => sdk/server}/EventFactory.java | 8 ++- .../server}/EventOutputFormatter.java | 12 ++-- .../server}/EventSummarizer.java | 6 +- .../server}/EventUserSerialization.java | 6 +- .../server}/EventsConfiguration.java | 3 +- .../server}/FeatureFlagsState.java | 7 +- .../server}/FeatureRequestor.java | 2 +- .../server}/FlagsStateOption.java | 6 +- .../server}/HttpConfiguration.java | 2 +- .../server}/HttpErrorException.java | 2 +- .../server}/InMemoryDataStore.java | 16 ++--- .../{client => sdk/server}/JsonHelpers.java | 3 +- .../{client => sdk/server}/LDClient.java | 31 +++++---- .../server}/LDClientInterface.java | 6 +- .../{client => sdk/server}/LDConfig.java | 24 +++---- .../server}/NewRelicReflector.java | 2 +- .../server}/PollingProcessor.java | 10 +-- .../server}/SemanticVersion.java | 2 +- .../server}/SimpleLRUCache.java | 2 +- .../server}/StreamProcessor.java | 22 +++---- .../{client => sdk/server}/Util.java | 2 +- .../server}/integrations/CacheMonitor.java | 2 +- .../integrations/EventProcessorBuilder.java | 26 ++++---- .../server}/integrations/FileData.java | 8 +-- .../integrations/FileDataSourceBuilder.java | 12 ++-- .../integrations/FileDataSourceImpl.java | 26 ++++---- .../integrations/FileDataSourceParsing.java | 8 +-- .../PersistentDataStoreBuilder.java | 16 ++--- .../PersistentDataStoreWrapper.java | 16 ++--- .../PollingDataSourceBuilder.java | 8 +-- .../server}/integrations/Redis.java | 6 +- .../integrations/RedisDataStoreBuilder.java | 16 ++--- .../integrations/RedisDataStoreImpl.java | 14 ++-- .../StreamingDataSourceBuilder.java | 8 +-- .../server}/integrations/package-info.java | 6 +- .../server}/interfaces/ClientContext.java | 8 +-- .../server}/interfaces/DataSource.java | 2 +- .../server}/interfaces/DataSourceFactory.java | 4 +- .../server}/interfaces/DataStore.java | 10 +-- .../server}/interfaces/DataStoreFactory.java | 4 +- .../server}/interfaces/DataStoreTypes.java | 2 +- .../server}/interfaces/DataStoreUpdates.java | 8 +-- .../interfaces/DiagnosticDescription.java | 12 ++-- .../server}/interfaces/Event.java | 10 +-- .../server}/interfaces/EventProcessor.java | 2 +- .../interfaces/EventProcessorFactory.java | 4 +- .../interfaces/PersistentDataStore.java | 10 +-- .../PersistentDataStoreFactory.java | 8 +-- .../server}/interfaces/VersionedData.java | 2 +- .../server}/interfaces/VersionedDataKind.java | 2 +- .../server}/interfaces/package-info.java | 2 +- .../launchdarkly/sdk/server/package-info.java | 8 +++ .../{client => sdk}/EvaluationReasonTest.java | 5 +- .../{client => sdk}/LDUserTest.java | 3 +- .../{client/value => sdk}/LDValueTest.java | 6 +- .../{client => sdk}/UserAttributeTest.java | 2 +- .../server}/DataStoreTestBase.java | 18 +++--- .../server}/DataStoreTestTypes.java | 15 +++-- .../server}/DefaultEventProcessorTest.java | 29 +++++---- .../server}/DiagnosticAccumulatorTest.java | 6 +- .../server}/DiagnosticEventTest.java | 12 ++-- .../server}/DiagnosticIdTest.java | 3 +- .../server}/DiagnosticSdkTest.java | 7 +- .../server}/EvaluatorBucketingTest.java | 10 +-- .../server}/EvaluatorClauseTest.java | 26 ++++---- .../EvaluatorOperatorsParameterizedTest.java | 6 +- .../server}/EvaluatorOperatorsTest.java | 6 +- .../server}/EvaluatorRuleTest.java | 20 +++--- .../server}/EvaluatorSegmentMatchTest.java | 16 +++-- .../{client => sdk/server}/EvaluatorTest.java | 28 ++++---- .../server}/EvaluatorTestUtil.java | 5 +- .../server}/EventOutputTest.java | 17 +++-- .../server}/EventSummarizerTest.java | 15 +++-- .../server}/EventUserSerializationTest.java | 12 ++-- .../server}/FeatureFlagsStateTest.java | 11 +++- .../server}/FeatureRequestorTest.java | 15 +++-- .../server}/FlagModelDeserializationTest.java | 4 +- .../server}/InMemoryDataStoreTest.java | 5 +- .../server}/LDClientEndToEndTest.java | 22 ++++--- .../server}/LDClientEvaluationTest.java | 41 +++++++----- .../server}/LDClientEventTest.java | 39 ++++++----- .../LDClientExternalUpdatesOnlyTest.java | 18 ++++-- .../server}/LDClientOfflineTest.java | 20 ++++-- .../{client => sdk/server}/LDClientTest.java | 64 +++++++++++-------- .../{client => sdk/server}/LDConfigTest.java | 4 +- .../{client => sdk/server}/ModelBuilders.java | 10 +-- .../server}/PollingProcessorTest.java | 22 +++++-- .../server}/SemanticVersionTest.java | 4 +- .../server}/SimpleLRUCacheTest.java | 4 +- .../server}/StreamProcessorTest.java | 40 ++++++++---- .../{client => sdk/server}/TestHttpUtil.java | 7 +- .../{client => sdk/server}/TestUtil.java | 40 ++++++------ .../{client => sdk/server}/UtilTest.java | 8 ++- .../ClientWithFileDataSourceTest.java | 24 +++---- .../server}/integrations/DataLoaderTest.java | 22 +++---- .../EventProcessorBuilderTest.java | 5 +- .../integrations/FileDataSourceTest.java | 30 +++++---- .../integrations/FileDataSourceTestData.java | 4 +- .../integrations/FlagFileParserJsonTest.java | 4 +- .../integrations/FlagFileParserTestBase.java | 16 ++--- .../integrations/FlagFileParserYamlTest.java | 4 +- .../PersistentDataStoreTestBase.java | 22 +++---- .../PersistentDataStoreWrapperTest.java | 29 +++++---- .../RedisDataStoreBuilderTest.java | 5 +- .../integrations/RedisDataStoreImplTest.java | 6 +- 139 files changed, 849 insertions(+), 656 deletions(-) delete mode 100644 src/main/java/com/launchdarkly/client/package-info.java delete mode 100644 src/main/java/com/launchdarkly/client/value/package-info.java rename src/main/java/com/launchdarkly/{client/value => sdk}/ArrayBuilder.java (98%) rename src/main/java/com/launchdarkly/{client => sdk}/EvaluationDetail.java (88%) rename src/main/java/com/launchdarkly/{client => sdk}/EvaluationReason.java (99%) rename src/main/java/com/launchdarkly/{client => sdk}/LDUser.java (99%) rename src/main/java/com/launchdarkly/{client/value => sdk}/LDValue.java (99%) rename src/main/java/com/launchdarkly/{client/value => sdk}/LDValueArray.java (96%) rename src/main/java/com/launchdarkly/{client/value => sdk}/LDValueBool.java (95%) rename src/main/java/com/launchdarkly/{client/value => sdk}/LDValueNull.java (93%) rename src/main/java/com/launchdarkly/{client/value => sdk}/LDValueNumber.java (97%) rename src/main/java/com/launchdarkly/{client/value => sdk}/LDValueObject.java (97%) rename src/main/java/com/launchdarkly/{client/value => sdk}/LDValueString.java (95%) rename src/main/java/com/launchdarkly/{client/value => sdk}/LDValueType.java (93%) rename src/main/java/com/launchdarkly/{client/value => sdk}/LDValueTypeAdapter.java (97%) rename src/main/java/com/launchdarkly/{client/value => sdk}/ObjectBuilder.java (98%) rename src/main/java/com/launchdarkly/{client => sdk}/UserAttribute.java (98%) create mode 100644 src/main/java/com/launchdarkly/sdk/package-info.java rename src/main/java/com/launchdarkly/{client => sdk/server}/ClientContextImpl.java (91%) rename src/main/java/com/launchdarkly/{client => sdk/server}/Components.java (93%) rename src/main/java/com/launchdarkly/{client => sdk/server}/DataModel.java (96%) rename src/main/java/com/launchdarkly/{client => sdk/server}/DataStoreDataSetSorter.java (90%) rename src/main/java/com/launchdarkly/{client => sdk/server}/DataStoreUpdatesImpl.java (66%) rename src/main/java/com/launchdarkly/{client => sdk/server}/DefaultEventProcessor.java (97%) rename src/main/java/com/launchdarkly/{client => sdk/server}/DefaultFeatureRequestor.java (87%) rename src/main/java/com/launchdarkly/{client => sdk/server}/DiagnosticAccumulator.java (97%) rename src/main/java/com/launchdarkly/{client => sdk/server}/DiagnosticEvent.java (96%) rename src/main/java/com/launchdarkly/{client => sdk/server}/DiagnosticId.java (89%) rename src/main/java/com/launchdarkly/{client => sdk/server}/Evaluator.java (97%) rename src/main/java/com/launchdarkly/{client => sdk/server}/EvaluatorBucketing.java (94%) rename src/main/java/com/launchdarkly/{client => sdk/server}/EvaluatorOperators.java (98%) rename src/main/java/com/launchdarkly/{client => sdk/server}/EventFactory.java (95%) rename src/main/java/com/launchdarkly/{client => sdk/server}/EventOutputFormatter.java (95%) rename src/main/java/com/launchdarkly/{client => sdk/server}/EventSummarizer.java (97%) rename src/main/java/com/launchdarkly/{client => sdk/server}/EventUserSerialization.java (95%) rename src/main/java/com/launchdarkly/{client => sdk/server}/EventsConfiguration.java (94%) rename src/main/java/com/launchdarkly/{client => sdk/server}/FeatureFlagsState.java (97%) rename src/main/java/com/launchdarkly/{client => sdk/server}/FeatureRequestor.java (94%) rename src/main/java/com/launchdarkly/{client => sdk/server}/FlagsStateOption.java (90%) rename src/main/java/com/launchdarkly/{client => sdk/server}/HttpConfiguration.java (96%) rename src/main/java/com/launchdarkly/{client => sdk/server}/HttpErrorException.java (88%) rename src/main/java/com/launchdarkly/{client => sdk/server}/InMemoryDataStore.java (85%) rename src/main/java/com/launchdarkly/{client => sdk/server}/JsonHelpers.java (97%) rename src/main/java/com/launchdarkly/{client => sdk/server}/LDClient.java (95%) rename src/main/java/com/launchdarkly/{client => sdk/server}/LDClientInterface.java (98%) rename src/main/java/com/launchdarkly/{client => sdk/server}/LDConfig.java (93%) rename src/main/java/com/launchdarkly/{client => sdk/server}/NewRelicReflector.java (97%) rename src/main/java/com/launchdarkly/{client => sdk/server}/PollingProcessor.java (90%) rename src/main/java/com/launchdarkly/{client => sdk/server}/SemanticVersion.java (99%) rename src/main/java/com/launchdarkly/{client => sdk/server}/SimpleLRUCache.java (94%) rename src/main/java/com/launchdarkly/{client => sdk/server}/StreamProcessor.java (93%) rename src/main/java/com/launchdarkly/{client => sdk/server}/Util.java (98%) rename src/main/java/com/launchdarkly/{client => sdk/server}/integrations/CacheMonitor.java (98%) rename src/main/java/com/launchdarkly/{client => sdk/server}/integrations/EventProcessorBuilder.java (91%) rename src/main/java/com/launchdarkly/{client => sdk/server}/integrations/FileData.java (93%) rename src/main/java/com/launchdarkly/{client => sdk/server}/integrations/FileDataSourceBuilder.java (87%) rename src/main/java/com/launchdarkly/{client => sdk/server}/integrations/FileDataSourceImpl.java (90%) rename src/main/java/com/launchdarkly/{client => sdk/server}/integrations/FileDataSourceParsing.java (97%) rename src/main/java/com/launchdarkly/{client => sdk/server}/integrations/PersistentDataStoreBuilder.java (94%) rename src/main/java/com/launchdarkly/{client => sdk/server}/integrations/PersistentDataStoreWrapper.java (96%) rename src/main/java/com/launchdarkly/{client => sdk/server}/integrations/PollingDataSourceBuilder.java (91%) rename src/main/java/com/launchdarkly/{client => sdk/server}/integrations/Redis.java (76%) rename src/main/java/com/launchdarkly/{client => sdk/server}/integrations/RedisDataStoreBuilder.java (91%) rename src/main/java/com/launchdarkly/{client => sdk/server}/integrations/RedisDataStoreImpl.java (92%) rename src/main/java/com/launchdarkly/{client => sdk/server}/integrations/StreamingDataSourceBuilder.java (92%) rename src/main/java/com/launchdarkly/{client => sdk/server}/integrations/package-info.java (80%) rename src/main/java/com/launchdarkly/{client => sdk/server}/interfaces/ClientContext.java (68%) rename src/main/java/com/launchdarkly/{client => sdk/server}/interfaces/DataSource.java (94%) rename src/main/java/com/launchdarkly/{client => sdk/server}/interfaces/DataSourceFactory.java (84%) rename src/main/java/com/launchdarkly/{client => sdk/server}/interfaces/DataStore.java (89%) rename src/main/java/com/launchdarkly/{client => sdk/server}/interfaces/DataStoreFactory.java (79%) rename src/main/java/com/launchdarkly/{client => sdk/server}/interfaces/DataStoreTypes.java (99%) rename src/main/java/com/launchdarkly/{client => sdk/server}/interfaces/DataStoreUpdates.java (86%) rename src/main/java/com/launchdarkly/{client => sdk/server}/interfaces/DiagnosticDescription.java (70%) rename src/main/java/com/launchdarkly/{client => sdk/server}/interfaces/Event.java (96%) rename src/main/java/com/launchdarkly/{client => sdk/server}/interfaces/EventProcessor.java (93%) rename src/main/java/com/launchdarkly/{client => sdk/server}/interfaces/EventProcessorFactory.java (80%) rename src/main/java/com/launchdarkly/{client => sdk/server}/interfaces/PersistentDataStore.java (94%) rename src/main/java/com/launchdarkly/{client => sdk/server}/interfaces/PersistentDataStoreFactory.java (70%) rename src/main/java/com/launchdarkly/{client => sdk/server}/interfaces/VersionedData.java (91%) rename src/main/java/com/launchdarkly/{client => sdk/server}/interfaces/VersionedDataKind.java (98%) rename src/main/java/com/launchdarkly/{client => sdk/server}/interfaces/package-info.java (83%) create mode 100644 src/main/java/com/launchdarkly/sdk/server/package-info.java rename src/test/java/com/launchdarkly/{client => sdk}/EvaluationReasonTest.java (96%) rename src/test/java/com/launchdarkly/{client => sdk}/LDUserTest.java (99%) rename src/test/java/com/launchdarkly/{client/value => sdk}/LDValueTest.java (98%) rename src/test/java/com/launchdarkly/{client => sdk}/UserAttributeTest.java (98%) rename src/test/java/com/launchdarkly/{client => sdk/server}/DataStoreTestBase.java (90%) rename src/test/java/com/launchdarkly/{client => sdk/server}/DataStoreTestTypes.java (90%) rename src/test/java/com/launchdarkly/{client => sdk/server}/DefaultEventProcessorTest.java (97%) rename src/test/java/com/launchdarkly/{client => sdk/server}/DiagnosticAccumulatorTest.java (92%) rename src/test/java/com/launchdarkly/{client => sdk/server}/DiagnosticEventTest.java (95%) rename src/test/java/com/launchdarkly/{client => sdk/server}/DiagnosticIdTest.java (94%) rename src/test/java/com/launchdarkly/{client => sdk/server}/DiagnosticSdkTest.java (92%) rename src/test/java/com/launchdarkly/{client => sdk/server}/EvaluatorBucketingTest.java (92%) rename src/test/java/com/launchdarkly/{client => sdk/server}/EvaluatorClauseTest.java (86%) rename src/test/java/com/launchdarkly/{client => sdk/server}/EvaluatorOperatorsParameterizedTest.java (97%) rename src/test/java/com/launchdarkly/{client => sdk/server}/EvaluatorOperatorsTest.java (77%) rename src/test/java/com/launchdarkly/{client => sdk/server}/EvaluatorRuleTest.java (87%) rename src/test/java/com/launchdarkly/{client => sdk/server}/EvaluatorSegmentMatchTest.java (88%) rename src/test/java/com/launchdarkly/{client => sdk/server}/EvaluatorTest.java (94%) rename src/test/java/com/launchdarkly/{client => sdk/server}/EvaluatorTestUtil.java (95%) rename src/test/java/com/launchdarkly/{client => sdk/server}/EventOutputTest.java (97%) rename src/test/java/com/launchdarkly/{client => sdk/server}/EventSummarizerTest.java (89%) rename src/test/java/com/launchdarkly/{client => sdk/server}/EventUserSerializationTest.java (93%) rename src/test/java/com/launchdarkly/{client => sdk/server}/FeatureFlagsStateTest.java (92%) rename src/test/java/com/launchdarkly/{client => sdk/server}/FeatureRequestorTest.java (93%) rename src/test/java/com/launchdarkly/{client => sdk/server}/FlagModelDeserializationTest.java (91%) rename src/test/java/com/launchdarkly/{client => sdk/server}/InMemoryDataStoreTest.java (55%) rename src/test/java/com/launchdarkly/{client => sdk/server}/LDClientEndToEndTest.java (86%) rename src/test/java/com/launchdarkly/{client => sdk/server}/LDClientEvaluationTest.java (91%) rename src/test/java/com/launchdarkly/{client => sdk/server}/LDClientEventTest.java (93%) rename src/test/java/com/launchdarkly/{client => sdk/server}/LDClientExternalUpdatesOnlyTest.java (75%) rename src/test/java/com/launchdarkly/{client => sdk/server}/LDClientOfflineTest.java (79%) rename src/test/java/com/launchdarkly/{client => sdk/server}/LDClientTest.java (87%) rename src/test/java/com/launchdarkly/{client => sdk/server}/LDConfigTest.java (97%) rename src/test/java/com/launchdarkly/{client => sdk/server}/ModelBuilders.java (97%) rename src/test/java/com/launchdarkly/{client => sdk/server}/PollingProcessorTest.java (88%) rename src/test/java/com/launchdarkly/{client => sdk/server}/SemanticVersionTest.java (98%) rename src/test/java/com/launchdarkly/{client => sdk/server}/SimpleLRUCacheTest.java (94%) rename src/test/java/com/launchdarkly/{client => sdk/server}/StreamProcessorTest.java (93%) rename src/test/java/com/launchdarkly/{client => sdk/server}/TestHttpUtil.java (91%) rename src/test/java/com/launchdarkly/{client => sdk/server}/TestUtil.java (85%) rename src/test/java/com/launchdarkly/{client => sdk/server}/UtilTest.java (82%) rename src/test/java/com/launchdarkly/{client => sdk/server}/integrations/ClientWithFileDataSourceTest.java (56%) rename src/test/java/com/launchdarkly/{client => sdk/server}/integrations/DataLoaderTest.java (81%) rename src/test/java/com/launchdarkly/{client => sdk/server}/integrations/EventProcessorBuilderTest.java (84%) rename src/test/java/com/launchdarkly/{client => sdk/server}/integrations/FileDataSourceTest.java (84%) rename src/test/java/com/launchdarkly/{client => sdk/server}/integrations/FileDataSourceTestData.java (95%) rename src/test/java/com/launchdarkly/{client => sdk/server}/integrations/FlagFileParserJsonTest.java (57%) rename src/test/java/com/launchdarkly/{client => sdk/server}/integrations/FlagFileParserTestBase.java (77%) rename src/test/java/com/launchdarkly/{client => sdk/server}/integrations/FlagFileParserYamlTest.java (57%) rename src/test/java/com/launchdarkly/{client => sdk/server}/integrations/PersistentDataStoreTestBase.java (94%) rename src/test/java/com/launchdarkly/{client => sdk/server}/integrations/PersistentDataStoreWrapperTest.java (95%) rename src/test/java/com/launchdarkly/{client => sdk/server}/integrations/RedisDataStoreBuilderTest.java (93%) rename src/test/java/com/launchdarkly/{client => sdk/server}/integrations/RedisDataStoreImplTest.java (83%) diff --git a/build.gradle b/build.gradle index c7891f267..7536a579f 100644 --- a/build.gradle +++ b/build.gradle @@ -43,7 +43,7 @@ allprojects { } ext { - sdkBasePackage = "com.launchdarkly.client" + sdkBasePackage = "com.launchdarkly.sdk" sdkBaseName = "launchdarkly-java-server-sdk" // List any packages here that should be included in OSGi imports for the SDK, if they cannot @@ -242,7 +242,7 @@ def addOsgiManifest(jarTask, List importConfigs, List/dev/null verify_sdk_classes= \ - $(call classes_should_contain,'^com/launchdarkly/client/[^/]*$$',com.launchdarkly.client) && \ + $(call classes_should_contain,'^com/launchdarkly/sdk/[^/]*$$',com.launchdarkly.sdk) && \ $(foreach subpkg,$(sdk_subpackage_names), \ - $(call classes_should_contain,'^com/launchdarkly/client/$(subpkg)/',com.launchdarkly.client.$(subpkg)) && ) true + $(call classes_should_contain,'^com/launchdarkly/sdk/$(subpkg)/',com.launchdarkly.sdk.$(subpkg)) && ) true sdk_subpackage_names= \ - $(shell ls -d $(PROJECT_DIR)/src/main/java/com/launchdarkly/client/*/ | sed -e 's@^.*/\([^/]*\)/@\1@') + $(shell ls -d $(PROJECT_DIR)/src/main/java/com/launchdarkly/sdk/*/ | sed -e 's@^.*/\([^/]*\)/@\1@') caption=echo "" && echo "$(1)" @@ -97,7 +97,7 @@ test-all-jar-classes: $(SDK_ALL_JAR) $(TEMP_DIR) @$(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/launchdarkly/sdk',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) @@ -106,7 +106,7 @@ test-default-jar-classes: $(SDK_DEFAULT_JAR) $(TEMP_DIR) @$(call classes_prepare,$<) @$(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/launchdarkly/shaded/com/launchdarkly/sdk',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)) @@ -114,7 +114,7 @@ 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) + @$(call classes_should_not_contain,-v '^com/launchdarkly/sdk/',anything other than SDK classes) $(SDK_DEFAULT_JAR): cd .. && ./gradlew shadowJar 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 4c762db05..1438fbaa3 100644 --- a/packaging-test/test-app/src/main/java/testapp/TestApp.java +++ b/packaging-test/test-app/src/main/java/testapp/TestApp.java @@ -1,7 +1,8 @@ package testapp; -import com.launchdarkly.client.*; -import com.launchdarkly.client.integrations.*; +import com.launchdarkly.sdk.*; +import com.launchdarkly.sdk.server.*; +import com.launchdarkly.sdk.integrations.*; import com.google.gson.*; import org.slf4j.*; diff --git a/src/main/java/com/launchdarkly/client/package-info.java b/src/main/java/com/launchdarkly/client/package-info.java deleted file mode 100644 index 14ba4590e..000000000 --- a/src/main/java/com/launchdarkly/client/package-info.java +++ /dev/null @@ -1,8 +0,0 @@ -/** - * 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/value/package-info.java b/src/main/java/com/launchdarkly/client/value/package-info.java deleted file mode 100644 index 59e453f22..000000000 --- a/src/main/java/com/launchdarkly/client/value/package-info.java +++ /dev/null @@ -1,4 +0,0 @@ -/** - * Provides the {@link com.launchdarkly.client.value.LDValue} abstraction for supported data types. - */ -package com.launchdarkly.client.value; diff --git a/src/main/java/com/launchdarkly/client/value/ArrayBuilder.java b/src/main/java/com/launchdarkly/sdk/ArrayBuilder.java similarity index 98% rename from src/main/java/com/launchdarkly/client/value/ArrayBuilder.java rename to src/main/java/com/launchdarkly/sdk/ArrayBuilder.java index e68b7a204..a335c4d3b 100644 --- a/src/main/java/com/launchdarkly/client/value/ArrayBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/ArrayBuilder.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client.value; +package com.launchdarkly.sdk; import com.google.common.collect.ImmutableList; diff --git a/src/main/java/com/launchdarkly/client/EvaluationDetail.java b/src/main/java/com/launchdarkly/sdk/EvaluationDetail.java similarity index 88% rename from src/main/java/com/launchdarkly/client/EvaluationDetail.java rename to src/main/java/com/launchdarkly/sdk/EvaluationDetail.java index f9eaf2a65..a57c9b464 100644 --- a/src/main/java/com/launchdarkly/client/EvaluationDetail.java +++ b/src/main/java/com/launchdarkly/sdk/EvaluationDetail.java @@ -1,7 +1,7 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk; import com.google.common.base.Objects; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.server.LDClientInterface; /** * An object returned by the "variation detail" methods such as {@link LDClientInterface#boolVariationDetail(String, LDUser, boolean)}, @@ -42,7 +42,14 @@ public static EvaluationDetail fromValue(T value, Integer variationIndex, return new EvaluationDetail(reason, variationIndex, value); } - static EvaluationDetail error(EvaluationReason.ErrorKind errorKind, LDValue defaultValue) { + /** + * Shortcut for creating an instance with an error result. + * + * @param errorKind the type of error + * @param defaultValue the application default value + * @return an {@link EvaluationDetail} + */ + public static EvaluationDetail error(EvaluationReason.ErrorKind errorKind, LDValue defaultValue) { return new EvaluationDetail(EvaluationReason.error(errorKind), null, LDValue.normalize(defaultValue)); } diff --git a/src/main/java/com/launchdarkly/client/EvaluationReason.java b/src/main/java/com/launchdarkly/sdk/EvaluationReason.java similarity index 99% rename from src/main/java/com/launchdarkly/client/EvaluationReason.java rename to src/main/java/com/launchdarkly/sdk/EvaluationReason.java index 1b48346f7..6ff7713ca 100644 --- a/src/main/java/com/launchdarkly/client/EvaluationReason.java +++ b/src/main/java/com/launchdarkly/sdk/EvaluationReason.java @@ -1,4 +1,6 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk; + +import com.launchdarkly.sdk.server.LDClientInterface; import java.util.Objects; diff --git a/src/main/java/com/launchdarkly/client/LDUser.java b/src/main/java/com/launchdarkly/sdk/LDUser.java similarity index 99% rename from src/main/java/com/launchdarkly/client/LDUser.java rename to src/main/java/com/launchdarkly/sdk/LDUser.java index 8c052540c..0cda1d489 100644 --- a/src/main/java/com/launchdarkly/client/LDUser.java +++ b/src/main/java/com/launchdarkly/sdk/LDUser.java @@ -1,9 +1,9 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; -import com.launchdarkly.client.value.LDValue; +import com.google.gson.Gson; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -29,6 +29,7 @@ */ public class LDUser { private static final Logger logger = LoggerFactory.getLogger(LDUser.class); + private static final Gson defaultGson = new Gson(); // 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. @@ -196,7 +197,7 @@ public Iterable getCustomAttributes() { /** * Returns an enumeration of all attributes that were marked private for this user. *

    - * This does not include any attributes that were globally marked private in {@link LDConfig.Builder}. + * This does not include any attributes that were globally marked private in your SDK configuration. * * @return the names of private attributes for this user */ @@ -227,7 +228,7 @@ public boolean isAttributePrivate(UserAttribute attribute) { * @return a JSON representation of the user */ public String toJsonString() { - return JsonHelpers.gsonInstance().toJson(this); + return defaultGson.toJson(this); } @Override diff --git a/src/main/java/com/launchdarkly/client/value/LDValue.java b/src/main/java/com/launchdarkly/sdk/LDValue.java similarity index 99% rename from src/main/java/com/launchdarkly/client/value/LDValue.java rename to src/main/java/com/launchdarkly/sdk/LDValue.java index 9a291cffb..125aabce8 100644 --- a/src/main/java/com/launchdarkly/client/value/LDValue.java +++ b/src/main/java/com/launchdarkly/sdk/LDValue.java @@ -1,12 +1,11 @@ -package com.launchdarkly.client.value; +package com.launchdarkly.sdk; import com.google.common.collect.ImmutableList; import com.google.common.collect.Iterables; import com.google.gson.Gson; import com.google.gson.annotations.JsonAdapter; import com.google.gson.stream.JsonWriter; -import com.launchdarkly.client.LDClientInterface; -import com.launchdarkly.client.LDUser; +import com.launchdarkly.sdk.server.LDClientInterface; import java.io.IOException; import java.util.Map; diff --git a/src/main/java/com/launchdarkly/client/value/LDValueArray.java b/src/main/java/com/launchdarkly/sdk/LDValueArray.java similarity index 96% rename from src/main/java/com/launchdarkly/client/value/LDValueArray.java rename to src/main/java/com/launchdarkly/sdk/LDValueArray.java index cb798eff7..966eae9af 100644 --- a/src/main/java/com/launchdarkly/client/value/LDValueArray.java +++ b/src/main/java/com/launchdarkly/sdk/LDValueArray.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client.value; +package com.launchdarkly.sdk; import com.google.common.collect.ImmutableList; import com.google.gson.annotations.JsonAdapter; diff --git a/src/main/java/com/launchdarkly/client/value/LDValueBool.java b/src/main/java/com/launchdarkly/sdk/LDValueBool.java similarity index 95% rename from src/main/java/com/launchdarkly/client/value/LDValueBool.java rename to src/main/java/com/launchdarkly/sdk/LDValueBool.java index 32ed560d4..f68789ee7 100644 --- a/src/main/java/com/launchdarkly/client/value/LDValueBool.java +++ b/src/main/java/com/launchdarkly/sdk/LDValueBool.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client.value; +package com.launchdarkly.sdk; import com.google.gson.annotations.JsonAdapter; import com.google.gson.stream.JsonWriter; diff --git a/src/main/java/com/launchdarkly/client/value/LDValueNull.java b/src/main/java/com/launchdarkly/sdk/LDValueNull.java similarity index 93% rename from src/main/java/com/launchdarkly/client/value/LDValueNull.java rename to src/main/java/com/launchdarkly/sdk/LDValueNull.java index 21dd33e27..1b3246dab 100644 --- a/src/main/java/com/launchdarkly/client/value/LDValueNull.java +++ b/src/main/java/com/launchdarkly/sdk/LDValueNull.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client.value; +package com.launchdarkly.sdk; import com.google.gson.annotations.JsonAdapter; import com.google.gson.stream.JsonWriter; diff --git a/src/main/java/com/launchdarkly/client/value/LDValueNumber.java b/src/main/java/com/launchdarkly/sdk/LDValueNumber.java similarity index 97% rename from src/main/java/com/launchdarkly/client/value/LDValueNumber.java rename to src/main/java/com/launchdarkly/sdk/LDValueNumber.java index bfefc6f53..4c5c2baae 100644 --- a/src/main/java/com/launchdarkly/client/value/LDValueNumber.java +++ b/src/main/java/com/launchdarkly/sdk/LDValueNumber.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client.value; +package com.launchdarkly.sdk; import com.google.gson.annotations.JsonAdapter; import com.google.gson.stream.JsonWriter; diff --git a/src/main/java/com/launchdarkly/client/value/LDValueObject.java b/src/main/java/com/launchdarkly/sdk/LDValueObject.java similarity index 97% rename from src/main/java/com/launchdarkly/client/value/LDValueObject.java rename to src/main/java/com/launchdarkly/sdk/LDValueObject.java index 30670720d..a0d4e88f6 100644 --- a/src/main/java/com/launchdarkly/client/value/LDValueObject.java +++ b/src/main/java/com/launchdarkly/sdk/LDValueObject.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client.value; +package com.launchdarkly.sdk; import com.google.common.collect.ImmutableMap; import com.google.gson.annotations.JsonAdapter; diff --git a/src/main/java/com/launchdarkly/client/value/LDValueString.java b/src/main/java/com/launchdarkly/sdk/LDValueString.java similarity index 95% rename from src/main/java/com/launchdarkly/client/value/LDValueString.java rename to src/main/java/com/launchdarkly/sdk/LDValueString.java index 8f6be51b3..dcdeb4e65 100644 --- a/src/main/java/com/launchdarkly/client/value/LDValueString.java +++ b/src/main/java/com/launchdarkly/sdk/LDValueString.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client.value; +package com.launchdarkly.sdk; import com.google.gson.annotations.JsonAdapter; import com.google.gson.stream.JsonWriter; diff --git a/src/main/java/com/launchdarkly/client/value/LDValueType.java b/src/main/java/com/launchdarkly/sdk/LDValueType.java similarity index 93% rename from src/main/java/com/launchdarkly/client/value/LDValueType.java rename to src/main/java/com/launchdarkly/sdk/LDValueType.java index d7e3ff7f4..11804f610 100644 --- a/src/main/java/com/launchdarkly/client/value/LDValueType.java +++ b/src/main/java/com/launchdarkly/sdk/LDValueType.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client.value; +package com.launchdarkly.sdk; /** * Describes the type of an {@link LDValue}. These correspond to the standard types in JSON. diff --git a/src/main/java/com/launchdarkly/client/value/LDValueTypeAdapter.java b/src/main/java/com/launchdarkly/sdk/LDValueTypeAdapter.java similarity index 97% rename from src/main/java/com/launchdarkly/client/value/LDValueTypeAdapter.java rename to src/main/java/com/launchdarkly/sdk/LDValueTypeAdapter.java index 72c50b960..acdfa7c3d 100644 --- a/src/main/java/com/launchdarkly/client/value/LDValueTypeAdapter.java +++ b/src/main/java/com/launchdarkly/sdk/LDValueTypeAdapter.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client.value; +package com.launchdarkly.sdk; import com.google.gson.TypeAdapter; import com.google.gson.stream.JsonReader; diff --git a/src/main/java/com/launchdarkly/client/value/ObjectBuilder.java b/src/main/java/com/launchdarkly/sdk/ObjectBuilder.java similarity index 98% rename from src/main/java/com/launchdarkly/client/value/ObjectBuilder.java rename to src/main/java/com/launchdarkly/sdk/ObjectBuilder.java index 1027652d9..c85777e13 100644 --- a/src/main/java/com/launchdarkly/client/value/ObjectBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/ObjectBuilder.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client.value; +package com.launchdarkly.sdk; import java.util.HashMap; import java.util.Map; diff --git a/src/main/java/com/launchdarkly/client/UserAttribute.java b/src/main/java/com/launchdarkly/sdk/UserAttribute.java similarity index 98% rename from src/main/java/com/launchdarkly/client/UserAttribute.java rename to src/main/java/com/launchdarkly/sdk/UserAttribute.java index a931e6fd9..050ba3bf6 100644 --- a/src/main/java/com/launchdarkly/client/UserAttribute.java +++ b/src/main/java/com/launchdarkly/sdk/UserAttribute.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk; import com.google.common.collect.ImmutableList; import com.google.common.collect.Maps; @@ -6,7 +6,6 @@ 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.Map; diff --git a/src/main/java/com/launchdarkly/sdk/package-info.java b/src/main/java/com/launchdarkly/sdk/package-info.java new file mode 100644 index 000000000..922165523 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/package-info.java @@ -0,0 +1,4 @@ +/** + * Base namespace for LaunchDarkly Java-based SDKs, containing common types. + */ +package com.launchdarkly.sdk; diff --git a/src/main/java/com/launchdarkly/client/ClientContextImpl.java b/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java similarity index 91% rename from src/main/java/com/launchdarkly/client/ClientContextImpl.java rename to src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java index 66f8a5373..83f010ef8 100644 --- a/src/main/java/com/launchdarkly/client/ClientContextImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java @@ -1,6 +1,6 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; -import com.launchdarkly.client.interfaces.ClientContext; +import com.launchdarkly.sdk.server.interfaces.ClientContext; final class ClientContextImpl implements ClientContext { private final String sdkKey; diff --git a/src/main/java/com/launchdarkly/client/Components.java b/src/main/java/com/launchdarkly/sdk/server/Components.java similarity index 93% rename from src/main/java/com/launchdarkly/client/Components.java rename to src/main/java/com/launchdarkly/sdk/server/Components.java index 369f25334..a8177f862 100644 --- a/src/main/java/com/launchdarkly/client/Components.java +++ b/src/main/java/com/launchdarkly/sdk/server/Components.java @@ -1,22 +1,22 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; -import com.launchdarkly.client.DiagnosticEvent.ConfigProperty; -import com.launchdarkly.client.integrations.EventProcessorBuilder; -import com.launchdarkly.client.integrations.PersistentDataStoreBuilder; -import com.launchdarkly.client.integrations.PollingDataSourceBuilder; -import com.launchdarkly.client.integrations.StreamingDataSourceBuilder; -import com.launchdarkly.client.interfaces.ClientContext; -import com.launchdarkly.client.interfaces.DataSource; -import com.launchdarkly.client.interfaces.DataSourceFactory; -import com.launchdarkly.client.interfaces.DataStore; -import com.launchdarkly.client.interfaces.DataStoreFactory; -import com.launchdarkly.client.interfaces.DataStoreUpdates; -import com.launchdarkly.client.interfaces.DiagnosticDescription; -import com.launchdarkly.client.interfaces.Event; -import com.launchdarkly.client.interfaces.EventProcessor; -import com.launchdarkly.client.interfaces.EventProcessorFactory; -import com.launchdarkly.client.interfaces.PersistentDataStoreFactory; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.DiagnosticEvent.ConfigProperty; +import com.launchdarkly.sdk.server.integrations.EventProcessorBuilder; +import com.launchdarkly.sdk.server.integrations.PersistentDataStoreBuilder; +import com.launchdarkly.sdk.server.integrations.PollingDataSourceBuilder; +import com.launchdarkly.sdk.server.integrations.StreamingDataSourceBuilder; +import com.launchdarkly.sdk.server.interfaces.ClientContext; +import com.launchdarkly.sdk.server.interfaces.DataSource; +import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; +import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; +import com.launchdarkly.sdk.server.interfaces.DiagnosticDescription; +import com.launchdarkly.sdk.server.interfaces.Event; +import com.launchdarkly.sdk.server.interfaces.EventProcessor; +import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; +import com.launchdarkly.sdk.server.interfaces.PersistentDataStoreFactory; import java.io.IOException; import java.net.URI; @@ -75,7 +75,7 @@ public static DataStoreFactory inMemoryDataStore() { * @param storeFactory the factory/builder for the specific kind of persistent data store * @return a {@link PersistentDataStoreBuilder} * @see LDConfig.Builder#dataStore(DataStoreFactory) - * @see com.launchdarkly.client.integrations.Redis + * @see com.launchdarkly.sdk.server.integrations.Redis * @since 4.12.0 */ public static PersistentDataStoreBuilder persistentDataStore(PersistentDataStoreFactory storeFactory) { diff --git a/src/main/java/com/launchdarkly/client/DataModel.java b/src/main/java/com/launchdarkly/sdk/server/DataModel.java similarity index 96% rename from src/main/java/com/launchdarkly/client/DataModel.java rename to src/main/java/com/launchdarkly/sdk/server/DataModel.java index 8d1cd9fc6..f6fe40a67 100644 --- a/src/main/java/com/launchdarkly/client/DataModel.java +++ b/src/main/java/com/launchdarkly/sdk/server/DataModel.java @@ -1,11 +1,13 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.gson.annotations.JsonAdapter; -import com.launchdarkly.client.interfaces.DataStoreTypes.DataKind; -import com.launchdarkly.client.interfaces.DataStoreTypes.ItemDescriptor; -import com.launchdarkly.client.interfaces.VersionedData; -import com.launchdarkly.client.interfaces.VersionedDataKind; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.VersionedData; +import com.launchdarkly.sdk.server.interfaces.VersionedDataKind; import java.util.List; diff --git a/src/main/java/com/launchdarkly/client/DataStoreDataSetSorter.java b/src/main/java/com/launchdarkly/sdk/server/DataStoreDataSetSorter.java similarity index 90% rename from src/main/java/com/launchdarkly/client/DataStoreDataSetSorter.java rename to src/main/java/com/launchdarkly/sdk/server/DataStoreDataSetSorter.java index aef7218d5..6262af235 100644 --- a/src/main/java/com/launchdarkly/client/DataStoreDataSetSorter.java +++ b/src/main/java/com/launchdarkly/sdk/server/DataStoreDataSetSorter.java @@ -1,13 +1,13 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSortedMap; -import com.launchdarkly.client.interfaces.DataStoreTypes.DataKind; -import com.launchdarkly.client.interfaces.DataStoreTypes.FullDataSet; -import com.launchdarkly.client.interfaces.DataStoreTypes.ItemDescriptor; -import com.launchdarkly.client.interfaces.DataStoreTypes.KeyedItems; -import com.launchdarkly.client.interfaces.VersionedDataKind; +import com.launchdarkly.sdk.server.interfaces.VersionedDataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; import java.util.Comparator; import java.util.HashMap; @@ -18,7 +18,7 @@ /** * Implements a dependency graph ordering for data to be stored in a data store. We must use this - * on every data set that will be passed to {@link com.launchdarkly.client.interfaces.DataStore#init(Map)}. + * on every data set that will be passed to {@link com.launchdarkly.sdk.server.interfaces.DataStore#init(Map)}. * * @since 4.6.1 */ diff --git a/src/main/java/com/launchdarkly/client/DataStoreUpdatesImpl.java b/src/main/java/com/launchdarkly/sdk/server/DataStoreUpdatesImpl.java similarity index 66% rename from src/main/java/com/launchdarkly/client/DataStoreUpdatesImpl.java rename to src/main/java/com/launchdarkly/sdk/server/DataStoreUpdatesImpl.java index 5a830da68..754ef6ecc 100644 --- a/src/main/java/com/launchdarkly/client/DataStoreUpdatesImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/DataStoreUpdatesImpl.java @@ -1,10 +1,10 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; -import com.launchdarkly.client.interfaces.DataStore; -import com.launchdarkly.client.interfaces.DataStoreTypes.DataKind; -import com.launchdarkly.client.interfaces.DataStoreTypes.FullDataSet; -import com.launchdarkly.client.interfaces.DataStoreTypes.ItemDescriptor; -import com.launchdarkly.client.interfaces.DataStoreUpdates; +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; /** * The data source will push updates into this component. We then apply any necessary diff --git a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java similarity index 97% rename from src/main/java/com/launchdarkly/client/DefaultEventProcessor.java rename to src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java index b8c69c361..19f2ee5db 100644 --- a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java @@ -1,10 +1,11 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.common.annotations.VisibleForTesting; import com.google.common.util.concurrent.ThreadFactoryBuilder; -import com.launchdarkly.client.EventSummarizer.EventSummary; -import com.launchdarkly.client.interfaces.Event; -import com.launchdarkly.client.interfaces.EventProcessor; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.server.EventSummarizer.EventSummary; +import com.launchdarkly.sdk.server.interfaces.Event; +import com.launchdarkly.sdk.server.interfaces.EventProcessor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -29,11 +30,11 @@ 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 static com.launchdarkly.sdk.server.Util.configureHttpClientBuilder; +import static com.launchdarkly.sdk.server.Util.getHeadersBuilderFor; +import static com.launchdarkly.sdk.server.Util.httpErrorMessage; +import static com.launchdarkly.sdk.server.Util.isHttpErrorRecoverable; +import static com.launchdarkly.sdk.server.Util.shutdownHttpClient; import okhttp3.Headers; import okhttp3.MediaType; diff --git a/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java b/src/main/java/com/launchdarkly/sdk/server/DefaultFeatureRequestor.java similarity index 87% rename from src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java rename to src/main/java/com/launchdarkly/sdk/server/DefaultFeatureRequestor.java index 6d4355920..820618a9d 100644 --- a/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java +++ b/src/main/java/com/launchdarkly/sdk/server/DefaultFeatureRequestor.java @@ -1,14 +1,14 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.Maps; import com.google.common.io.Files; -import com.launchdarkly.client.interfaces.DataStoreTypes.FullDataSet; -import com.launchdarkly.client.interfaces.DataStoreTypes.ItemDescriptor; -import com.launchdarkly.client.interfaces.DataStoreTypes.KeyedItems; -import com.launchdarkly.client.interfaces.VersionedData; +import com.launchdarkly.sdk.server.interfaces.VersionedData; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -18,10 +18,10 @@ import java.net.URI; 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; +import static com.launchdarkly.sdk.server.JsonHelpers.gsonInstance; +import static com.launchdarkly.sdk.server.Util.configureHttpClientBuilder; +import static com.launchdarkly.sdk.server.Util.getHeadersBuilderFor; +import static com.launchdarkly.sdk.server.Util.shutdownHttpClient; import okhttp3.Cache; import okhttp3.Headers; diff --git a/src/main/java/com/launchdarkly/client/DiagnosticAccumulator.java b/src/main/java/com/launchdarkly/sdk/server/DiagnosticAccumulator.java similarity index 97% rename from src/main/java/com/launchdarkly/client/DiagnosticAccumulator.java rename to src/main/java/com/launchdarkly/sdk/server/DiagnosticAccumulator.java index 22782294f..cea391e90 100644 --- a/src/main/java/com/launchdarkly/client/DiagnosticAccumulator.java +++ b/src/main/java/com/launchdarkly/sdk/server/DiagnosticAccumulator.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import java.util.ArrayList; import java.util.List; diff --git a/src/main/java/com/launchdarkly/client/DiagnosticEvent.java b/src/main/java/com/launchdarkly/sdk/server/DiagnosticEvent.java similarity index 96% rename from src/main/java/com/launchdarkly/client/DiagnosticEvent.java rename to src/main/java/com/launchdarkly/sdk/server/DiagnosticEvent.java index a2942a186..284465e61 100644 --- a/src/main/java/com/launchdarkly/client/DiagnosticEvent.java +++ b/src/main/java/com/launchdarkly/sdk/server/DiagnosticEvent.java @@ -1,9 +1,9 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; -import com.launchdarkly.client.interfaces.DiagnosticDescription; -import com.launchdarkly.client.value.LDValue; -import com.launchdarkly.client.value.LDValueType; -import com.launchdarkly.client.value.ObjectBuilder; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.LDValueType; +import com.launchdarkly.sdk.ObjectBuilder; +import com.launchdarkly.sdk.server.interfaces.DiagnosticDescription; import java.util.List; diff --git a/src/main/java/com/launchdarkly/client/DiagnosticId.java b/src/main/java/com/launchdarkly/sdk/server/DiagnosticId.java similarity index 89% rename from src/main/java/com/launchdarkly/client/DiagnosticId.java rename to src/main/java/com/launchdarkly/sdk/server/DiagnosticId.java index 713aebe33..8601a9780 100644 --- a/src/main/java/com/launchdarkly/client/DiagnosticId.java +++ b/src/main/java/com/launchdarkly/sdk/server/DiagnosticId.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import java.util.UUID; diff --git a/src/main/java/com/launchdarkly/client/Evaluator.java b/src/main/java/com/launchdarkly/sdk/server/Evaluator.java similarity index 97% rename from src/main/java/com/launchdarkly/client/Evaluator.java rename to src/main/java/com/launchdarkly/sdk/server/Evaluator.java index 5f3cd15b8..c43cf3fa2 100644 --- a/src/main/java/com/launchdarkly/client/Evaluator.java +++ b/src/main/java/com/launchdarkly/sdk/server/Evaluator.java @@ -1,10 +1,13 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.common.collect.ImmutableList; import com.google.common.collect.Iterables; -import com.launchdarkly.client.interfaces.Event; -import com.launchdarkly.client.value.LDValue; -import com.launchdarkly.client.value.LDValueType; +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.LDValueType; +import com.launchdarkly.sdk.server.interfaces.Event; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/src/main/java/com/launchdarkly/client/EvaluatorBucketing.java b/src/main/java/com/launchdarkly/sdk/server/EvaluatorBucketing.java similarity index 94% rename from src/main/java/com/launchdarkly/client/EvaluatorBucketing.java rename to src/main/java/com/launchdarkly/sdk/server/EvaluatorBucketing.java index 429557d11..f45425e2b 100644 --- a/src/main/java/com/launchdarkly/client/EvaluatorBucketing.java +++ b/src/main/java/com/launchdarkly/sdk/server/EvaluatorBucketing.java @@ -1,6 +1,8 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.UserAttribute; import org.apache.commons.codec.digest.DigestUtils; diff --git a/src/main/java/com/launchdarkly/client/EvaluatorOperators.java b/src/main/java/com/launchdarkly/sdk/server/EvaluatorOperators.java similarity index 98% rename from src/main/java/com/launchdarkly/client/EvaluatorOperators.java rename to src/main/java/com/launchdarkly/sdk/server/EvaluatorOperators.java index 39009ee61..75d02ae52 100644 --- a/src/main/java/com/launchdarkly/client/EvaluatorOperators.java +++ b/src/main/java/com/launchdarkly/sdk/server/EvaluatorOperators.java @@ -1,6 +1,6 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.LDValue; import java.time.Instant; import java.time.ZoneOffset; diff --git a/src/main/java/com/launchdarkly/client/EventFactory.java b/src/main/java/com/launchdarkly/sdk/server/EventFactory.java similarity index 95% rename from src/main/java/com/launchdarkly/client/EventFactory.java rename to src/main/java/com/launchdarkly/sdk/server/EventFactory.java index 05718398a..5391a497b 100644 --- a/src/main/java/com/launchdarkly/client/EventFactory.java +++ b/src/main/java/com/launchdarkly/sdk/server/EventFactory.java @@ -1,7 +1,9 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; -import com.launchdarkly.client.interfaces.Event; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.interfaces.Event; abstract class EventFactory { public static final EventFactory DEFAULT = new DefaultEventFactory(false); diff --git a/src/main/java/com/launchdarkly/client/EventOutputFormatter.java b/src/main/java/com/launchdarkly/sdk/server/EventOutputFormatter.java similarity index 95% rename from src/main/java/com/launchdarkly/client/EventOutputFormatter.java rename to src/main/java/com/launchdarkly/sdk/server/EventOutputFormatter.java index 93535620d..ca2ca4294 100644 --- a/src/main/java/com/launchdarkly/client/EventOutputFormatter.java +++ b/src/main/java/com/launchdarkly/sdk/server/EventOutputFormatter.java @@ -1,11 +1,13 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.gson.Gson; import com.google.gson.stream.JsonWriter; -import com.launchdarkly.client.EventSummarizer.CounterKey; -import com.launchdarkly.client.EventSummarizer.CounterValue; -import com.launchdarkly.client.interfaces.Event; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.EventSummarizer.CounterKey; +import com.launchdarkly.sdk.server.EventSummarizer.CounterValue; +import com.launchdarkly.sdk.server.interfaces.Event; import java.io.IOException; import java.io.Writer; diff --git a/src/main/java/com/launchdarkly/client/EventSummarizer.java b/src/main/java/com/launchdarkly/sdk/server/EventSummarizer.java similarity index 97% rename from src/main/java/com/launchdarkly/client/EventSummarizer.java rename to src/main/java/com/launchdarkly/sdk/server/EventSummarizer.java index 27679e9ad..1d90a2e65 100644 --- a/src/main/java/com/launchdarkly/client/EventSummarizer.java +++ b/src/main/java/com/launchdarkly/sdk/server/EventSummarizer.java @@ -1,7 +1,7 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; -import com.launchdarkly.client.interfaces.Event; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.interfaces.Event; import java.util.HashMap; import java.util.Map; diff --git a/src/main/java/com/launchdarkly/client/EventUserSerialization.java b/src/main/java/com/launchdarkly/sdk/server/EventUserSerialization.java similarity index 95% rename from src/main/java/com/launchdarkly/client/EventUserSerialization.java rename to src/main/java/com/launchdarkly/sdk/server/EventUserSerialization.java index f2cb098a5..5b49a5b47 100644 --- a/src/main/java/com/launchdarkly/client/EventUserSerialization.java +++ b/src/main/java/com/launchdarkly/sdk/server/EventUserSerialization.java @@ -1,9 +1,11 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.gson.TypeAdapter; import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonWriter; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.UserAttribute; import java.io.IOException; import java.util.Set; diff --git a/src/main/java/com/launchdarkly/client/EventsConfiguration.java b/src/main/java/com/launchdarkly/sdk/server/EventsConfiguration.java similarity index 94% rename from src/main/java/com/launchdarkly/client/EventsConfiguration.java rename to src/main/java/com/launchdarkly/sdk/server/EventsConfiguration.java index c0e38fc54..92acfb4b0 100644 --- a/src/main/java/com/launchdarkly/client/EventsConfiguration.java +++ b/src/main/java/com/launchdarkly/sdk/server/EventsConfiguration.java @@ -1,6 +1,7 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.common.collect.ImmutableSet; +import com.launchdarkly.sdk.UserAttribute; import java.net.URI; import java.time.Duration; diff --git a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java b/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java similarity index 97% rename from src/main/java/com/launchdarkly/client/FeatureFlagsState.java rename to src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java index 5612d4b60..487abc992 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java +++ b/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.common.base.Objects; import com.google.gson.Gson; @@ -6,7 +6,8 @@ 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 com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDValue; import java.io.IOException; import java.util.Collections; @@ -15,7 +16,7 @@ /** * A snapshot of the state of all feature flags with regard to a specific user, generated by - * calling {@link LDClientInterface#allFlagsState(LDUser, FlagsStateOption...)}. + * calling {@link LDClientInterface#allFlagsState(com.launchdarkly.sdk.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/FeatureRequestor.java b/src/main/java/com/launchdarkly/sdk/server/FeatureRequestor.java similarity index 94% rename from src/main/java/com/launchdarkly/client/FeatureRequestor.java rename to src/main/java/com/launchdarkly/sdk/server/FeatureRequestor.java index d0b689042..cf53f3360 100644 --- a/src/main/java/com/launchdarkly/client/FeatureRequestor.java +++ b/src/main/java/com/launchdarkly/sdk/server/FeatureRequestor.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import java.io.Closeable; import java.io.IOException; diff --git a/src/main/java/com/launchdarkly/client/FlagsStateOption.java b/src/main/java/com/launchdarkly/sdk/server/FlagsStateOption.java similarity index 90% rename from src/main/java/com/launchdarkly/client/FlagsStateOption.java rename to src/main/java/com/launchdarkly/sdk/server/FlagsStateOption.java index 71cb14829..79359fefd 100644 --- a/src/main/java/com/launchdarkly/client/FlagsStateOption.java +++ b/src/main/java/com/launchdarkly/sdk/server/FlagsStateOption.java @@ -1,7 +1,9 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.EvaluationReason; /** - * Optional parameters that can be passed to {@link LDClientInterface#allFlagsState(LDUser, FlagsStateOption...)}. + * Optional parameters that can be passed to {@link LDClientInterface#allFlagsState(com.launchdarkly.sdk.LDUser, FlagsStateOption...)}. * @since 4.3.0 */ public final class FlagsStateOption { diff --git a/src/main/java/com/launchdarkly/client/HttpConfiguration.java b/src/main/java/com/launchdarkly/sdk/server/HttpConfiguration.java similarity index 96% rename from src/main/java/com/launchdarkly/client/HttpConfiguration.java rename to src/main/java/com/launchdarkly/sdk/server/HttpConfiguration.java index 0524155dc..52cbdb9f5 100644 --- a/src/main/java/com/launchdarkly/client/HttpConfiguration.java +++ b/src/main/java/com/launchdarkly/sdk/server/HttpConfiguration.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import java.net.Proxy; import java.time.Duration; diff --git a/src/main/java/com/launchdarkly/client/HttpErrorException.java b/src/main/java/com/launchdarkly/sdk/server/HttpErrorException.java similarity index 88% rename from src/main/java/com/launchdarkly/client/HttpErrorException.java rename to src/main/java/com/launchdarkly/sdk/server/HttpErrorException.java index 8450e260f..30b10f3ff 100644 --- a/src/main/java/com/launchdarkly/client/HttpErrorException.java +++ b/src/main/java/com/launchdarkly/sdk/server/HttpErrorException.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; @SuppressWarnings("serial") final class HttpErrorException extends Exception { diff --git a/src/main/java/com/launchdarkly/client/InMemoryDataStore.java b/src/main/java/com/launchdarkly/sdk/server/InMemoryDataStore.java similarity index 85% rename from src/main/java/com/launchdarkly/client/InMemoryDataStore.java rename to src/main/java/com/launchdarkly/sdk/server/InMemoryDataStore.java index 6dcf992fe..55dfe549d 100644 --- a/src/main/java/com/launchdarkly/client/InMemoryDataStore.java +++ b/src/main/java/com/launchdarkly/sdk/server/InMemoryDataStore.java @@ -1,13 +1,13 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.common.collect.ImmutableList; -import com.launchdarkly.client.interfaces.DataStore; -import com.launchdarkly.client.interfaces.DataStoreTypes.DataKind; -import com.launchdarkly.client.interfaces.DataStoreTypes.FullDataSet; -import com.launchdarkly.client.interfaces.DataStoreTypes.ItemDescriptor; -import com.launchdarkly.client.interfaces.DataStoreTypes.KeyedItems; -import com.launchdarkly.client.interfaces.DiagnosticDescription; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DiagnosticDescription; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; import java.io.IOException; import java.util.HashMap; diff --git a/src/main/java/com/launchdarkly/client/JsonHelpers.java b/src/main/java/com/launchdarkly/sdk/server/JsonHelpers.java similarity index 97% rename from src/main/java/com/launchdarkly/client/JsonHelpers.java rename to src/main/java/com/launchdarkly/sdk/server/JsonHelpers.java index 8c9fb0145..5dc4e94e9 100644 --- a/src/main/java/com/launchdarkly/client/JsonHelpers.java +++ b/src/main/java/com/launchdarkly/sdk/server/JsonHelpers.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.gson.Gson; import com.google.gson.GsonBuilder; @@ -7,6 +7,7 @@ import com.google.gson.reflect.TypeToken; import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonWriter; +import com.launchdarkly.sdk.LDUser; import java.io.IOException; diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/sdk/server/LDClient.java similarity index 95% rename from src/main/java/com/launchdarkly/client/LDClient.java rename to src/main/java/com/launchdarkly/sdk/server/LDClient.java index cbd0d7d32..f92a83a01 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClient.java @@ -1,17 +1,20 @@ -package com.launchdarkly.client; - -import com.launchdarkly.client.integrations.EventProcessorBuilder; -import com.launchdarkly.client.interfaces.DataSource; -import com.launchdarkly.client.interfaces.DataSourceFactory; -import com.launchdarkly.client.interfaces.DataStore; -import com.launchdarkly.client.interfaces.DataStoreFactory; -import com.launchdarkly.client.interfaces.DataStoreTypes.ItemDescriptor; -import com.launchdarkly.client.interfaces.DataStoreTypes.KeyedItems; -import com.launchdarkly.client.interfaces.DataStoreUpdates; -import com.launchdarkly.client.interfaces.Event; -import com.launchdarkly.client.interfaces.EventProcessor; -import com.launchdarkly.client.interfaces.EventProcessorFactory; -import com.launchdarkly.client.value.LDValue; +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.integrations.EventProcessorBuilder; +import com.launchdarkly.sdk.server.interfaces.DataSource; +import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; +import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; +import com.launchdarkly.sdk.server.interfaces.Event; +import com.launchdarkly.sdk.server.interfaces.EventProcessor; +import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; import org.apache.commons.codec.binary.Hex; import org.slf4j.Logger; diff --git a/src/main/java/com/launchdarkly/client/LDClientInterface.java b/src/main/java/com/launchdarkly/sdk/server/LDClientInterface.java similarity index 98% rename from src/main/java/com/launchdarkly/client/LDClientInterface.java rename to src/main/java/com/launchdarkly/sdk/server/LDClientInterface.java index 533b44b88..5d6be9ff7 100644 --- a/src/main/java/com/launchdarkly/client/LDClientInterface.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClientInterface.java @@ -1,6 +1,8 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; import java.io.Closeable; import java.io.IOException; diff --git a/src/main/java/com/launchdarkly/client/LDConfig.java b/src/main/java/com/launchdarkly/sdk/server/LDConfig.java similarity index 93% rename from src/main/java/com/launchdarkly/client/LDConfig.java rename to src/main/java/com/launchdarkly/sdk/server/LDConfig.java index 7fdb6074b..6aaa3bcb7 100644 --- a/src/main/java/com/launchdarkly/client/LDConfig.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDConfig.java @@ -1,9 +1,9 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; -import com.launchdarkly.client.interfaces.DataSourceFactory; -import com.launchdarkly.client.interfaces.DataStoreFactory; -import com.launchdarkly.client.interfaces.EventProcessor; -import com.launchdarkly.client.interfaces.EventProcessorFactory; +import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; +import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; +import com.launchdarkly.sdk.server.interfaces.EventProcessor; +import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -20,7 +20,7 @@ import okhttp3.Credentials; /** - * 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}. + * This class exposes advanced configuration options for the {@link LDClient}. Instances of this class must be constructed with a {@link com.launchdarkly.sdk.server.LDConfig.Builder}. */ public final class LDConfig { private static final Logger logger = LoggerFactory.getLogger(LDConfig.class); @@ -78,7 +78,7 @@ protected LDConfig(Builder builder) { /** * A builder that helps construct - * {@link com.launchdarkly.client.LDConfig} objects. Builder calls can be chained, enabling the + * {@link com.launchdarkly.sdk.server.LDConfig} objects. Builder calls can be chained, enabling the * following pattern: *

        * LDConfig config = new LDConfig.Builder()
    @@ -130,7 +130,7 @@ public Builder connectTimeout(Duration connectTimeout) {
          * 

    * 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 + * {@link com.launchdarkly.sdk.server.integrations.FileData#dataSource()}. See those methods * for details on how to configure them. * * @param factory the factory object @@ -146,7 +146,7 @@ public Builder dataSource(DataSourceFactory factory) { * 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()}; for database integrations, use - * {@link Components#persistentDataStore(com.launchdarkly.client.interfaces.PersistentDataStoreFactory)}. + * {@link Components#persistentDataStore(com.launchdarkly.sdk.server.interfaces.PersistentDataStoreFactory)}. * * @param factory the factory object * @return the builder @@ -166,7 +166,7 @@ public Builder dataStore(DataStoreFactory factory) { * 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#diagnosticRecordingInterval(Duration) + * @see com.launchdarkly.sdk.server.integrations.EventProcessorBuilder#diagnosticRecordingInterval(Duration) * * @param diagnosticOptOut true if you want to opt out of sending any diagnostics data * @return the builder @@ -356,9 +356,9 @@ Authenticator proxyAuthenticator() { } /** - * Builds the configured {@link com.launchdarkly.client.LDConfig} object. + * Builds the configured {@link com.launchdarkly.sdk.server.LDConfig} object. * - * @return the {@link com.launchdarkly.client.LDConfig} configured by this builder + * @return the {@link com.launchdarkly.sdk.server.LDConfig} configured by this builder */ public LDConfig build() { return new LDConfig(this); diff --git a/src/main/java/com/launchdarkly/client/NewRelicReflector.java b/src/main/java/com/launchdarkly/sdk/server/NewRelicReflector.java similarity index 97% rename from src/main/java/com/launchdarkly/client/NewRelicReflector.java rename to src/main/java/com/launchdarkly/sdk/server/NewRelicReflector.java index 91a09c52c..62d660f14 100644 --- a/src/main/java/com/launchdarkly/client/NewRelicReflector.java +++ b/src/main/java/com/launchdarkly/sdk/server/NewRelicReflector.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.common.base.Joiner; diff --git a/src/main/java/com/launchdarkly/client/PollingProcessor.java b/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java similarity index 90% rename from src/main/java/com/launchdarkly/client/PollingProcessor.java rename to src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java index a71e15002..18f52fc7d 100644 --- a/src/main/java/com/launchdarkly/client/PollingProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java @@ -1,10 +1,10 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; 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.DataSource; -import com.launchdarkly.client.interfaces.DataStoreUpdates; +import com.launchdarkly.sdk.server.interfaces.DataSource; +import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -18,8 +18,8 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; -import static com.launchdarkly.client.Util.httpErrorMessage; -import static com.launchdarkly.client.Util.isHttpErrorRecoverable; +import static com.launchdarkly.sdk.server.Util.httpErrorMessage; +import static com.launchdarkly.sdk.server.Util.isHttpErrorRecoverable; final class PollingProcessor implements DataSource { private static final Logger logger = LoggerFactory.getLogger(PollingProcessor.class); diff --git a/src/main/java/com/launchdarkly/client/SemanticVersion.java b/src/main/java/com/launchdarkly/sdk/server/SemanticVersion.java similarity index 99% rename from src/main/java/com/launchdarkly/client/SemanticVersion.java rename to src/main/java/com/launchdarkly/sdk/server/SemanticVersion.java index 7e0ef034c..cb5a152cd 100644 --- a/src/main/java/com/launchdarkly/client/SemanticVersion.java +++ b/src/main/java/com/launchdarkly/sdk/server/SemanticVersion.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import java.util.regex.Matcher; import java.util.regex.Pattern; diff --git a/src/main/java/com/launchdarkly/client/SimpleLRUCache.java b/src/main/java/com/launchdarkly/sdk/server/SimpleLRUCache.java similarity index 94% rename from src/main/java/com/launchdarkly/client/SimpleLRUCache.java rename to src/main/java/com/launchdarkly/sdk/server/SimpleLRUCache.java index a048e9a06..cc79f6cf1 100644 --- a/src/main/java/com/launchdarkly/client/SimpleLRUCache.java +++ b/src/main/java/com/launchdarkly/sdk/server/SimpleLRUCache.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import java.util.LinkedHashMap; import java.util.Map; diff --git a/src/main/java/com/launchdarkly/client/StreamProcessor.java b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java similarity index 93% rename from src/main/java/com/launchdarkly/client/StreamProcessor.java rename to src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java index d663fb1b1..d623ff4d0 100644 --- a/src/main/java/com/launchdarkly/client/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java @@ -1,15 +1,15 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; 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.DataSource; -import com.launchdarkly.client.interfaces.DataStoreTypes.DataKind; -import com.launchdarkly.client.interfaces.DataStoreTypes.ItemDescriptor; -import com.launchdarkly.client.interfaces.DataStoreUpdates; import com.launchdarkly.eventsource.ConnectionErrorHandler; import com.launchdarkly.eventsource.ConnectionErrorHandler.Action; +import com.launchdarkly.sdk.server.interfaces.DataSource; +import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.eventsource.EventHandler; import com.launchdarkly.eventsource.EventSource; import com.launchdarkly.eventsource.MessageEvent; @@ -24,12 +24,12 @@ import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicBoolean; -import static com.launchdarkly.client.DataModel.DataKinds.FEATURES; -import static com.launchdarkly.client.DataModel.DataKinds.SEGMENTS; -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.sdk.server.DataModel.DataKinds.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.DataKinds.SEGMENTS; +import static com.launchdarkly.sdk.server.Util.configureHttpClientBuilder; +import static com.launchdarkly.sdk.server.Util.getHeadersBuilderFor; +import static com.launchdarkly.sdk.server.Util.httpErrorMessage; +import static com.launchdarkly.sdk.server.Util.isHttpErrorRecoverable; import okhttp3.Headers; import okhttp3.OkHttpClient; diff --git a/src/main/java/com/launchdarkly/client/Util.java b/src/main/java/com/launchdarkly/sdk/server/Util.java similarity index 98% rename from src/main/java/com/launchdarkly/client/Util.java rename to src/main/java/com/launchdarkly/sdk/server/Util.java index fd4c51a53..fea341fce 100644 --- a/src/main/java/com/launchdarkly/client/Util.java +++ b/src/main/java/com/launchdarkly/sdk/server/Util.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import java.util.concurrent.TimeUnit; diff --git a/src/main/java/com/launchdarkly/client/integrations/CacheMonitor.java b/src/main/java/com/launchdarkly/sdk/server/integrations/CacheMonitor.java similarity index 98% rename from src/main/java/com/launchdarkly/client/integrations/CacheMonitor.java rename to src/main/java/com/launchdarkly/sdk/server/integrations/CacheMonitor.java index e3592029a..506dd0145 100644 --- a/src/main/java/com/launchdarkly/client/integrations/CacheMonitor.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/CacheMonitor.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; import java.util.Objects; import java.util.concurrent.Callable; diff --git a/src/main/java/com/launchdarkly/client/integrations/EventProcessorBuilder.java b/src/main/java/com/launchdarkly/sdk/server/integrations/EventProcessorBuilder.java similarity index 91% rename from src/main/java/com/launchdarkly/client/integrations/EventProcessorBuilder.java rename to src/main/java/com/launchdarkly/sdk/server/integrations/EventProcessorBuilder.java index d1e6a587b..80274006e 100644 --- a/src/main/java/com/launchdarkly/client/integrations/EventProcessorBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/EventProcessorBuilder.java @@ -1,8 +1,8 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; -import com.launchdarkly.client.Components; -import com.launchdarkly.client.UserAttribute; -import com.launchdarkly.client.interfaces.EventProcessorFactory; +import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; import java.net.URI; import java.time.Duration; @@ -15,7 +15,7 @@ *

    * 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)}: + * properties with the methods of this class, and pass it to {@link com.launchdarkly.sdk.server.LDConfig.Builder#events(EventProcessorFactory)}: *

    
      *     LDConfig config = new LDConfig.Builder()
      *         .events(Components.sendEvents().capacity(5000).flushIntervalSeconds(2))
    @@ -72,12 +72,12 @@ public abstract class EventProcessorBuilder implements EventProcessorFactory {
        * 

    * 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}. + * {@link com.launchdarkly.sdk.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 + * @see com.launchdarkly.sdk.LDUser.Builder */ public EventProcessorBuilder allAttributesPrivate(boolean allAttributesPrivate) { this.allAttributesPrivate = allAttributesPrivate; @@ -124,9 +124,9 @@ public EventProcessorBuilder capacity(int capacity) { *

    * The default value is {@link #DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL}; the minimum value is * {@link #MIN_DIAGNOSTIC_RECORDING_INTERVAL}. This property is ignored if - * {@link com.launchdarkly.client.LDConfig.Builder#diagnosticOptOut(boolean)} is set to {@code true}. + * {@link com.launchdarkly.sdk.server.LDConfig.Builder#diagnosticOptOut(boolean)} is set to {@code true}. * - * @see com.launchdarkly.client.LDConfig.Builder#diagnosticOptOut(boolean) + * @see com.launchdarkly.sdk.server.LDConfig.Builder#diagnosticOptOut(boolean) * * @param diagnosticRecordingInterval the diagnostics interval; null to use the default * @return the builder @@ -175,7 +175,7 @@ public EventProcessorBuilder inlineUsersInEvents(boolean inlineUsersInEvents) { *

    * 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. + * individual user with {@link com.launchdarkly.sdk.LDUser.Builder} methods. *

    * Using {@link #privateAttributes(UserAttribute...)} is preferable to avoid the possibility of * misspelling a built-in attribute. @@ -183,7 +183,7 @@ public EventProcessorBuilder inlineUsersInEvents(boolean inlineUsersInEvents) { * @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 + * @see com.launchdarkly.sdk.LDUser.Builder */ public EventProcessorBuilder privateAttributeNames(String... attributeNames) { privateAttributes = new HashSet<>(); @@ -198,12 +198,12 @@ public EventProcessorBuilder privateAttributeNames(String... attributeNames) { *

    * 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. + * individual user with {@link com.launchdarkly.sdk.LDUser.Builder} methods. * * @param attributes a set of attributes that will be removed from user data set to LaunchDarkly * @return the builder * @see #allAttributesPrivate(boolean) - * @see com.launchdarkly.client.LDUser.Builder + * @see com.launchdarkly.sdk.LDUser.Builder * @see #privateAttributeNames */ public EventProcessorBuilder privateAttributes(UserAttribute... attributes) { diff --git a/src/main/java/com/launchdarkly/client/integrations/FileData.java b/src/main/java/com/launchdarkly/sdk/server/integrations/FileData.java similarity index 93% rename from src/main/java/com/launchdarkly/client/integrations/FileData.java rename to src/main/java/com/launchdarkly/sdk/server/integrations/FileData.java index af8096261..f38722cf1 100644 --- a/src/main/java/com/launchdarkly/client/integrations/FileData.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/FileData.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; /** * Integration between the LaunchDarkly SDK and file data. @@ -17,7 +17,7 @@ public abstract class FileData { *

    * 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.interfaces.DataSourceFactory)}. + * {@link com.launchdarkly.sdk.server.LDConfig.Builder#dataSource(com.launchdarkly.sdk.server.interfaces.DataSourceFactory)}. *

    * 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 @@ -34,8 +34,8 @@ public abstract class FileData { *

    * 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.Components#noEvents()} or - * {@link com.launchdarkly.client.LDConfig.Builder#offline(boolean)}. + * this with {@link com.launchdarkly.sdk.server.Components#noEvents()} or + * {@link com.launchdarkly.sdk.server.LDConfig.Builder#offline(boolean)}. *

    * Flag data files can be either JSON or YAML. They contain an object with three possible * properties: diff --git a/src/main/java/com/launchdarkly/client/integrations/FileDataSourceBuilder.java b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBuilder.java similarity index 87% rename from src/main/java/com/launchdarkly/client/integrations/FileDataSourceBuilder.java rename to src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBuilder.java index f101c7fdb..429f27382 100644 --- a/src/main/java/com/launchdarkly/client/integrations/FileDataSourceBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBuilder.java @@ -1,9 +1,9 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; -import com.launchdarkly.client.interfaces.ClientContext; -import com.launchdarkly.client.interfaces.DataSource; -import com.launchdarkly.client.interfaces.DataSourceFactory; -import com.launchdarkly.client.interfaces.DataStoreUpdates; +import com.launchdarkly.sdk.server.interfaces.ClientContext; +import com.launchdarkly.sdk.server.interfaces.DataSource; +import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; +import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; import java.nio.file.InvalidPathException; import java.nio.file.Path; @@ -14,7 +14,7 @@ /** * 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(DataSourceFactory)}. + * then pass the resulting object to {@link com.launchdarkly.sdk.server.LDConfig.Builder#dataSource(DataSourceFactory)}. *

    * For more details, see {@link FileData}. * diff --git a/src/main/java/com/launchdarkly/client/integrations/FileDataSourceImpl.java b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java similarity index 90% rename from src/main/java/com/launchdarkly/client/integrations/FileDataSourceImpl.java rename to src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java index e65a19785..08211c24f 100644 --- a/src/main/java/com/launchdarkly/client/integrations/FileDataSourceImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java @@ -1,19 +1,19 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; import com.google.common.collect.ImmutableList; import com.google.common.util.concurrent.Futures; -import com.launchdarkly.client.DataModel; -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 com.launchdarkly.client.interfaces.DataSource; -import com.launchdarkly.client.interfaces.DataStoreTypes.DataKind; -import com.launchdarkly.client.interfaces.DataStoreTypes.FullDataSet; -import com.launchdarkly.client.interfaces.DataStoreTypes.ItemDescriptor; -import com.launchdarkly.client.interfaces.DataStoreTypes.KeyedItems; -import com.launchdarkly.client.interfaces.DataStoreUpdates; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.DataModel; +import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FileDataException; +import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FlagFactory; +import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FlagFileParser; +import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FlagFileRep; +import com.launchdarkly.sdk.server.interfaces.DataSource; +import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/src/main/java/com/launchdarkly/client/integrations/FileDataSourceParsing.java b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceParsing.java similarity index 97% rename from src/main/java/com/launchdarkly/client/integrations/FileDataSourceParsing.java rename to src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceParsing.java index 67e27f6be..bc4235d9e 100644 --- a/src/main/java/com/launchdarkly/client/integrations/FileDataSourceParsing.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceParsing.java @@ -1,11 +1,11 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; import com.google.gson.Gson; import com.google.gson.JsonElement; import com.google.gson.JsonSyntaxException; -import com.launchdarkly.client.DataModel; -import com.launchdarkly.client.interfaces.DataStoreTypes.ItemDescriptor; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.DataModel; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import org.yaml.snakeyaml.Yaml; import org.yaml.snakeyaml.error.YAMLException; diff --git a/src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreBuilder.java b/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreBuilder.java similarity index 94% rename from src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreBuilder.java rename to src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreBuilder.java index 6e7b7c99f..9a662f132 100644 --- a/src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreBuilder.java @@ -1,11 +1,11 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; -import com.launchdarkly.client.Components; -import com.launchdarkly.client.interfaces.ClientContext; -import com.launchdarkly.client.interfaces.DataStore; -import com.launchdarkly.client.interfaces.DataStoreFactory; -import com.launchdarkly.client.interfaces.PersistentDataStore; -import com.launchdarkly.client.interfaces.PersistentDataStoreFactory; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.interfaces.ClientContext; +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; +import com.launchdarkly.sdk.server.interfaces.PersistentDataStore; +import com.launchdarkly.sdk.server.interfaces.PersistentDataStoreFactory; import java.time.Duration; import java.util.concurrent.TimeUnit; @@ -18,7 +18,7 @@ * There is also universal behavior that the SDK provides for all persistent data stores, such as caching; * the {@link PersistentDataStoreBuilder} adds this. *

    - * After configuring this object, pass it to {@link com.launchdarkly.client.LDConfig.Builder#dataStore(DataStoreFactory)} + * After configuring this object, pass it to {@link com.launchdarkly.sdk.server.LDConfig.Builder#dataStore(DataStoreFactory)} * to use it in the SDK configuration. For example, using the Redis integration: * *

    
    diff --git a/src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreWrapper.java b/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapper.java
    similarity index 96%
    rename from src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreWrapper.java
    rename to src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapper.java
    index 6db1ab594..0f66f7ea1 100644
    --- a/src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreWrapper.java
    +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapper.java
    @@ -1,4 +1,4 @@
    -package com.launchdarkly.client.integrations;
    +package com.launchdarkly.sdk.server.integrations;
     
     import com.google.common.base.Optional;
     import com.google.common.cache.CacheBuilder;
    @@ -9,13 +9,13 @@
     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.interfaces.DataStore;
    -import com.launchdarkly.client.interfaces.DataStoreTypes.DataKind;
    -import com.launchdarkly.client.interfaces.DataStoreTypes.FullDataSet;
    -import com.launchdarkly.client.interfaces.DataStoreTypes.ItemDescriptor;
    -import com.launchdarkly.client.interfaces.DataStoreTypes.KeyedItems;
    -import com.launchdarkly.client.interfaces.DataStoreTypes.SerializedItemDescriptor;
    -import com.launchdarkly.client.interfaces.PersistentDataStore;
    +import com.launchdarkly.sdk.server.interfaces.DataStore;
    +import com.launchdarkly.sdk.server.interfaces.PersistentDataStore;
    +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind;
    +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet;
    +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor;
    +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems;
    +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.SerializedItemDescriptor;
     
     import java.io.IOException;
     import java.time.Duration;
    diff --git a/src/main/java/com/launchdarkly/client/integrations/PollingDataSourceBuilder.java b/src/main/java/com/launchdarkly/sdk/server/integrations/PollingDataSourceBuilder.java
    similarity index 91%
    rename from src/main/java/com/launchdarkly/client/integrations/PollingDataSourceBuilder.java
    rename to src/main/java/com/launchdarkly/sdk/server/integrations/PollingDataSourceBuilder.java
    index d5d53a61b..43f1fa38a 100644
    --- a/src/main/java/com/launchdarkly/client/integrations/PollingDataSourceBuilder.java
    +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/PollingDataSourceBuilder.java
    @@ -1,7 +1,7 @@
    -package com.launchdarkly.client.integrations;
    +package com.launchdarkly.sdk.server.integrations;
     
    -import com.launchdarkly.client.Components;
    -import com.launchdarkly.client.interfaces.DataSourceFactory;
    +import com.launchdarkly.sdk.server.Components;
    +import com.launchdarkly.sdk.server.interfaces.DataSourceFactory;
     
     import java.net.URI;
     import java.time.Duration;
    @@ -15,7 +15,7 @@
      * 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(DataSourceFactory)}: + * change its properties with the methods of this class, and pass it to {@link com.launchdarkly.sdk.server.LDConfig.Builder#dataSource(DataSourceFactory)}: *

    
      *     LDConfig config = new LDConfig.Builder()
      *         .dataSource(Components.pollingDataSource().pollIntervalMillis(45000))
    diff --git a/src/main/java/com/launchdarkly/client/integrations/Redis.java b/src/main/java/com/launchdarkly/sdk/server/integrations/Redis.java
    similarity index 76%
    rename from src/main/java/com/launchdarkly/client/integrations/Redis.java
    rename to src/main/java/com/launchdarkly/sdk/server/integrations/Redis.java
    index 41297d76e..3f3536e76 100644
    --- a/src/main/java/com/launchdarkly/client/integrations/Redis.java
    +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/Redis.java
    @@ -1,4 +1,4 @@
    -package com.launchdarkly.client.integrations;
    +package com.launchdarkly.sdk.server.integrations;
     
     /**
      * Integration between the LaunchDarkly SDK and Redis.
    @@ -10,9 +10,9 @@ 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 Redis options. Then, pass it to {@link com.launchdarkly.client.Components#persistentDataStore(com.launchdarkly.client.interfaces.PersistentDataStoreFactory)} + * custom Redis options. Then, pass it to {@link com.launchdarkly.sdk.server.Components#persistentDataStore(com.launchdarkly.sdk.server.interfaces.PersistentDataStoreFactory)} * and set any desired caching options. Finally, pass the result to - * {@link com.launchdarkly.client.LDConfig.Builder#dataStore(com.launchdarkly.client.interfaces.DataStoreFactory)}. + * {@link com.launchdarkly.sdk.server.LDConfig.Builder#dataStore(com.launchdarkly.sdk.server.interfaces.DataStoreFactory)}. * For example: * *

    
    diff --git a/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java b/src/main/java/com/launchdarkly/sdk/server/integrations/RedisDataStoreBuilder.java
    similarity index 91%
    rename from src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java
    rename to src/main/java/com/launchdarkly/sdk/server/integrations/RedisDataStoreBuilder.java
    index f100ae9fd..bf824184c 100644
    --- a/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java
    +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/RedisDataStoreBuilder.java
    @@ -1,12 +1,12 @@
    -package com.launchdarkly.client.integrations;
    +package com.launchdarkly.sdk.server.integrations;
     
     import com.google.common.base.Joiner;
    -import com.launchdarkly.client.LDConfig;
    -import com.launchdarkly.client.interfaces.ClientContext;
    -import com.launchdarkly.client.interfaces.DiagnosticDescription;
    -import com.launchdarkly.client.interfaces.PersistentDataStore;
    -import com.launchdarkly.client.interfaces.PersistentDataStoreFactory;
    -import com.launchdarkly.client.value.LDValue;
    +import com.launchdarkly.sdk.LDValue;
    +import com.launchdarkly.sdk.server.LDConfig;
    +import com.launchdarkly.sdk.server.interfaces.ClientContext;
    +import com.launchdarkly.sdk.server.interfaces.DiagnosticDescription;
    +import com.launchdarkly.sdk.server.interfaces.PersistentDataStore;
    +import com.launchdarkly.sdk.server.interfaces.PersistentDataStoreFactory;
     
     import java.net.URI;
     import java.time.Duration;
    @@ -21,7 +21,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.interfaces.DataStoreFactory)}. + * {@link com.launchdarkly.sdk.server.LDConfig.Builder#dataStore(com.launchdarkly.sdk.server.interfaces.DataStoreFactory)}. * You do not need to call {@link #createPersistentDataStore(ClientContext)} yourself to build the actual data store; that * will be done by the SDK. *

    diff --git a/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreImpl.java b/src/main/java/com/launchdarkly/sdk/server/integrations/RedisDataStoreImpl.java similarity index 92% rename from src/main/java/com/launchdarkly/client/integrations/RedisDataStoreImpl.java rename to src/main/java/com/launchdarkly/sdk/server/integrations/RedisDataStoreImpl.java index a2f2e0d36..7d1133cfe 100644 --- a/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/RedisDataStoreImpl.java @@ -1,13 +1,13 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.Maps; -import com.launchdarkly.client.interfaces.DataStoreTypes.DataKind; -import com.launchdarkly.client.interfaces.DataStoreTypes.FullDataSet; -import com.launchdarkly.client.interfaces.DataStoreTypes.ItemDescriptor; -import com.launchdarkly.client.interfaces.DataStoreTypes.KeyedItems; -import com.launchdarkly.client.interfaces.DataStoreTypes.SerializedItemDescriptor; -import com.launchdarkly.client.interfaces.PersistentDataStore; +import com.launchdarkly.sdk.server.interfaces.PersistentDataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.SerializedItemDescriptor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/src/main/java/com/launchdarkly/client/integrations/StreamingDataSourceBuilder.java b/src/main/java/com/launchdarkly/sdk/server/integrations/StreamingDataSourceBuilder.java similarity index 92% rename from src/main/java/com/launchdarkly/client/integrations/StreamingDataSourceBuilder.java rename to src/main/java/com/launchdarkly/sdk/server/integrations/StreamingDataSourceBuilder.java index a54f15a58..4943858d2 100644 --- a/src/main/java/com/launchdarkly/client/integrations/StreamingDataSourceBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/StreamingDataSourceBuilder.java @@ -1,7 +1,7 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; -import com.launchdarkly.client.Components; -import com.launchdarkly.client.interfaces.DataSourceFactory; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; import java.net.URI; import java.time.Duration; @@ -11,7 +11,7 @@ *

    * 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(DataSourceFactory)}: + * change its properties with the methods of this class, and pass it to {@link com.launchdarkly.sdk.server.LDConfig.Builder#dataSource(DataSourceFactory)}: *

    
      *     LDConfig config = new LDConfig.Builder()
      *         .dataSource(Components.streamingDataSource().initialReconnectDelayMillis(500))
    diff --git a/src/main/java/com/launchdarkly/client/integrations/package-info.java b/src/main/java/com/launchdarkly/sdk/server/integrations/package-info.java
    similarity index 80%
    rename from src/main/java/com/launchdarkly/client/integrations/package-info.java
    rename to src/main/java/com/launchdarkly/sdk/server/integrations/package-info.java
    index 079858106..8e89a15e1 100644
    --- a/src/main/java/com/launchdarkly/client/integrations/package-info.java
    +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/package-info.java
    @@ -2,12 +2,12 @@
      * 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} + * In the current main LaunchDarkly Java SDK library, this package contains {@link com.launchdarkly.sdk.server.integrations.Redis} + * (for using Redis as a store for flag data) and {@link com.launchdarkly.sdk.server.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; +package com.launchdarkly.sdk.server.integrations; diff --git a/src/main/java/com/launchdarkly/client/interfaces/ClientContext.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/ClientContext.java similarity index 68% rename from src/main/java/com/launchdarkly/client/interfaces/ClientContext.java rename to src/main/java/com/launchdarkly/sdk/server/interfaces/ClientContext.java index 2b20d3c87..fc9c922eb 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/ClientContext.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/ClientContext.java @@ -1,9 +1,9 @@ -package com.launchdarkly.client.interfaces; +package com.launchdarkly.sdk.server.interfaces; -import com.launchdarkly.client.LDConfig; +import com.launchdarkly.sdk.server.LDConfig; /** - * Context information provided by the {@link com.launchdarkly.client.LDClient} when creating components. + * Context information provided by the {@link com.launchdarkly.sdk.server.LDClient} when creating components. *

    * This is passed as a parameter to {@link DataStoreFactory#createDataStore(ClientContext)}, etc. The * actual implementation class may contain other properties that are only relevant to the built-in SDK @@ -14,7 +14,7 @@ */ public interface ClientContext { /** - * The current {@link com.launchdarkly.client.LDClient} instance's SDK key. + * The current {@link com.launchdarkly.sdk.server.LDClient} instance's SDK key. * @return the SDK key */ public String getSdkKey(); diff --git a/src/main/java/com/launchdarkly/client/interfaces/DataSource.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSource.java similarity index 94% rename from src/main/java/com/launchdarkly/client/interfaces/DataSource.java rename to src/main/java/com/launchdarkly/sdk/server/interfaces/DataSource.java index 008f08240..848420cb3 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/DataSource.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSource.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client.interfaces; +package com.launchdarkly.sdk.server.interfaces; import java.io.Closeable; import java.io.IOException; diff --git a/src/main/java/com/launchdarkly/client/interfaces/DataSourceFactory.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceFactory.java similarity index 84% rename from src/main/java/com/launchdarkly/client/interfaces/DataSourceFactory.java rename to src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceFactory.java index 01cf9c8b6..87f9c1482 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/DataSourceFactory.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceFactory.java @@ -1,6 +1,6 @@ -package com.launchdarkly.client.interfaces; +package com.launchdarkly.sdk.server.interfaces; -import com.launchdarkly.client.Components; +import com.launchdarkly.sdk.server.Components; /** * Interface for a factory that creates some implementation of {@link DataSource}. diff --git a/src/main/java/com/launchdarkly/client/interfaces/DataStore.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStore.java similarity index 89% rename from src/main/java/com/launchdarkly/client/interfaces/DataStore.java rename to src/main/java/com/launchdarkly/sdk/server/interfaces/DataStore.java index 125d83aed..83f8d34cd 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/DataStore.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStore.java @@ -1,9 +1,9 @@ -package com.launchdarkly.client.interfaces; +package com.launchdarkly.sdk.server.interfaces; -import com.launchdarkly.client.interfaces.DataStoreTypes.DataKind; -import com.launchdarkly.client.interfaces.DataStoreTypes.FullDataSet; -import com.launchdarkly.client.interfaces.DataStoreTypes.ItemDescriptor; -import com.launchdarkly.client.interfaces.DataStoreTypes.KeyedItems; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; import java.io.Closeable; diff --git a/src/main/java/com/launchdarkly/client/interfaces/DataStoreFactory.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreFactory.java similarity index 79% rename from src/main/java/com/launchdarkly/client/interfaces/DataStoreFactory.java rename to src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreFactory.java index ef4a3cf35..0ed2456ad 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/DataStoreFactory.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreFactory.java @@ -1,6 +1,6 @@ -package com.launchdarkly.client.interfaces; +package com.launchdarkly.sdk.server.interfaces; -import com.launchdarkly.client.Components; +import com.launchdarkly.sdk.server.Components; /** * Interface for a factory that creates some implementation of {@link DataStore}. diff --git a/src/main/java/com/launchdarkly/client/interfaces/DataStoreTypes.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypes.java similarity index 99% rename from src/main/java/com/launchdarkly/client/interfaces/DataStoreTypes.java rename to src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypes.java index 777d2dc98..12d7380c8 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/DataStoreTypes.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypes.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client.interfaces; +package com.launchdarkly.sdk.server.interfaces; import com.google.common.base.Objects; import com.google.common.collect.ImmutableList; diff --git a/src/main/java/com/launchdarkly/client/interfaces/DataStoreUpdates.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreUpdates.java similarity index 86% rename from src/main/java/com/launchdarkly/client/interfaces/DataStoreUpdates.java rename to src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreUpdates.java index 6fc91d764..2b8d19757 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/DataStoreUpdates.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreUpdates.java @@ -1,8 +1,8 @@ -package com.launchdarkly.client.interfaces; +package com.launchdarkly.sdk.server.interfaces; -import com.launchdarkly.client.interfaces.DataStoreTypes.DataKind; -import com.launchdarkly.client.interfaces.DataStoreTypes.FullDataSet; -import com.launchdarkly.client.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; /** * Interface that a data source implementation will use to push data into the underlying diff --git a/src/main/java/com/launchdarkly/client/interfaces/DiagnosticDescription.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DiagnosticDescription.java similarity index 70% rename from src/main/java/com/launchdarkly/client/interfaces/DiagnosticDescription.java rename to src/main/java/com/launchdarkly/sdk/server/interfaces/DiagnosticDescription.java index 89b0244f9..5cbc0c832 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/DiagnosticDescription.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DiagnosticDescription.java @@ -1,15 +1,15 @@ -package com.launchdarkly.client.interfaces; +package com.launchdarkly.sdk.server.interfaces; -import com.launchdarkly.client.LDConfig; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.LDConfig; /** * 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.interfaces.DataStoreFactory}, - * {@link com.launchdarkly.client.interfaces.DataSourceFactory}, {@link com.launchdarkly.client.interfaces.EventProcessorFactory}, - * or {@link com.launchdarkly.client.interfaces.PersistentDataStoreFactory} may choose to contribute + * Any class that implements {@link com.launchdarkly.sdk.server.interfaces.DataStoreFactory}, + * {@link com.launchdarkly.sdk.server.interfaces.DataSourceFactory}, {@link com.launchdarkly.sdk.server.interfaces.EventProcessorFactory}, + * or {@link com.launchdarkly.sdk.server.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()}. *

    diff --git a/src/main/java/com/launchdarkly/client/interfaces/Event.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/Event.java similarity index 96% rename from src/main/java/com/launchdarkly/client/interfaces/Event.java rename to src/main/java/com/launchdarkly/sdk/server/interfaces/Event.java index 9c37f3a0a..bad2815b8 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/Event.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/Event.java @@ -1,9 +1,9 @@ -package com.launchdarkly.client.interfaces; +package com.launchdarkly.sdk.server.interfaces; -import com.launchdarkly.client.EvaluationReason; -import com.launchdarkly.client.LDClientInterface; -import com.launchdarkly.client.LDUser; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.LDClientInterface; /** * Base class for all analytics events that are generated by the client. Also defines all of its own subclasses. diff --git a/src/main/java/com/launchdarkly/client/interfaces/EventProcessor.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/EventProcessor.java similarity index 93% rename from src/main/java/com/launchdarkly/client/interfaces/EventProcessor.java rename to src/main/java/com/launchdarkly/sdk/server/interfaces/EventProcessor.java index a489bc525..656964257 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/EventProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/EventProcessor.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client.interfaces; +package com.launchdarkly.sdk.server.interfaces; import java.io.Closeable; diff --git a/src/main/java/com/launchdarkly/client/interfaces/EventProcessorFactory.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/EventProcessorFactory.java similarity index 80% rename from src/main/java/com/launchdarkly/client/interfaces/EventProcessorFactory.java rename to src/main/java/com/launchdarkly/sdk/server/interfaces/EventProcessorFactory.java index 9d5ea1254..afc247770 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/EventProcessorFactory.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/EventProcessorFactory.java @@ -1,6 +1,6 @@ -package com.launchdarkly.client.interfaces; +package com.launchdarkly.sdk.server.interfaces; -import com.launchdarkly.client.Components; +import com.launchdarkly.sdk.server.Components; /** * Interface for a factory that creates some implementation of {@link EventProcessor}. diff --git a/src/main/java/com/launchdarkly/client/interfaces/PersistentDataStore.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/PersistentDataStore.java similarity index 94% rename from src/main/java/com/launchdarkly/client/interfaces/PersistentDataStore.java rename to src/main/java/com/launchdarkly/sdk/server/interfaces/PersistentDataStore.java index 1797e92af..03c0794e3 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/PersistentDataStore.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/PersistentDataStore.java @@ -1,9 +1,9 @@ -package com.launchdarkly.client.interfaces; +package com.launchdarkly.sdk.server.interfaces; -import com.launchdarkly.client.interfaces.DataStoreTypes.DataKind; -import com.launchdarkly.client.interfaces.DataStoreTypes.FullDataSet; -import com.launchdarkly.client.interfaces.DataStoreTypes.KeyedItems; -import com.launchdarkly.client.interfaces.DataStoreTypes.SerializedItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.SerializedItemDescriptor; import java.io.Closeable; diff --git a/src/main/java/com/launchdarkly/client/interfaces/PersistentDataStoreFactory.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/PersistentDataStoreFactory.java similarity index 70% rename from src/main/java/com/launchdarkly/client/interfaces/PersistentDataStoreFactory.java rename to src/main/java/com/launchdarkly/sdk/server/interfaces/PersistentDataStoreFactory.java index 6b94d03ad..f86b7b788 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/PersistentDataStoreFactory.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/PersistentDataStoreFactory.java @@ -1,14 +1,14 @@ -package com.launchdarkly.client.interfaces; +package com.launchdarkly.sdk.server.interfaces; -import com.launchdarkly.client.integrations.PersistentDataStoreBuilder; +import com.launchdarkly.sdk.server.integrations.PersistentDataStoreBuilder; /** * Interface for a factory that creates some implementation of a persistent data store. *

    * This interface is implemented by database integrations. Usage is described in - * {@link com.launchdarkly.client.Components#persistentDataStore}. + * {@link com.launchdarkly.sdk.server.Components#persistentDataStore}. * - * @see com.launchdarkly.client.Components + * @see com.launchdarkly.sdk.server.Components * @since 4.12.0 */ public interface PersistentDataStoreFactory { diff --git a/src/main/java/com/launchdarkly/client/interfaces/VersionedData.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/VersionedData.java similarity index 91% rename from src/main/java/com/launchdarkly/client/interfaces/VersionedData.java rename to src/main/java/com/launchdarkly/sdk/server/interfaces/VersionedData.java index 971c9ea7f..b1f788322 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/VersionedData.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/VersionedData.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client.interfaces; +package com.launchdarkly.sdk.server.interfaces; /** * Common interface for string-keyed, versioned objects that can be kept in a {@link DataStore}. diff --git a/src/main/java/com/launchdarkly/client/interfaces/VersionedDataKind.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/VersionedDataKind.java similarity index 98% rename from src/main/java/com/launchdarkly/client/interfaces/VersionedDataKind.java rename to src/main/java/com/launchdarkly/sdk/server/interfaces/VersionedDataKind.java index d13b790cc..87dd3db84 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/VersionedDataKind.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/VersionedDataKind.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client.interfaces; +package com.launchdarkly.sdk.server.interfaces; import com.google.common.collect.ImmutableList; diff --git a/src/main/java/com/launchdarkly/client/interfaces/package-info.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/package-info.java similarity index 83% rename from src/main/java/com/launchdarkly/client/interfaces/package-info.java rename to src/main/java/com/launchdarkly/sdk/server/interfaces/package-info.java index d798dc8f0..5d38c8803 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/package-info.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/package-info.java @@ -4,4 +4,4 @@ * 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; +package com.launchdarkly.sdk.server.interfaces; diff --git a/src/main/java/com/launchdarkly/sdk/server/package-info.java b/src/main/java/com/launchdarkly/sdk/server/package-info.java new file mode 100644 index 000000000..50149adce --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/package-info.java @@ -0,0 +1,8 @@ +/** + * The main package for the LaunchDarkly Java SDK. + *

    + * You will most often use {@link com.launchdarkly.sdk.server.LDClient} (the SDK client), + * {@link com.launchdarkly.sdk.server.LDConfig} (configuration options for the client), and + * {@link com.launchdarkly.sdk.LDUser} (user properties for feature flag evaluation). + */ +package com.launchdarkly.sdk.server; diff --git a/src/test/java/com/launchdarkly/client/EvaluationReasonTest.java b/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java similarity index 96% rename from src/test/java/com/launchdarkly/client/EvaluationReasonTest.java rename to src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java index 2941617a7..d319bbcdd 100644 --- a/src/test/java/com/launchdarkly/client/EvaluationReasonTest.java +++ b/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java @@ -1,7 +1,8 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk; import com.google.gson.Gson; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDValue; import org.junit.Test; diff --git a/src/test/java/com/launchdarkly/client/LDUserTest.java b/src/test/java/com/launchdarkly/sdk/LDUserTest.java similarity index 99% rename from src/test/java/com/launchdarkly/client/LDUserTest.java rename to src/test/java/com/launchdarkly/sdk/LDUserTest.java index 750be7209..3ac454f5c 100644 --- a/src/test/java/com/launchdarkly/client/LDUserTest.java +++ b/src/test/java/com/launchdarkly/sdk/LDUserTest.java @@ -1,9 +1,8 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Iterables; -import com.launchdarkly.client.value.LDValue; import org.junit.Test; diff --git a/src/test/java/com/launchdarkly/client/value/LDValueTest.java b/src/test/java/com/launchdarkly/sdk/LDValueTest.java similarity index 98% rename from src/test/java/com/launchdarkly/client/value/LDValueTest.java rename to src/test/java/com/launchdarkly/sdk/LDValueTest.java index 73ee44b44..d3fb71b5a 100644 --- a/src/test/java/com/launchdarkly/client/value/LDValueTest.java +++ b/src/test/java/com/launchdarkly/sdk/LDValueTest.java @@ -1,8 +1,12 @@ -package com.launchdarkly.client.value; +package com.launchdarkly.sdk; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.gson.Gson; +import com.launchdarkly.sdk.ArrayBuilder; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.LDValueType; +import com.launchdarkly.sdk.ObjectBuilder; import org.junit.Test; diff --git a/src/test/java/com/launchdarkly/client/UserAttributeTest.java b/src/test/java/com/launchdarkly/sdk/UserAttributeTest.java similarity index 98% rename from src/test/java/com/launchdarkly/client/UserAttributeTest.java rename to src/test/java/com/launchdarkly/sdk/UserAttributeTest.java index bebd622bc..04a2d5f77 100644 --- a/src/test/java/com/launchdarkly/client/UserAttributeTest.java +++ b/src/test/java/com/launchdarkly/sdk/UserAttributeTest.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk; import org.junit.Test; diff --git a/src/test/java/com/launchdarkly/client/DataStoreTestBase.java b/src/test/java/com/launchdarkly/sdk/server/DataStoreTestBase.java similarity index 90% rename from src/test/java/com/launchdarkly/client/DataStoreTestBase.java rename to src/test/java/com/launchdarkly/sdk/server/DataStoreTestBase.java index 71fcf8bab..38fef5927 100644 --- a/src/test/java/com/launchdarkly/client/DataStoreTestBase.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataStoreTestBase.java @@ -1,10 +1,10 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; -import com.launchdarkly.client.DataStoreTestTypes.DataBuilder; -import com.launchdarkly.client.DataStoreTestTypes.TestItem; -import com.launchdarkly.client.interfaces.DataStore; -import com.launchdarkly.client.interfaces.DataStoreTypes.FullDataSet; -import com.launchdarkly.client.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.DataStoreTestTypes.DataBuilder; +import com.launchdarkly.sdk.server.DataStoreTestTypes.TestItem; +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import org.junit.After; import org.junit.Before; @@ -12,9 +12,9 @@ import java.util.Map; -import static com.launchdarkly.client.DataStoreTestTypes.OTHER_TEST_ITEMS; -import static com.launchdarkly.client.DataStoreTestTypes.TEST_ITEMS; -import static com.launchdarkly.client.DataStoreTestTypes.toItemsMap; +import static com.launchdarkly.sdk.server.DataStoreTestTypes.OTHER_TEST_ITEMS; +import static com.launchdarkly.sdk.server.DataStoreTestTypes.TEST_ITEMS; +import static com.launchdarkly.sdk.server.DataStoreTestTypes.toItemsMap; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; diff --git a/src/test/java/com/launchdarkly/client/DataStoreTestTypes.java b/src/test/java/com/launchdarkly/sdk/server/DataStoreTestTypes.java similarity index 90% rename from src/test/java/com/launchdarkly/client/DataStoreTestTypes.java rename to src/test/java/com/launchdarkly/sdk/server/DataStoreTestTypes.java index 6f01e56bc..f8c62ebb4 100644 --- a/src/test/java/com/launchdarkly/client/DataStoreTestTypes.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataStoreTestTypes.java @@ -1,15 +1,16 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.common.base.Objects; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.Maps; -import com.launchdarkly.client.interfaces.DataStoreTypes.DataKind; -import com.launchdarkly.client.interfaces.DataStoreTypes.FullDataSet; -import com.launchdarkly.client.interfaces.DataStoreTypes.ItemDescriptor; -import com.launchdarkly.client.interfaces.DataStoreTypes.KeyedItems; -import com.launchdarkly.client.interfaces.DataStoreTypes.SerializedItemDescriptor; -import com.launchdarkly.client.interfaces.VersionedData; +import com.launchdarkly.sdk.server.JsonHelpers; +import com.launchdarkly.sdk.server.interfaces.VersionedData; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.SerializedItemDescriptor; import java.util.AbstractMap; import java.util.HashMap; diff --git a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java similarity index 97% rename from src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java rename to src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java index 78743286f..2df3302ff 100644 --- a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java @@ -1,11 +1,14 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.common.collect.ImmutableSet; import com.google.gson.Gson; -import com.launchdarkly.client.integrations.EventProcessorBuilder; -import com.launchdarkly.client.interfaces.Event; -import com.launchdarkly.client.interfaces.EventProcessorFactory; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.server.integrations.EventProcessorBuilder; +import com.launchdarkly.sdk.server.interfaces.Event; +import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; import org.hamcrest.Matcher; import org.hamcrest.Matchers; @@ -18,14 +21,14 @@ import java.util.UUID; import java.util.concurrent.TimeUnit; -import static com.launchdarkly.client.Components.sendEvents; -import static com.launchdarkly.client.ModelBuilders.flagBuilder; -import static com.launchdarkly.client.TestHttpUtil.httpsServerWithSelfSignedCert; -import static com.launchdarkly.client.TestHttpUtil.makeStartedServer; -import static com.launchdarkly.client.TestUtil.clientContext; -import static com.launchdarkly.client.TestUtil.hasJsonProperty; -import static com.launchdarkly.client.TestUtil.isJsonArray; -import static com.launchdarkly.client.TestUtil.simpleEvaluation; +import static com.launchdarkly.sdk.server.Components.sendEvents; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.TestHttpUtil.httpsServerWithSelfSignedCert; +import static com.launchdarkly.sdk.server.TestHttpUtil.makeStartedServer; +import static com.launchdarkly.sdk.server.TestUtil.clientContext; +import static com.launchdarkly.sdk.server.TestUtil.hasJsonProperty; +import static com.launchdarkly.sdk.server.TestUtil.isJsonArray; +import static com.launchdarkly.sdk.server.TestUtil.simpleEvaluation; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.allOf; import static org.hamcrest.Matchers.contains; diff --git a/src/test/java/com/launchdarkly/client/DiagnosticAccumulatorTest.java b/src/test/java/com/launchdarkly/sdk/server/DiagnosticAccumulatorTest.java similarity index 92% rename from src/test/java/com/launchdarkly/client/DiagnosticAccumulatorTest.java rename to src/test/java/com/launchdarkly/sdk/server/DiagnosticAccumulatorTest.java index 95d1eff69..06f9baa81 100644 --- a/src/test/java/com/launchdarkly/client/DiagnosticAccumulatorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DiagnosticAccumulatorTest.java @@ -1,4 +1,8 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.DiagnosticAccumulator; +import com.launchdarkly.sdk.server.DiagnosticEvent; +import com.launchdarkly.sdk.server.DiagnosticId; import org.junit.Test; diff --git a/src/test/java/com/launchdarkly/client/DiagnosticEventTest.java b/src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java similarity index 95% rename from src/test/java/com/launchdarkly/client/DiagnosticEventTest.java rename to src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java index 3693daadf..dfab347ec 100644 --- a/src/test/java/com/launchdarkly/client/DiagnosticEventTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java @@ -1,11 +1,15 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.gson.Gson; import com.google.gson.JsonArray; import com.google.gson.JsonObject; -import com.launchdarkly.client.integrations.Redis; -import com.launchdarkly.client.value.LDValue; -import com.launchdarkly.client.value.ObjectBuilder; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.ObjectBuilder; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.DiagnosticEvent; +import com.launchdarkly.sdk.server.DiagnosticId; +import com.launchdarkly.sdk.server.LDConfig; +import com.launchdarkly.sdk.server.integrations.Redis; import org.junit.Test; diff --git a/src/test/java/com/launchdarkly/client/DiagnosticIdTest.java b/src/test/java/com/launchdarkly/sdk/server/DiagnosticIdTest.java similarity index 94% rename from src/test/java/com/launchdarkly/client/DiagnosticIdTest.java rename to src/test/java/com/launchdarkly/sdk/server/DiagnosticIdTest.java index d8e7630c7..a81bd95eb 100644 --- a/src/test/java/com/launchdarkly/client/DiagnosticIdTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DiagnosticIdTest.java @@ -1,7 +1,8 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.gson.Gson; import com.google.gson.JsonObject; +import com.launchdarkly.sdk.server.DiagnosticId; import org.junit.Test; diff --git a/src/test/java/com/launchdarkly/client/DiagnosticSdkTest.java b/src/test/java/com/launchdarkly/sdk/server/DiagnosticSdkTest.java similarity index 92% rename from src/test/java/com/launchdarkly/client/DiagnosticSdkTest.java rename to src/test/java/com/launchdarkly/sdk/server/DiagnosticSdkTest.java index 2d7b56425..1c4cf7580 100644 --- a/src/test/java/com/launchdarkly/client/DiagnosticSdkTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DiagnosticSdkTest.java @@ -1,8 +1,11 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.gson.Gson; import com.google.gson.JsonObject; -import com.launchdarkly.client.DiagnosticEvent.Init.DiagnosticSdk; +import com.launchdarkly.sdk.server.LDClient; +import com.launchdarkly.sdk.server.LDConfig; +import com.launchdarkly.sdk.server.DiagnosticEvent.Init.DiagnosticSdk; + import org.junit.Test; import static org.junit.Assert.assertEquals; diff --git a/src/test/java/com/launchdarkly/client/EvaluatorBucketingTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorBucketingTest.java similarity index 92% rename from src/test/java/com/launchdarkly/client/EvaluatorBucketingTest.java rename to src/test/java/com/launchdarkly/sdk/server/EvaluatorBucketingTest.java index 06db2c3ba..7cafb3705 100644 --- a/src/test/java/com/launchdarkly/client/EvaluatorBucketingTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorBucketingTest.java @@ -1,8 +1,10 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; -import com.launchdarkly.client.DataModel.Rollout; -import com.launchdarkly.client.DataModel.VariationOrRollout; -import com.launchdarkly.client.DataModel.WeightedVariation; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.server.DataModel.Rollout; +import com.launchdarkly.sdk.server.DataModel.VariationOrRollout; +import com.launchdarkly.sdk.server.DataModel.WeightedVariation; import org.hamcrest.Matchers; import org.junit.Test; diff --git a/src/test/java/com/launchdarkly/client/EvaluatorClauseTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorClauseTest.java similarity index 86% rename from src/test/java/com/launchdarkly/client/EvaluatorClauseTest.java rename to src/test/java/com/launchdarkly/sdk/server/EvaluatorClauseTest.java index 54753fd44..38b7e8454 100644 --- a/src/test/java/com/launchdarkly/client/EvaluatorClauseTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorClauseTest.java @@ -1,20 +1,24 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.gson.Gson; import com.google.gson.JsonElement; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.UserAttribute; import org.junit.Test; -import static com.launchdarkly.client.EvaluationDetail.fromValue; -import static com.launchdarkly.client.EvaluatorTestUtil.BASE_EVALUATOR; -import static com.launchdarkly.client.EvaluatorTestUtil.evaluatorBuilder; -import static com.launchdarkly.client.ModelBuilders.booleanFlagWithClauses; -import static com.launchdarkly.client.ModelBuilders.clause; -import static com.launchdarkly.client.ModelBuilders.fallthroughVariation; -import static com.launchdarkly.client.ModelBuilders.flagBuilder; -import static com.launchdarkly.client.ModelBuilders.ruleBuilder; -import static com.launchdarkly.client.ModelBuilders.segmentBuilder; +import static com.launchdarkly.sdk.EvaluationDetail.fromValue; +import static com.launchdarkly.sdk.server.EvaluatorTestUtil.BASE_EVALUATOR; +import static com.launchdarkly.sdk.server.EvaluatorTestUtil.evaluatorBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.booleanFlagWithClauses; +import static com.launchdarkly.sdk.server.ModelBuilders.clause; +import static com.launchdarkly.sdk.server.ModelBuilders.fallthroughVariation; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.ruleBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.segmentBuilder; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; diff --git a/src/test/java/com/launchdarkly/client/EvaluatorOperatorsParameterizedTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorOperatorsParameterizedTest.java similarity index 97% rename from src/test/java/com/launchdarkly/client/EvaluatorOperatorsParameterizedTest.java rename to src/test/java/com/launchdarkly/sdk/server/EvaluatorOperatorsParameterizedTest.java index 1d69208e9..240f70c27 100644 --- a/src/test/java/com/launchdarkly/client/EvaluatorOperatorsParameterizedTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorOperatorsParameterizedTest.java @@ -1,6 +1,8 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.DataModel; +import com.launchdarkly.sdk.server.EvaluatorOperators; import org.junit.Test; import org.junit.runner.RunWith; diff --git a/src/test/java/com/launchdarkly/client/EvaluatorOperatorsTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorOperatorsTest.java similarity index 77% rename from src/test/java/com/launchdarkly/client/EvaluatorOperatorsTest.java rename to src/test/java/com/launchdarkly/sdk/server/EvaluatorOperatorsTest.java index 3070b0387..5a94e7b74 100644 --- a/src/test/java/com/launchdarkly/client/EvaluatorOperatorsTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorOperatorsTest.java @@ -1,6 +1,8 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.DataModel; +import com.launchdarkly.sdk.server.EvaluatorOperators; import org.junit.Test; diff --git a/src/test/java/com/launchdarkly/client/EvaluatorRuleTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorRuleTest.java similarity index 87% rename from src/test/java/com/launchdarkly/client/EvaluatorRuleTest.java rename to src/test/java/com/launchdarkly/sdk/server/EvaluatorRuleTest.java index 899b32691..b15a21594 100644 --- a/src/test/java/com/launchdarkly/client/EvaluatorRuleTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorRuleTest.java @@ -1,15 +1,19 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.UserAttribute; import org.junit.Test; -import static com.launchdarkly.client.EvaluatorTestUtil.BASE_EVALUATOR; -import static com.launchdarkly.client.ModelBuilders.clause; -import static com.launchdarkly.client.ModelBuilders.emptyRollout; -import static com.launchdarkly.client.ModelBuilders.fallthroughVariation; -import static com.launchdarkly.client.ModelBuilders.flagBuilder; -import static com.launchdarkly.client.ModelBuilders.ruleBuilder; +import static com.launchdarkly.sdk.server.EvaluatorTestUtil.BASE_EVALUATOR; +import static com.launchdarkly.sdk.server.ModelBuilders.clause; +import static com.launchdarkly.sdk.server.ModelBuilders.emptyRollout; +import static com.launchdarkly.sdk.server.ModelBuilders.fallthroughVariation; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.ruleBuilder; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.emptyIterable; import static org.junit.Assert.assertEquals; diff --git a/src/test/java/com/launchdarkly/client/EvaluatorSegmentMatchTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorSegmentMatchTest.java similarity index 88% rename from src/test/java/com/launchdarkly/client/EvaluatorSegmentMatchTest.java rename to src/test/java/com/launchdarkly/sdk/server/EvaluatorSegmentMatchTest.java index 87f7b9c27..90b02a9bc 100644 --- a/src/test/java/com/launchdarkly/client/EvaluatorSegmentMatchTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorSegmentMatchTest.java @@ -1,14 +1,16 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.UserAttribute; import org.junit.Test; -import static com.launchdarkly.client.EvaluatorTestUtil.evaluatorBuilder; -import static com.launchdarkly.client.ModelBuilders.booleanFlagWithClauses; -import static com.launchdarkly.client.ModelBuilders.clause; -import static com.launchdarkly.client.ModelBuilders.segmentBuilder; -import static com.launchdarkly.client.ModelBuilders.segmentRuleBuilder; +import static com.launchdarkly.sdk.server.EvaluatorTestUtil.evaluatorBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.booleanFlagWithClauses; +import static com.launchdarkly.sdk.server.ModelBuilders.clause; +import static com.launchdarkly.sdk.server.ModelBuilders.segmentBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.segmentRuleBuilder; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; diff --git a/src/test/java/com/launchdarkly/client/EvaluatorTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java similarity index 94% rename from src/test/java/com/launchdarkly/client/EvaluatorTest.java rename to src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java index 633f08d56..22596c4b4 100644 --- a/src/test/java/com/launchdarkly/client/EvaluatorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java @@ -1,20 +1,24 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.common.collect.Iterables; -import com.launchdarkly.client.interfaces.Event; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.server.interfaces.Event; import org.junit.Test; -import static com.launchdarkly.client.EvaluationDetail.fromValue; -import static com.launchdarkly.client.EvaluatorTestUtil.BASE_EVALUATOR; -import static com.launchdarkly.client.EvaluatorTestUtil.evaluatorBuilder; -import static com.launchdarkly.client.ModelBuilders.clause; -import static com.launchdarkly.client.ModelBuilders.fallthroughVariation; -import static com.launchdarkly.client.ModelBuilders.flagBuilder; -import static com.launchdarkly.client.ModelBuilders.prerequisite; -import static com.launchdarkly.client.ModelBuilders.ruleBuilder; -import static com.launchdarkly.client.ModelBuilders.target; +import static com.launchdarkly.sdk.EvaluationDetail.fromValue; +import static com.launchdarkly.sdk.server.EvaluatorTestUtil.BASE_EVALUATOR; +import static com.launchdarkly.sdk.server.EvaluatorTestUtil.evaluatorBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.clause; +import static com.launchdarkly.sdk.server.ModelBuilders.fallthroughVariation; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.prerequisite; +import static com.launchdarkly.sdk.server.ModelBuilders.ruleBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.target; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.emptyIterable; import static org.junit.Assert.assertEquals; diff --git a/src/test/java/com/launchdarkly/client/EvaluatorTestUtil.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTestUtil.java similarity index 95% rename from src/test/java/com/launchdarkly/client/EvaluatorTestUtil.java rename to src/test/java/com/launchdarkly/sdk/server/EvaluatorTestUtil.java index 7806d1f69..dcb9372f4 100644 --- a/src/test/java/com/launchdarkly/client/EvaluatorTestUtil.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTestUtil.java @@ -1,4 +1,7 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.DataModel; +import com.launchdarkly.sdk.server.Evaluator; @SuppressWarnings("javadoc") public abstract class EvaluatorTestUtil { diff --git a/src/test/java/com/launchdarkly/client/EventOutputTest.java b/src/test/java/com/launchdarkly/sdk/server/EventOutputTest.java similarity index 97% rename from src/test/java/com/launchdarkly/client/EventOutputTest.java rename to src/test/java/com/launchdarkly/sdk/server/EventOutputTest.java index a2aa9e0be..0db986e0f 100644 --- a/src/test/java/com/launchdarkly/client/EventOutputTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EventOutputTest.java @@ -1,12 +1,15 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.common.collect.ImmutableSet; import com.google.gson.Gson; -import com.launchdarkly.client.EventSummarizer.EventSummary; -import com.launchdarkly.client.interfaces.Event; -import com.launchdarkly.client.interfaces.Event.FeatureRequest; -import com.launchdarkly.client.value.LDValue; -import com.launchdarkly.client.value.ObjectBuilder; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.ObjectBuilder; +import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.server.EventSummarizer.EventSummary; +import com.launchdarkly.sdk.server.interfaces.Event; +import com.launchdarkly.sdk.server.interfaces.Event.FeatureRequest; import org.junit.Test; @@ -14,7 +17,7 @@ import java.io.StringWriter; import java.util.Set; -import static com.launchdarkly.client.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.containsInAnyOrder; diff --git a/src/test/java/com/launchdarkly/client/EventSummarizerTest.java b/src/test/java/com/launchdarkly/sdk/server/EventSummarizerTest.java similarity index 89% rename from src/test/java/com/launchdarkly/client/EventSummarizerTest.java rename to src/test/java/com/launchdarkly/sdk/server/EventSummarizerTest.java index 297268b49..f294eb0b9 100644 --- a/src/test/java/com/launchdarkly/client/EventSummarizerTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EventSummarizerTest.java @@ -1,15 +1,20 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; -import com.launchdarkly.client.interfaces.Event; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.DataModel; +import com.launchdarkly.sdk.server.EventFactory; +import com.launchdarkly.sdk.server.EventSummarizer; +import com.launchdarkly.sdk.server.interfaces.Event; import org.junit.Test; import java.util.HashMap; import java.util.Map; -import static com.launchdarkly.client.ModelBuilders.flagBuilder; -import static com.launchdarkly.client.TestUtil.simpleEvaluation; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.TestUtil.simpleEvaluation; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.junit.Assert.assertEquals; diff --git a/src/test/java/com/launchdarkly/client/EventUserSerializationTest.java b/src/test/java/com/launchdarkly/sdk/server/EventUserSerializationTest.java similarity index 93% rename from src/test/java/com/launchdarkly/client/EventUserSerializationTest.java rename to src/test/java/com/launchdarkly/sdk/server/EventUserSerializationTest.java index 3d0f606b7..bcda3e15a 100644 --- a/src/test/java/com/launchdarkly/client/EventUserSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EventUserSerializationTest.java @@ -1,10 +1,12 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.reflect.TypeToken; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.UserAttribute; import org.junit.Test; @@ -14,10 +16,10 @@ 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.makeEventsConfig; +import static com.launchdarkly.sdk.server.JsonHelpers.gsonInstance; +import static com.launchdarkly.sdk.server.JsonHelpers.gsonInstanceForEventsSerialization; +import static com.launchdarkly.sdk.server.TestUtil.defaultEventsConfig; +import static com.launchdarkly.sdk.server.TestUtil.makeEventsConfig; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java b/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java similarity index 92% rename from src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java rename to src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java index d2283c697..742ca71a6 100644 --- a/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java @@ -1,13 +1,18 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.common.collect.ImmutableMap; import com.google.gson.Gson; import com.google.gson.JsonElement; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.DataModel; +import com.launchdarkly.sdk.server.Evaluator; +import com.launchdarkly.sdk.server.FeatureFlagsState; +import com.launchdarkly.sdk.server.FlagsStateOption; import org.junit.Test; -import static com.launchdarkly.client.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; diff --git a/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java b/src/test/java/com/launchdarkly/sdk/server/FeatureRequestorTest.java similarity index 93% rename from src/test/java/com/launchdarkly/client/FeatureRequestorTest.java rename to src/test/java/com/launchdarkly/sdk/server/FeatureRequestorTest.java index 2940c8c30..82aea362d 100644 --- a/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/FeatureRequestorTest.java @@ -1,4 +1,11 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.DataModel; +import com.launchdarkly.sdk.server.DefaultFeatureRequestor; +import com.launchdarkly.sdk.server.FeatureRequestor; +import com.launchdarkly.sdk.server.HttpErrorException; +import com.launchdarkly.sdk.server.LDClient; +import com.launchdarkly.sdk.server.LDConfig; import org.junit.Assert; import org.junit.Test; @@ -8,9 +15,9 @@ import javax.net.ssl.SSLHandshakeException; -import static com.launchdarkly.client.TestHttpUtil.httpsServerWithSelfSignedCert; -import static com.launchdarkly.client.TestHttpUtil.jsonResponse; -import static com.launchdarkly.client.TestHttpUtil.makeStartedServer; +import static com.launchdarkly.sdk.server.TestHttpUtil.httpsServerWithSelfSignedCert; +import static com.launchdarkly.sdk.server.TestHttpUtil.jsonResponse; +import static com.launchdarkly.sdk.server.TestHttpUtil.makeStartedServer; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; diff --git a/src/test/java/com/launchdarkly/client/FlagModelDeserializationTest.java b/src/test/java/com/launchdarkly/sdk/server/FlagModelDeserializationTest.java similarity index 91% rename from src/test/java/com/launchdarkly/client/FlagModelDeserializationTest.java rename to src/test/java/com/launchdarkly/sdk/server/FlagModelDeserializationTest.java index ee6d63956..83d0dea83 100644 --- a/src/test/java/com/launchdarkly/client/FlagModelDeserializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/FlagModelDeserializationTest.java @@ -1,6 +1,8 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.gson.Gson; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.server.DataModel; import org.junit.Test; diff --git a/src/test/java/com/launchdarkly/client/InMemoryDataStoreTest.java b/src/test/java/com/launchdarkly/sdk/server/InMemoryDataStoreTest.java similarity index 55% rename from src/test/java/com/launchdarkly/client/InMemoryDataStoreTest.java rename to src/test/java/com/launchdarkly/sdk/server/InMemoryDataStoreTest.java index 7d994f1c7..9e2b0d1ff 100644 --- a/src/test/java/com/launchdarkly/client/InMemoryDataStoreTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/InMemoryDataStoreTest.java @@ -1,6 +1,7 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; -import com.launchdarkly.client.interfaces.DataStore; +import com.launchdarkly.sdk.server.InMemoryDataStore; +import com.launchdarkly.sdk.server.interfaces.DataStore; @SuppressWarnings("javadoc") public class InMemoryDataStoreTest extends DataStoreTestBase { diff --git a/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientEndToEndTest.java similarity index 86% rename from src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java rename to src/test/java/com/launchdarkly/sdk/server/LDClientEndToEndTest.java index e8c870337..bf5d28a64 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientEndToEndTest.java @@ -1,18 +1,22 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.gson.Gson; import com.google.gson.JsonObject; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.DataModel; +import com.launchdarkly.sdk.server.LDClient; +import com.launchdarkly.sdk.server.LDConfig; import org.junit.Test; -import static com.launchdarkly.client.Components.noEvents; -import static com.launchdarkly.client.ModelBuilders.flagBuilder; -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 com.launchdarkly.sdk.server.Components.noEvents; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.TestHttpUtil.basePollingConfig; +import static com.launchdarkly.sdk.server.TestHttpUtil.baseStreamingConfig; +import static com.launchdarkly.sdk.server.TestHttpUtil.httpsServerWithSelfSignedCert; +import static com.launchdarkly.sdk.server.TestHttpUtil.jsonResponse; +import static com.launchdarkly.sdk.server.TestHttpUtil.makeStartedServer; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; diff --git a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java similarity index 91% rename from src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java rename to src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java index 742168b15..c867bd3d0 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java @@ -1,28 +1,39 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.common.collect.ImmutableMap; import com.google.gson.Gson; import com.google.gson.JsonElement; -import com.launchdarkly.client.interfaces.DataStore; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.DataModel; +import com.launchdarkly.sdk.server.FeatureFlagsState; +import com.launchdarkly.sdk.server.FlagsStateOption; +import com.launchdarkly.sdk.server.InMemoryDataStore; +import com.launchdarkly.sdk.server.LDClient; +import com.launchdarkly.sdk.server.LDClientInterface; +import com.launchdarkly.sdk.server.LDConfig; +import com.launchdarkly.sdk.server.interfaces.DataStore; import org.junit.Test; import java.time.Duration; import java.util.Map; -import static com.launchdarkly.client.ModelBuilders.booleanFlagWithClauses; -import static com.launchdarkly.client.ModelBuilders.clause; -import static com.launchdarkly.client.ModelBuilders.fallthroughVariation; -import static com.launchdarkly.client.ModelBuilders.flagBuilder; -import static com.launchdarkly.client.ModelBuilders.flagWithValue; -import static com.launchdarkly.client.ModelBuilders.segmentBuilder; -import static com.launchdarkly.client.TestUtil.dataStoreThatThrowsException; -import static com.launchdarkly.client.TestUtil.failedDataSource; -import static com.launchdarkly.client.TestUtil.specificDataSource; -import static com.launchdarkly.client.TestUtil.specificDataStore; -import static com.launchdarkly.client.TestUtil.upsertFlag; -import static com.launchdarkly.client.TestUtil.upsertSegment; +import static com.launchdarkly.sdk.server.ModelBuilders.booleanFlagWithClauses; +import static com.launchdarkly.sdk.server.ModelBuilders.clause; +import static com.launchdarkly.sdk.server.ModelBuilders.fallthroughVariation; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.flagWithValue; +import static com.launchdarkly.sdk.server.ModelBuilders.segmentBuilder; +import static com.launchdarkly.sdk.server.TestUtil.dataStoreThatThrowsException; +import static com.launchdarkly.sdk.server.TestUtil.failedDataSource; +import static com.launchdarkly.sdk.server.TestUtil.specificDataSource; +import static com.launchdarkly.sdk.server.TestUtil.specificDataStore; +import static com.launchdarkly.sdk.server.TestUtil.upsertFlag; +import static com.launchdarkly.sdk.server.TestUtil.upsertSegment; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; diff --git a/src/test/java/com/launchdarkly/client/LDClientEventTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientEventTest.java similarity index 93% rename from src/test/java/com/launchdarkly/client/LDClientEventTest.java rename to src/test/java/com/launchdarkly/sdk/server/LDClientEventTest.java index bdb1096b2..15c30e142 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEventTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientEventTest.java @@ -1,22 +1,29 @@ -package com.launchdarkly.client; - -import com.launchdarkly.client.EvaluationReason.ErrorKind; -import com.launchdarkly.client.interfaces.DataStore; -import com.launchdarkly.client.interfaces.Event; -import com.launchdarkly.client.value.LDValue; +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.EvaluationReason.ErrorKind; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.DataModel; +import com.launchdarkly.sdk.server.LDClient; +import com.launchdarkly.sdk.server.LDClientInterface; +import com.launchdarkly.sdk.server.LDConfig; +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.Event; import org.junit.Test; -import static com.launchdarkly.client.ModelBuilders.clauseMatchingUser; -import static com.launchdarkly.client.ModelBuilders.clauseNotMatchingUser; -import static com.launchdarkly.client.ModelBuilders.fallthroughVariation; -import static com.launchdarkly.client.ModelBuilders.flagBuilder; -import static com.launchdarkly.client.ModelBuilders.flagWithValue; -import static com.launchdarkly.client.ModelBuilders.prerequisite; -import static com.launchdarkly.client.ModelBuilders.ruleBuilder; -import static com.launchdarkly.client.TestUtil.specificDataStore; -import static com.launchdarkly.client.TestUtil.specificEventProcessor; -import static com.launchdarkly.client.TestUtil.upsertFlag; +import static com.launchdarkly.sdk.server.ModelBuilders.clauseMatchingUser; +import static com.launchdarkly.sdk.server.ModelBuilders.clauseNotMatchingUser; +import static com.launchdarkly.sdk.server.ModelBuilders.fallthroughVariation; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.flagWithValue; +import static com.launchdarkly.sdk.server.ModelBuilders.prerequisite; +import static com.launchdarkly.sdk.server.ModelBuilders.ruleBuilder; +import static com.launchdarkly.sdk.server.TestUtil.specificDataStore; +import static com.launchdarkly.sdk.server.TestUtil.specificEventProcessor; +import static com.launchdarkly.sdk.server.TestUtil.upsertFlag; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; diff --git a/src/test/java/com/launchdarkly/client/LDClientExternalUpdatesOnlyTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientExternalUpdatesOnlyTest.java similarity index 75% rename from src/test/java/com/launchdarkly/client/LDClientExternalUpdatesOnlyTest.java rename to src/test/java/com/launchdarkly/sdk/server/LDClientExternalUpdatesOnlyTest.java index c95d6068e..527b10926 100644 --- a/src/test/java/com/launchdarkly/client/LDClientExternalUpdatesOnlyTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientExternalUpdatesOnlyTest.java @@ -1,15 +1,21 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; -import com.launchdarkly.client.interfaces.DataStore; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.DataModel; +import com.launchdarkly.sdk.server.DefaultEventProcessor; +import com.launchdarkly.sdk.server.LDClient; +import com.launchdarkly.sdk.server.LDConfig; +import com.launchdarkly.sdk.server.interfaces.DataStore; import org.junit.Test; import java.io.IOException; -import static com.launchdarkly.client.ModelBuilders.flagWithValue; -import static com.launchdarkly.client.TestUtil.specificDataStore; -import static com.launchdarkly.client.TestUtil.upsertFlag; +import static com.launchdarkly.sdk.server.ModelBuilders.flagWithValue; +import static com.launchdarkly.sdk.server.TestUtil.specificDataStore; +import static com.launchdarkly.sdk.server.TestUtil.upsertFlag; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; diff --git a/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientOfflineTest.java similarity index 79% rename from src/test/java/com/launchdarkly/client/LDClientOfflineTest.java rename to src/test/java/com/launchdarkly/sdk/server/LDClientOfflineTest.java index 2c92a2e82..097b2b43c 100644 --- a/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientOfflineTest.java @@ -1,17 +1,23 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.common.collect.ImmutableMap; -import com.launchdarkly.client.interfaces.DataStore; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.FeatureFlagsState; +import com.launchdarkly.sdk.server.LDClient; +import com.launchdarkly.sdk.server.LDClientInterface; +import com.launchdarkly.sdk.server.LDConfig; +import com.launchdarkly.sdk.server.interfaces.DataStore; import org.junit.Test; import java.io.IOException; -import static com.launchdarkly.client.ModelBuilders.flagWithValue; -import static com.launchdarkly.client.TestUtil.initedDataStore; -import static com.launchdarkly.client.TestUtil.specificDataStore; -import static com.launchdarkly.client.TestUtil.upsertFlag; +import static com.launchdarkly.sdk.server.ModelBuilders.flagWithValue; +import static com.launchdarkly.sdk.server.TestUtil.initedDataStore; +import static com.launchdarkly.sdk.server.TestUtil.specificDataStore; +import static com.launchdarkly.sdk.server.TestUtil.upsertFlag; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; diff --git a/src/test/java/com/launchdarkly/client/LDClientTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java similarity index 87% rename from src/test/java/com/launchdarkly/client/LDClientTest.java rename to src/test/java/com/launchdarkly/sdk/server/LDClientTest.java index dc8ed4485..260751ceb 100644 --- a/src/test/java/com/launchdarkly/client/LDClientTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java @@ -1,21 +1,33 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.common.base.Joiner; import com.google.common.collect.ImmutableList; import com.google.common.collect.Iterables; -import com.launchdarkly.client.DataStoreTestTypes.DataBuilder; -import com.launchdarkly.client.interfaces.ClientContext; -import com.launchdarkly.client.interfaces.DataSource; -import com.launchdarkly.client.interfaces.DataSourceFactory; -import com.launchdarkly.client.interfaces.DataStore; -import com.launchdarkly.client.interfaces.DataStoreTypes.DataKind; -import com.launchdarkly.client.interfaces.DataStoreTypes.FullDataSet; -import com.launchdarkly.client.interfaces.DataStoreTypes.ItemDescriptor; -import com.launchdarkly.client.interfaces.DataStoreUpdates; -import com.launchdarkly.client.interfaces.Event; -import com.launchdarkly.client.interfaces.EventProcessor; -import com.launchdarkly.client.interfaces.EventProcessorFactory; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.ClientContextImpl; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.DataModel; +import com.launchdarkly.sdk.server.DefaultEventProcessor; +import com.launchdarkly.sdk.server.DiagnosticAccumulator; +import com.launchdarkly.sdk.server.InMemoryDataStore; +import com.launchdarkly.sdk.server.LDClient; +import com.launchdarkly.sdk.server.LDClientInterface; +import com.launchdarkly.sdk.server.LDConfig; +import com.launchdarkly.sdk.server.PollingProcessor; +import com.launchdarkly.sdk.server.StreamProcessor; +import com.launchdarkly.sdk.server.DataStoreTestTypes.DataBuilder; +import com.launchdarkly.sdk.server.interfaces.ClientContext; +import com.launchdarkly.sdk.server.interfaces.DataSource; +import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; +import com.launchdarkly.sdk.server.interfaces.Event; +import com.launchdarkly.sdk.server.interfaces.EventProcessor; +import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import org.easymock.Capture; import org.easymock.EasyMock; @@ -33,18 +45,18 @@ import java.util.concurrent.TimeoutException; import static com.google.common.collect.Iterables.transform; -import static com.launchdarkly.client.DataModel.DataKinds.FEATURES; -import static com.launchdarkly.client.DataModel.DataKinds.SEGMENTS; -import static com.launchdarkly.client.DataStoreTestTypes.toDataMap; -import static com.launchdarkly.client.ModelBuilders.flagBuilder; -import static com.launchdarkly.client.ModelBuilders.flagWithValue; -import static com.launchdarkly.client.ModelBuilders.prerequisite; -import static com.launchdarkly.client.ModelBuilders.segmentBuilder; -import static com.launchdarkly.client.TestUtil.dataSourceWithData; -import static com.launchdarkly.client.TestUtil.failedDataSource; -import static com.launchdarkly.client.TestUtil.initedDataStore; -import static com.launchdarkly.client.TestUtil.specificDataStore; -import static com.launchdarkly.client.TestUtil.upsertFlag; +import static com.launchdarkly.sdk.server.DataModel.DataKinds.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.DataKinds.SEGMENTS; +import static com.launchdarkly.sdk.server.DataStoreTestTypes.toDataMap; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.flagWithValue; +import static com.launchdarkly.sdk.server.ModelBuilders.prerequisite; +import static com.launchdarkly.sdk.server.ModelBuilders.segmentBuilder; +import static com.launchdarkly.sdk.server.TestUtil.dataSourceWithData; +import static com.launchdarkly.sdk.server.TestUtil.failedDataSource; +import static com.launchdarkly.sdk.server.TestUtil.initedDataStore; +import static com.launchdarkly.sdk.server.TestUtil.specificDataStore; +import static com.launchdarkly.sdk.server.TestUtil.upsertFlag; import static org.easymock.EasyMock.anyObject; import static org.easymock.EasyMock.capture; import static org.easymock.EasyMock.expect; diff --git a/src/test/java/com/launchdarkly/client/LDConfigTest.java b/src/test/java/com/launchdarkly/sdk/server/LDConfigTest.java similarity index 97% rename from src/test/java/com/launchdarkly/client/LDConfigTest.java rename to src/test/java/com/launchdarkly/sdk/server/LDConfigTest.java index 566f55378..cd3a99561 100644 --- a/src/test/java/com/launchdarkly/client/LDConfigTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDConfigTest.java @@ -1,4 +1,6 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.LDConfig; import org.junit.Test; diff --git a/src/test/java/com/launchdarkly/client/ModelBuilders.java b/src/test/java/com/launchdarkly/sdk/server/ModelBuilders.java similarity index 97% rename from src/test/java/com/launchdarkly/client/ModelBuilders.java rename to src/test/java/com/launchdarkly/sdk/server/ModelBuilders.java index d55fe1b60..cb4976c8d 100644 --- a/src/test/java/com/launchdarkly/client/ModelBuilders.java +++ b/src/test/java/com/launchdarkly/sdk/server/ModelBuilders.java @@ -1,8 +1,10 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.common.collect.ImmutableList; -import com.launchdarkly.client.DataModel.FeatureFlag; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.server.DataModel.FeatureFlag; import java.util.ArrayList; import java.util.Arrays; @@ -60,7 +62,7 @@ public static DataModel.Clause clauseMatchingUser(LDUser user) { public static DataModel.Clause clauseNotMatchingUser(LDUser user) { return clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of("not-" + user.getKey())); } - + public static DataModel.Target target(int variation, String... userKeys) { return new DataModel.Target(Arrays.asList(userKeys), variation); } diff --git a/src/test/java/com/launchdarkly/client/PollingProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java similarity index 88% rename from src/test/java/com/launchdarkly/client/PollingProcessorTest.java rename to src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java index c33729c7b..41773747d 100644 --- a/src/test/java/com/launchdarkly/client/PollingProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java @@ -1,8 +1,16 @@ -package com.launchdarkly.client; - -import com.launchdarkly.client.integrations.PollingDataSourceBuilder; -import com.launchdarkly.client.interfaces.DataSourceFactory; -import com.launchdarkly.client.interfaces.DataStore; +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.DataModel; +import com.launchdarkly.sdk.server.DefaultFeatureRequestor; +import com.launchdarkly.sdk.server.FeatureRequestor; +import com.launchdarkly.sdk.server.HttpErrorException; +import com.launchdarkly.sdk.server.InMemoryDataStore; +import com.launchdarkly.sdk.server.LDConfig; +import com.launchdarkly.sdk.server.PollingProcessor; +import com.launchdarkly.sdk.server.integrations.PollingDataSourceBuilder; +import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; +import com.launchdarkly.sdk.server.interfaces.DataStore; import org.junit.Test; @@ -14,8 +22,8 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; -import static com.launchdarkly.client.TestUtil.clientContext; -import static com.launchdarkly.client.TestUtil.dataStoreUpdates; +import static com.launchdarkly.sdk.server.TestUtil.clientContext; +import static com.launchdarkly.sdk.server.TestUtil.dataStoreUpdates; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.junit.Assert.assertFalse; diff --git a/src/test/java/com/launchdarkly/client/SemanticVersionTest.java b/src/test/java/com/launchdarkly/sdk/server/SemanticVersionTest.java similarity index 98% rename from src/test/java/com/launchdarkly/client/SemanticVersionTest.java rename to src/test/java/com/launchdarkly/sdk/server/SemanticVersionTest.java index 623523772..41ceb972b 100644 --- a/src/test/java/com/launchdarkly/client/SemanticVersionTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/SemanticVersionTest.java @@ -1,8 +1,10 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; +import com.launchdarkly.sdk.server.SemanticVersion; + import org.junit.Test; @SuppressWarnings("javadoc") diff --git a/src/test/java/com/launchdarkly/client/SimpleLRUCacheTest.java b/src/test/java/com/launchdarkly/sdk/server/SimpleLRUCacheTest.java similarity index 94% rename from src/test/java/com/launchdarkly/client/SimpleLRUCacheTest.java rename to src/test/java/com/launchdarkly/sdk/server/SimpleLRUCacheTest.java index 2c1f06cb3..69cf609e6 100644 --- a/src/test/java/com/launchdarkly/client/SimpleLRUCacheTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/SimpleLRUCacheTest.java @@ -1,4 +1,6 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.SimpleLRUCache; import org.junit.Test; diff --git a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java similarity index 93% rename from src/test/java/com/launchdarkly/client/StreamProcessorTest.java rename to src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java index 04e20f272..243b85d9c 100644 --- a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java @@ -1,13 +1,25 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; -import com.launchdarkly.client.integrations.StreamingDataSourceBuilder; -import com.launchdarkly.client.interfaces.DataSourceFactory; -import com.launchdarkly.client.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.eventsource.ConnectionErrorHandler; import com.launchdarkly.eventsource.EventHandler; import com.launchdarkly.eventsource.EventSource; import com.launchdarkly.eventsource.MessageEvent; import com.launchdarkly.eventsource.UnsuccessfulResponseException; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.DataModel; +import com.launchdarkly.sdk.server.DefaultFeatureRequestor; +import com.launchdarkly.sdk.server.DiagnosticAccumulator; +import com.launchdarkly.sdk.server.DiagnosticEvent; +import com.launchdarkly.sdk.server.DiagnosticId; +import com.launchdarkly.sdk.server.FeatureRequestor; +import com.launchdarkly.sdk.server.HttpConfiguration; +import com.launchdarkly.sdk.server.InMemoryDataStore; +import com.launchdarkly.sdk.server.LDClient; +import com.launchdarkly.sdk.server.LDConfig; +import com.launchdarkly.sdk.server.StreamProcessor; +import com.launchdarkly.sdk.server.integrations.StreamingDataSourceBuilder; +import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import org.easymock.EasyMockSupport; import org.junit.Before; @@ -25,16 +37,16 @@ import javax.net.ssl.SSLHandshakeException; -import static com.launchdarkly.client.DataModel.DataKinds.FEATURES; -import static com.launchdarkly.client.DataModel.DataKinds.SEGMENTS; -import static com.launchdarkly.client.ModelBuilders.flagBuilder; -import static com.launchdarkly.client.ModelBuilders.segmentBuilder; -import static com.launchdarkly.client.TestHttpUtil.eventStreamResponse; -import static com.launchdarkly.client.TestHttpUtil.makeStartedServer; -import static com.launchdarkly.client.TestUtil.clientContext; -import static com.launchdarkly.client.TestUtil.dataStoreUpdates; -import static com.launchdarkly.client.TestUtil.upsertFlag; -import static com.launchdarkly.client.TestUtil.upsertSegment; +import static com.launchdarkly.sdk.server.DataModel.DataKinds.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.DataKinds.SEGMENTS; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.segmentBuilder; +import static com.launchdarkly.sdk.server.TestHttpUtil.eventStreamResponse; +import static com.launchdarkly.sdk.server.TestHttpUtil.makeStartedServer; +import static com.launchdarkly.sdk.server.TestUtil.clientContext; +import static com.launchdarkly.sdk.server.TestUtil.dataStoreUpdates; +import static com.launchdarkly.sdk.server.TestUtil.upsertFlag; +import static com.launchdarkly.sdk.server.TestUtil.upsertSegment; import static org.easymock.EasyMock.expect; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; diff --git a/src/test/java/com/launchdarkly/client/TestHttpUtil.java b/src/test/java/com/launchdarkly/sdk/server/TestHttpUtil.java similarity index 91% rename from src/test/java/com/launchdarkly/client/TestHttpUtil.java rename to src/test/java/com/launchdarkly/sdk/server/TestHttpUtil.java index ca440cb4e..fffc09adc 100644 --- a/src/test/java/com/launchdarkly/client/TestHttpUtil.java +++ b/src/test/java/com/launchdarkly/sdk/server/TestHttpUtil.java @@ -1,7 +1,8 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; -import com.launchdarkly.client.integrations.PollingDataSourceBuilder; -import com.launchdarkly.client.integrations.StreamingDataSourceBuilder; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.integrations.PollingDataSourceBuilder; +import com.launchdarkly.sdk.server.integrations.StreamingDataSourceBuilder; import java.io.Closeable; import java.io.IOException; diff --git a/src/test/java/com/launchdarkly/client/TestUtil.java b/src/test/java/com/launchdarkly/sdk/server/TestUtil.java similarity index 85% rename from src/test/java/com/launchdarkly/client/TestUtil.java rename to src/test/java/com/launchdarkly/sdk/server/TestUtil.java index 67cd166e3..167f82b21 100644 --- a/src/test/java/com/launchdarkly/client/TestUtil.java +++ b/src/test/java/com/launchdarkly/sdk/server/TestUtil.java @@ -1,25 +1,27 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.SettableFuture; -import com.launchdarkly.client.DataModel.FeatureFlag; -import com.launchdarkly.client.DataModel.Segment; -import com.launchdarkly.client.integrations.EventProcessorBuilder; -import com.launchdarkly.client.interfaces.ClientContext; -import com.launchdarkly.client.interfaces.DataSource; -import com.launchdarkly.client.interfaces.DataSourceFactory; -import com.launchdarkly.client.interfaces.DataStore; -import com.launchdarkly.client.interfaces.DataStoreFactory; -import com.launchdarkly.client.interfaces.DataStoreTypes.DataKind; -import com.launchdarkly.client.interfaces.DataStoreTypes.FullDataSet; -import com.launchdarkly.client.interfaces.DataStoreTypes.ItemDescriptor; -import com.launchdarkly.client.interfaces.DataStoreTypes.KeyedItems; -import com.launchdarkly.client.interfaces.DataStoreUpdates; -import com.launchdarkly.client.interfaces.Event; -import com.launchdarkly.client.interfaces.EventProcessor; -import com.launchdarkly.client.interfaces.EventProcessorFactory; -import com.launchdarkly.client.value.LDValue; -import com.launchdarkly.client.value.LDValueType; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.LDValueType; +import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.server.DataModel.FeatureFlag; +import com.launchdarkly.sdk.server.DataModel.Segment; +import com.launchdarkly.sdk.server.integrations.EventProcessorBuilder; +import com.launchdarkly.sdk.server.interfaces.ClientContext; +import com.launchdarkly.sdk.server.interfaces.DataSource; +import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; +import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; +import com.launchdarkly.sdk.server.interfaces.Event; +import com.launchdarkly.sdk.server.interfaces.EventProcessor; +import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; import org.hamcrest.Description; import org.hamcrest.Matcher; diff --git a/src/test/java/com/launchdarkly/client/UtilTest.java b/src/test/java/com/launchdarkly/sdk/server/UtilTest.java similarity index 82% rename from src/test/java/com/launchdarkly/client/UtilTest.java rename to src/test/java/com/launchdarkly/sdk/server/UtilTest.java index b14c91ff1..d24b9c2ab 100644 --- a/src/test/java/com/launchdarkly/client/UtilTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/UtilTest.java @@ -1,11 +1,13 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.LDConfig; import org.junit.Test; import java.time.Duration; -import static com.launchdarkly.client.Util.configureHttpClientBuilder; -import static com.launchdarkly.client.Util.shutdownHttpClient; +import static com.launchdarkly.sdk.server.Util.configureHttpClientBuilder; +import static com.launchdarkly.sdk.server.Util.shutdownHttpClient; import static org.junit.Assert.assertEquals; import okhttp3.OkHttpClient; diff --git a/src/test/java/com/launchdarkly/client/integrations/ClientWithFileDataSourceTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/ClientWithFileDataSourceTest.java similarity index 56% rename from src/test/java/com/launchdarkly/client/integrations/ClientWithFileDataSourceTest.java rename to src/test/java/com/launchdarkly/sdk/server/integrations/ClientWithFileDataSourceTest.java index 695ce6415..9f0b8430b 100644 --- a/src/test/java/com/launchdarkly/client/integrations/ClientWithFileDataSourceTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/ClientWithFileDataSourceTest.java @@ -1,18 +1,20 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; -import com.launchdarkly.client.Components; -import com.launchdarkly.client.LDClient; -import com.launchdarkly.client.LDConfig; -import com.launchdarkly.client.LDUser; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.LDClient; +import com.launchdarkly.sdk.server.LDConfig; +import com.launchdarkly.sdk.server.integrations.FileData; +import com.launchdarkly.sdk.server.integrations.FileDataSourceBuilder; 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.sdk.server.integrations.FileDataSourceTestData.FLAG_VALUE_1; +import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.FLAG_VALUE_1_KEY; +import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.FULL_FLAG_1_KEY; +import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.FULL_FLAG_1_VALUE; +import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.resourceFilePath; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; diff --git a/src/test/java/com/launchdarkly/client/integrations/DataLoaderTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/DataLoaderTest.java similarity index 81% rename from src/test/java/com/launchdarkly/client/integrations/DataLoaderTest.java rename to src/test/java/com/launchdarkly/sdk/server/integrations/DataLoaderTest.java index 9105f4eaa..2ab0516eb 100644 --- a/src/test/java/com/launchdarkly/client/integrations/DataLoaderTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/DataLoaderTest.java @@ -1,25 +1,25 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; 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.integrations.FileDataSourceImpl.DataBuilder; -import com.launchdarkly.client.integrations.FileDataSourceImpl.DataLoader; -import com.launchdarkly.client.integrations.FileDataSourceParsing.FileDataException; -import com.launchdarkly.client.interfaces.DataStoreTypes.DataKind; -import com.launchdarkly.client.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.integrations.FileDataSourceImpl.DataBuilder; +import com.launchdarkly.sdk.server.integrations.FileDataSourceImpl.DataLoader; +import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FileDataException; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import org.junit.Assert; import org.junit.Test; import java.util.Map; -import static com.launchdarkly.client.DataModel.DataKinds.FEATURES; -import static com.launchdarkly.client.DataModel.DataKinds.SEGMENTS; -import static com.launchdarkly.client.DataStoreTestTypes.toDataMap; -import static com.launchdarkly.client.integrations.FileDataSourceTestData.FLAG_VALUE_1_KEY; -import static com.launchdarkly.client.integrations.FileDataSourceTestData.resourceFilePath; +import static com.launchdarkly.sdk.server.DataModel.DataKinds.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.DataKinds.SEGMENTS; +import static com.launchdarkly.sdk.server.DataStoreTestTypes.toDataMap; +import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.FLAG_VALUE_1_KEY; +import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.resourceFilePath; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; diff --git a/src/test/java/com/launchdarkly/client/integrations/EventProcessorBuilderTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/EventProcessorBuilderTest.java similarity index 84% rename from src/test/java/com/launchdarkly/client/integrations/EventProcessorBuilderTest.java rename to src/test/java/com/launchdarkly/sdk/server/integrations/EventProcessorBuilderTest.java index 5979e9602..7a9142d80 100644 --- a/src/test/java/com/launchdarkly/client/integrations/EventProcessorBuilderTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/EventProcessorBuilderTest.java @@ -1,6 +1,7 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; -import com.launchdarkly.client.Components; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.integrations.EventProcessorBuilder; import org.junit.Test; diff --git a/src/test/java/com/launchdarkly/client/integrations/FileDataSourceTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java similarity index 84% rename from src/test/java/com/launchdarkly/client/integrations/FileDataSourceTest.java rename to src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java index 23d5c14b8..cf12c9b94 100644 --- a/src/test/java/com/launchdarkly/client/integrations/FileDataSourceTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java @@ -1,9 +1,11 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; -import com.launchdarkly.client.LDConfig; -import com.launchdarkly.client.TestUtil; -import com.launchdarkly.client.interfaces.DataSource; -import com.launchdarkly.client.interfaces.DataStore; +import com.launchdarkly.sdk.server.LDConfig; +import com.launchdarkly.sdk.server.TestUtil; +import com.launchdarkly.sdk.server.integrations.FileData; +import com.launchdarkly.sdk.server.integrations.FileDataSourceBuilder; +import com.launchdarkly.sdk.server.interfaces.DataSource; +import com.launchdarkly.sdk.server.interfaces.DataStore; import org.junit.Test; @@ -14,15 +16,15 @@ import java.util.concurrent.Future; import static com.google.common.collect.Iterables.size; -import static com.launchdarkly.client.DataModel.DataKinds.FEATURES; -import static com.launchdarkly.client.DataModel.DataKinds.SEGMENTS; -import static com.launchdarkly.client.DataStoreTestTypes.toItemsMap; -import static com.launchdarkly.client.TestUtil.clientContext; -import static com.launchdarkly.client.TestUtil.dataStoreUpdates; -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.sdk.server.DataModel.DataKinds.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.DataKinds.SEGMENTS; +import static com.launchdarkly.sdk.server.DataStoreTestTypes.toItemsMap; +import static com.launchdarkly.sdk.server.TestUtil.clientContext; +import static com.launchdarkly.sdk.server.TestUtil.dataStoreUpdates; +import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.ALL_FLAG_KEYS; +import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.ALL_SEGMENT_KEYS; +import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.getResourceContents; +import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.resourceFilePath; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.junit.Assert.fail; diff --git a/src/test/java/com/launchdarkly/client/integrations/FileDataSourceTestData.java b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTestData.java similarity index 95% rename from src/test/java/com/launchdarkly/client/integrations/FileDataSourceTestData.java rename to src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTestData.java index 030c9f5f6..de1cb8ae5 100644 --- a/src/test/java/com/launchdarkly/client/integrations/FileDataSourceTestData.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTestData.java @@ -1,8 +1,8 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.LDValue; import java.net.URISyntaxException; import java.net.URL; diff --git a/src/test/java/com/launchdarkly/client/integrations/FlagFileParserJsonTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/FlagFileParserJsonTest.java similarity index 57% rename from src/test/java/com/launchdarkly/client/integrations/FlagFileParserJsonTest.java rename to src/test/java/com/launchdarkly/sdk/server/integrations/FlagFileParserJsonTest.java index c23a66772..45fe4cdfb 100644 --- a/src/test/java/com/launchdarkly/client/integrations/FlagFileParserJsonTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/FlagFileParserJsonTest.java @@ -1,6 +1,6 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; -import com.launchdarkly.client.integrations.FileDataSourceParsing.JsonFlagFileParser; +import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.JsonFlagFileParser; @SuppressWarnings("javadoc") public class FlagFileParserJsonTest extends FlagFileParserTestBase { diff --git a/src/test/java/com/launchdarkly/client/integrations/FlagFileParserTestBase.java b/src/test/java/com/launchdarkly/sdk/server/integrations/FlagFileParserTestBase.java similarity index 77% rename from src/test/java/com/launchdarkly/client/integrations/FlagFileParserTestBase.java rename to src/test/java/com/launchdarkly/sdk/server/integrations/FlagFileParserTestBase.java index fd2be268f..4aedd7359 100644 --- a/src/test/java/com/launchdarkly/client/integrations/FlagFileParserTestBase.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/FlagFileParserTestBase.java @@ -1,8 +1,8 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; -import com.launchdarkly.client.integrations.FileDataSourceParsing.FileDataException; -import com.launchdarkly.client.integrations.FileDataSourceParsing.FlagFileParser; -import com.launchdarkly.client.integrations.FileDataSourceParsing.FlagFileRep; +import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FileDataException; +import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FlagFileParser; +import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FlagFileRep; import org.junit.Test; @@ -10,10 +10,10 @@ 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.sdk.server.integrations.FileDataSourceTestData.FLAG_VALUES; +import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.FULL_FLAGS; +import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.FULL_SEGMENTS; +import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.resourceFilePath; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.nullValue; diff --git a/src/test/java/com/launchdarkly/client/integrations/FlagFileParserYamlTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/FlagFileParserYamlTest.java similarity index 57% rename from src/test/java/com/launchdarkly/client/integrations/FlagFileParserYamlTest.java rename to src/test/java/com/launchdarkly/sdk/server/integrations/FlagFileParserYamlTest.java index 3ad640e92..ce9100c4a 100644 --- a/src/test/java/com/launchdarkly/client/integrations/FlagFileParserYamlTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/FlagFileParserYamlTest.java @@ -1,6 +1,6 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; -import com.launchdarkly.client.integrations.FileDataSourceParsing.YamlFlagFileParser; +import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.YamlFlagFileParser; @SuppressWarnings("javadoc") public class FlagFileParserYamlTest extends FlagFileParserTestBase { diff --git a/src/test/java/com/launchdarkly/client/integrations/PersistentDataStoreTestBase.java b/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreTestBase.java similarity index 94% rename from src/test/java/com/launchdarkly/client/integrations/PersistentDataStoreTestBase.java rename to src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreTestBase.java index e0debfac9..b6134ffb0 100644 --- a/src/test/java/com/launchdarkly/client/integrations/PersistentDataStoreTestBase.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreTestBase.java @@ -1,11 +1,11 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; -import com.launchdarkly.client.DataStoreTestTypes.DataBuilder; -import com.launchdarkly.client.DataStoreTestTypes.TestItem; -import com.launchdarkly.client.interfaces.DataStoreTypes.FullDataSet; -import com.launchdarkly.client.interfaces.DataStoreTypes.ItemDescriptor; -import com.launchdarkly.client.interfaces.DataStoreTypes.SerializedItemDescriptor; -import com.launchdarkly.client.interfaces.PersistentDataStore; +import com.launchdarkly.sdk.server.DataStoreTestTypes.DataBuilder; +import com.launchdarkly.sdk.server.DataStoreTestTypes.TestItem; +import com.launchdarkly.sdk.server.interfaces.PersistentDataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.SerializedItemDescriptor; import org.junit.After; import org.junit.Assume; @@ -14,10 +14,10 @@ import java.util.Map; -import static com.launchdarkly.client.DataStoreTestTypes.OTHER_TEST_ITEMS; -import static com.launchdarkly.client.DataStoreTestTypes.TEST_ITEMS; -import static com.launchdarkly.client.DataStoreTestTypes.toItemsMap; -import static com.launchdarkly.client.DataStoreTestTypes.toSerialized; +import static com.launchdarkly.sdk.server.DataStoreTestTypes.OTHER_TEST_ITEMS; +import static com.launchdarkly.sdk.server.DataStoreTestTypes.TEST_ITEMS; +import static com.launchdarkly.sdk.server.DataStoreTestTypes.toItemsMap; +import static com.launchdarkly.sdk.server.DataStoreTestTypes.toSerialized; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; diff --git a/src/test/java/com/launchdarkly/client/integrations/PersistentDataStoreWrapperTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapperTest.java similarity index 95% rename from src/test/java/com/launchdarkly/client/integrations/PersistentDataStoreWrapperTest.java rename to src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapperTest.java index 4af8085a8..a5461fa0f 100644 --- a/src/test/java/com/launchdarkly/client/integrations/PersistentDataStoreWrapperTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapperTest.java @@ -1,15 +1,18 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; -import com.launchdarkly.client.DataStoreTestTypes.DataBuilder; -import com.launchdarkly.client.DataStoreTestTypes.TestItem; -import com.launchdarkly.client.interfaces.DataStoreTypes.DataKind; -import com.launchdarkly.client.interfaces.DataStoreTypes.FullDataSet; -import com.launchdarkly.client.interfaces.DataStoreTypes.ItemDescriptor; -import com.launchdarkly.client.interfaces.DataStoreTypes.KeyedItems; -import com.launchdarkly.client.interfaces.DataStoreTypes.SerializedItemDescriptor; -import com.launchdarkly.client.interfaces.PersistentDataStore; +import com.launchdarkly.sdk.server.DataStoreTestTypes.DataBuilder; +import com.launchdarkly.sdk.server.DataStoreTestTypes.TestItem; +import com.launchdarkly.sdk.server.integrations.CacheMonitor; +import com.launchdarkly.sdk.server.integrations.PersistentDataStoreBuilder; +import com.launchdarkly.sdk.server.integrations.PersistentDataStoreWrapper; +import com.launchdarkly.sdk.server.interfaces.PersistentDataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.SerializedItemDescriptor; import org.junit.Assert; import org.junit.Test; @@ -23,10 +26,10 @@ import java.util.LinkedHashMap; import java.util.Map; -import static com.launchdarkly.client.DataStoreTestTypes.TEST_ITEMS; -import static com.launchdarkly.client.DataStoreTestTypes.toDataMap; -import static com.launchdarkly.client.DataStoreTestTypes.toItemsMap; -import static com.launchdarkly.client.DataStoreTestTypes.toSerialized; +import static com.launchdarkly.sdk.server.DataStoreTestTypes.TEST_ITEMS; +import static com.launchdarkly.sdk.server.DataStoreTestTypes.toDataMap; +import static com.launchdarkly.sdk.server.DataStoreTestTypes.toItemsMap; +import static com.launchdarkly.sdk.server.DataStoreTestTypes.toSerialized; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; diff --git a/src/test/java/com/launchdarkly/client/integrations/RedisDataStoreBuilderTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/RedisDataStoreBuilderTest.java similarity index 93% rename from src/test/java/com/launchdarkly/client/integrations/RedisDataStoreBuilderTest.java rename to src/test/java/com/launchdarkly/sdk/server/integrations/RedisDataStoreBuilderTest.java index 6154ef8ad..1c40b4cc8 100644 --- a/src/test/java/com/launchdarkly/client/integrations/RedisDataStoreBuilderTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/RedisDataStoreBuilderTest.java @@ -1,4 +1,7 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; + +import com.launchdarkly.sdk.server.integrations.Redis; +import com.launchdarkly.sdk.server.integrations.RedisDataStoreBuilder; import org.junit.Test; diff --git a/src/test/java/com/launchdarkly/client/integrations/RedisDataStoreImplTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/RedisDataStoreImplTest.java similarity index 83% rename from src/test/java/com/launchdarkly/client/integrations/RedisDataStoreImplTest.java rename to src/test/java/com/launchdarkly/sdk/server/integrations/RedisDataStoreImplTest.java index e16aaa512..e352c678c 100644 --- a/src/test/java/com/launchdarkly/client/integrations/RedisDataStoreImplTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/RedisDataStoreImplTest.java @@ -1,6 +1,8 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; -import com.launchdarkly.client.integrations.RedisDataStoreImpl.UpdateListener; +import com.launchdarkly.sdk.server.integrations.Redis; +import com.launchdarkly.sdk.server.integrations.RedisDataStoreImpl; +import com.launchdarkly.sdk.server.integrations.RedisDataStoreImpl.UpdateListener; import org.junit.BeforeClass; From 9789b992441284aa91ed53059b8604938eb6f3d5 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 5 Mar 2020 16:57:07 -0800 Subject: [PATCH 332/673] fix packaging test --- packaging-test/test-app/src/main/java/testapp/TestApp.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 1438fbaa3..0d2a75271 100644 --- a/packaging-test/test-app/src/main/java/testapp/TestApp.java +++ b/packaging-test/test-app/src/main/java/testapp/TestApp.java @@ -2,7 +2,7 @@ import com.launchdarkly.sdk.*; import com.launchdarkly.sdk.server.*; -import com.launchdarkly.sdk.integrations.*; +import com.launchdarkly.sdk.server.integrations.*; import com.google.gson.*; import org.slf4j.*; From 6bc5c6db5c3333adf1bbd7b8e79da295ace1c189 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 5 Mar 2020 17:14:25 -0800 Subject: [PATCH 333/673] comment --- src/main/java/com/launchdarkly/client/LDUser.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/client/LDUser.java b/src/main/java/com/launchdarkly/client/LDUser.java index 8c052540c..2ff395ec7 100644 --- a/src/main/java/com/launchdarkly/client/LDUser.java +++ b/src/main/java/com/launchdarkly/client/LDUser.java @@ -25,7 +25,10 @@ * guides on Setting user attributes * and Targeting users. *

    - * If you want to pass an LDUser object to the front end to be used with the JavaScript SDK, cal + * If you want to pass an LDUser object to the front end to be used with the JavaScript SDK, call {@link #toJsonString()} + * to get its JSON encoding. Do not try to pass an LDUser instance to a reflection-based encoder such as Gson; its + * internal structure does not correspond directly to the JSON encoding, and an external instance of Gson will not + * recognize the Gson annotations used inside the SDK. */ public class LDUser { private static final Logger logger = LoggerFactory.getLogger(LDUser.class); From 47ba385fe5fb2cf4a57ead94b051415353f4827f Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 5 Mar 2020 17:48:50 -0800 Subject: [PATCH 334/673] remove unnecessary classes + misc comment copyedits --- .../com/launchdarkly/sdk/ArrayBuilder.java | 4 +- .../launchdarkly/sdk/EvaluationDetail.java | 4 +- .../launchdarkly/sdk/EvaluationReason.java | 8 +- .../java/com/launchdarkly/sdk/LDUser.java | 2 +- .../java/com/launchdarkly/sdk/LDValue.java | 21 +++-- .../com/launchdarkly/sdk/ObjectBuilder.java | 4 +- .../com/launchdarkly/sdk/UserAttribute.java | 4 + .../launchdarkly/sdk/server/DataModel.java | 92 ++++++++++++------- .../sdk/server/DataStoreDataSetSorter.java | 17 ++-- .../sdk/server/DefaultFeatureRequestor.java | 8 +- .../com/launchdarkly/sdk/server/LDClient.java | 12 ++- .../sdk/server/StreamProcessor.java | 12 +-- .../integrations/FileDataSourceImpl.java | 11 ++- .../integrations/FileDataSourceParsing.java | 10 +- .../sdk/server/interfaces/VersionedData.java | 23 ----- .../server/interfaces/VersionedDataKind.java | 91 ------------------ .../sdk/server/DataStoreTestTypes.java | 10 +- .../launchdarkly/sdk/server/LDClientTest.java | 25 ++--- .../sdk/server/StreamProcessorTest.java | 16 +--- .../com/launchdarkly/sdk/server/TestUtil.java | 6 +- .../server/integrations/DataLoaderTest.java | 4 +- .../integrations/FileDataSourceTest.java | 6 +- 22 files changed, 152 insertions(+), 238 deletions(-) delete mode 100644 src/main/java/com/launchdarkly/sdk/server/interfaces/VersionedData.java delete mode 100644 src/main/java/com/launchdarkly/sdk/server/interfaces/VersionedDataKind.java diff --git a/src/main/java/com/launchdarkly/sdk/ArrayBuilder.java b/src/main/java/com/launchdarkly/sdk/ArrayBuilder.java index a335c4d3b..fa5302738 100644 --- a/src/main/java/com/launchdarkly/sdk/ArrayBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/ArrayBuilder.java @@ -3,7 +3,9 @@ import com.google.common.collect.ImmutableList; /** - * A builder created by {@link LDValue#buildArray()}. Builder methods are not thread-safe. + * A builder created by {@link LDValue#buildArray()}. + *

    + * Builder methods are not thread-safe. * * @since 4.8.0 */ diff --git a/src/main/java/com/launchdarkly/sdk/EvaluationDetail.java b/src/main/java/com/launchdarkly/sdk/EvaluationDetail.java index a57c9b464..656132f60 100644 --- a/src/main/java/com/launchdarkly/sdk/EvaluationDetail.java +++ b/src/main/java/com/launchdarkly/sdk/EvaluationDetail.java @@ -1,11 +1,11 @@ package com.launchdarkly.sdk; import com.google.common.base.Objects; -import com.launchdarkly.sdk.server.LDClientInterface; /** - * An object returned by the "variation detail" methods such as {@link LDClientInterface#boolVariationDetail(String, LDUser, boolean)}, + * An object returned by the SDK's "variation detail" methods such as {@code boolVariationDetail}, * 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 */ diff --git a/src/main/java/com/launchdarkly/sdk/EvaluationReason.java b/src/main/java/com/launchdarkly/sdk/EvaluationReason.java index 6ff7713ca..704817db7 100644 --- a/src/main/java/com/launchdarkly/sdk/EvaluationReason.java +++ b/src/main/java/com/launchdarkly/sdk/EvaluationReason.java @@ -7,9 +7,11 @@ 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#boolVariationDetail(String, LDUser, boolean)}. - * + * Describes the reason that a flag evaluation produced a particular value. + *

    + * This is returned within {@link EvaluationDetail} by the SDK's "variation detail" methods such as + * {@code boolVariationDetail}. + *

    * Note that this is an enum-like class hierarchy rather than an enum, because some of the * possible reasons have their own properties. * diff --git a/src/main/java/com/launchdarkly/sdk/LDUser.java b/src/main/java/com/launchdarkly/sdk/LDUser.java index 190287b9c..196eb090f 100644 --- a/src/main/java/com/launchdarkly/sdk/LDUser.java +++ b/src/main/java/com/launchdarkly/sdk/LDUser.java @@ -15,7 +15,7 @@ import java.util.Set; /** - * A {@code LDUser} object contains specific attributes of a user browsing your site. + * A collection of attributes that can affect flag evaluation, usually corresponding to a user of your application. *

    * The only mandatory property is the {@code key}, which must uniquely identify each user; this could be a username * or email address for authenticated users, or a session ID for anonymous users. All other built-in properties are diff --git a/src/main/java/com/launchdarkly/sdk/LDValue.java b/src/main/java/com/launchdarkly/sdk/LDValue.java index 125aabce8..f4281d069 100644 --- a/src/main/java/com/launchdarkly/sdk/LDValue.java +++ b/src/main/java/com/launchdarkly/sdk/LDValue.java @@ -5,7 +5,6 @@ import com.google.gson.Gson; import com.google.gson.annotations.JsonAdapter; import com.google.gson.stream.JsonWriter; -import com.launchdarkly.sdk.server.LDClientInterface; import java.io.IOException; import java.util.Map; @@ -13,13 +12,21 @@ /** * 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. + * An {@link LDValue} instance can be a null (that is, an instance that represents a JSON null value, + * rather than a Java null reference), a boolean, a number (always encoded internally as double-precision + * floating-point, but can be treated as an integer), a string, an ordered list of {@link LDValue} + * values (a JSON array), or a map of strings to {@link LDValue} values (a JSON object). It is easily + * convertible to standard Java types. *

    - * 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. + * This can be used to represent complex data in a user custom attribute (see {@link LDUser.Builder#custom(String, LDValue)}), + * or to get a feature flag value that uses a complex type or that does not always use the same + * type (see the client's {@code jsonValueVariation} methods). + *

    + * While the LaunchDarkly SDK uses Gson internally for JSON parsing, it uses {@link LDValue} rather + * than Gson's {@code JsonElement} type for two reasons. First, this allows Gson types to be excluded + * from the API, so the SDK does not expose this dependency and cannot cause version conflicts in + * applications that use Gson themselves. Second, Gson's array and object types are mutable, which can + * cause concurrency risks. * * @since 4.8.0 */ diff --git a/src/main/java/com/launchdarkly/sdk/ObjectBuilder.java b/src/main/java/com/launchdarkly/sdk/ObjectBuilder.java index c85777e13..cb0157e93 100644 --- a/src/main/java/com/launchdarkly/sdk/ObjectBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/ObjectBuilder.java @@ -4,7 +4,9 @@ import java.util.Map; /** - * A builder created by {@link LDValue#buildObject()}. Builder methods are not thread-safe. + * A builder created by {@link LDValue#buildObject()}. + *

    + * Builder methods are not thread-safe. * * @since 4.8.0 */ diff --git a/src/main/java/com/launchdarkly/sdk/UserAttribute.java b/src/main/java/com/launchdarkly/sdk/UserAttribute.java index 050ba3bf6..ab1102616 100644 --- a/src/main/java/com/launchdarkly/sdk/UserAttribute.java +++ b/src/main/java/com/launchdarkly/sdk/UserAttribute.java @@ -17,6 +17,10 @@ * This abstraction helps to distinguish attribute names from other {@link String} values, and also * improves efficiency in feature flag data structures and evaluations because built-in attributes * always reuse the same instances. + *

    + * For a fuller description of user attributes and how they can be referenced in feature flag rules, see the reference + * guides on Setting user attributes + * and Targeting users. * * @since 5.0.0 */ diff --git a/src/main/java/com/launchdarkly/sdk/server/DataModel.java b/src/main/java/com/launchdarkly/sdk/server/DataModel.java index f6fe40a67..69e26445a 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DataModel.java +++ b/src/main/java/com/launchdarkly/sdk/server/DataModel.java @@ -1,60 +1,84 @@ package com.launchdarkly.sdk.server; +import com.google.common.collect.ImmutableList; import com.google.gson.annotations.JsonAdapter; import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.UserAttribute; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; -import com.launchdarkly.sdk.server.interfaces.VersionedData; -import com.launchdarkly.sdk.server.interfaces.VersionedDataKind; import java.util.List; /** - * Defines the full data model for feature flags and user segments, in the format provided by the SDK endpoints of - * the LaunchDarkly service. - * + * Contains information about the internal data model for feature flags and user segments. + *

    * The details of the data model are not public to application code (although of course developers can easily * look at the code or the data) so that changes to LaunchDarkly SDK implementation details will not be breaking - * changes to the application. + * changes to the application. Therefore, most of the members of this class are package-private. The public + * members provide a high-level description of model objects so that custom integration code or test code can + * store or serialize them. */ public abstract class DataModel { /** - * Contains standard instances of {@link VersionedDataKind} representing the main data model types. + * The {@link DataKind} instance that describes feature flag data. + *

    + * Applications should not need to reference this object directly. It is public so that custom integrations + * and test code can serialize or deserialize data or inject it into a data store. */ - public static abstract class DataKinds { - /** - * The {@link DataKind} instance that describes feature flag data. - */ - public static DataKind FEATURES = new DataKind("features", - DataKinds::serializeItem, - s -> deserializeItem(s, FeatureFlag.class)); - - /** - * The {@link DataKind} instance that describes user segment data. - */ - public static DataKind SEGMENTS = new DataKind("segments", - DataKinds::serializeItem, - s -> deserializeItem(s, Segment.class)); - - private static String serializeItem(ItemDescriptor item) { - Object o = item.getItem(); - if (o != null) { - return JsonHelpers.gsonInstance().toJson(o); - } - return "{\"version\":" + item.getVersion() + ",\"deleted\":true}"; - } - - private static ItemDescriptor deserializeItem(String s, Class itemClass) { - VersionedData o = JsonHelpers.gsonInstance().fromJson(s, itemClass); - return o.isDeleted() ? ItemDescriptor.deletedItem(o.getVersion()) : new ItemDescriptor(o.getVersion(), o); + public static DataKind FEATURES = new DataKind("features", + DataModel::serializeItem, + s -> deserializeItem(s, FeatureFlag.class)); + + /** + * The {@link DataKind} instance that describes user segment data. + *

    + * Applications should not need to reference this object directly. It is public so that custom integrations + * and test code can serialize or deserialize data or inject it into a data store. + */ + public static DataKind SEGMENTS = new DataKind("segments", + DataModel::serializeItem, + s -> deserializeItem(s, Segment.class)); + + /** + * An enumeration of all supported {@link DataKind} types. + *

    + * Applications should not need to reference this object directly. It is public so that custom data store + * implementations can determine ahead of time what kinds of model objects may need to be stored, if + * necessary. + */ + public static Iterable ALL_DATA_KINDS = ImmutableList.of(FEATURES, SEGMENTS); + + private static ItemDescriptor deserializeItem(String s, Class itemClass) { + VersionedData o = JsonHelpers.gsonInstance().fromJson(s, itemClass); + return o.isDeleted() ? ItemDescriptor.deletedItem(o.getVersion()) : new ItemDescriptor(o.getVersion(), o); + } + + private static String serializeItem(ItemDescriptor item) { + Object o = item.getItem(); + if (o != null) { + return JsonHelpers.gsonInstance().toJson(o); } + return "{\"version\":" + item.getVersion() + ",\"deleted\":true}"; } - + // All of these inner data model classes should have package-private scope. They should have only property // accessors; the evaluator logic is in Evaluator, EvaluatorBucketing, and EvaluatorOperators. + /** + * Common interface for FeatureFlag and Segment, for convenience in accessing their common properties. + * @since 3.0.0 + */ + interface VersionedData { + String getKey(); + int getVersion(); + /** + * True if this is a placeholder for a deleted item. + * @return true if deleted + */ + boolean isDeleted(); + } + @JsonAdapter(JsonHelpers.PostProcessingDeserializableTypeAdapterFactory.class) static final class FeatureFlag implements VersionedData, JsonHelpers.PostProcessingDeserializable { private String key; diff --git a/src/main/java/com/launchdarkly/sdk/server/DataStoreDataSetSorter.java b/src/main/java/com/launchdarkly/sdk/server/DataStoreDataSetSorter.java index 6262af235..3638e437f 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DataStoreDataSetSorter.java +++ b/src/main/java/com/launchdarkly/sdk/server/DataStoreDataSetSorter.java @@ -3,7 +3,6 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSortedMap; -import com.launchdarkly.sdk.server.interfaces.VersionedDataKind; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; @@ -15,6 +14,8 @@ import static com.google.common.collect.Iterables.isEmpty; import static com.google.common.collect.Iterables.transform; +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; /** * Implements a dependency graph ordering for data to be stored in a data store. We must use this @@ -24,10 +25,8 @@ */ abstract class DataStoreDataSetSorter { /** - * 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 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. + * Returns a copy of the input data set that guarantees that if you iterate through it the outer list and + * the inner list in the order provided, any object that depends on another object will be updated after it. * * @param allData the unordered data set * @return a map with a defined ordering @@ -81,14 +80,14 @@ private static void addWithDependenciesFirst(DataKind kind, } private static boolean isDependencyOrdered(DataKind kind) { - return kind == DataModel.DataKinds.FEATURES; + return kind == FEATURES; } private static Iterable getDependencyKeys(DataKind kind, Object item) { if (item == null) { return null; } - if (kind == DataModel.DataKinds.FEATURES) { + if (kind == FEATURES) { DataModel.FeatureFlag flag = (DataModel.FeatureFlag)item; if (flag.getPrerequisites() == null || flag.getPrerequisites().isEmpty()) { return ImmutableList.of(); @@ -99,9 +98,9 @@ private static Iterable getDependencyKeys(DataKind kind, Object item) { } private static int getPriority(DataKind kind) { - if (kind == DataModel.DataKinds.FEATURES) { + if (kind == FEATURES) { return 1; - } else if (kind == DataModel.DataKinds.SEGMENTS) { + } else if (kind == SEGMENTS) { return 0; } else { return kind.getName().length() + 2; diff --git a/src/main/java/com/launchdarkly/sdk/server/DefaultFeatureRequestor.java b/src/main/java/com/launchdarkly/sdk/server/DefaultFeatureRequestor.java index 820618a9d..cd35e11aa 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DefaultFeatureRequestor.java +++ b/src/main/java/com/launchdarkly/sdk/server/DefaultFeatureRequestor.java @@ -5,7 +5,7 @@ import com.google.common.collect.ImmutableMap; import com.google.common.collect.Maps; import com.google.common.io.Files; -import com.launchdarkly.sdk.server.interfaces.VersionedData; +import com.launchdarkly.sdk.server.DataModel.VersionedData; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; @@ -18,6 +18,8 @@ import java.net.URI; import java.util.Map; +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; import static com.launchdarkly.sdk.server.JsonHelpers.gsonInstance; import static com.launchdarkly.sdk.server.Util.configureHttpClientBuilder; import static com.launchdarkly.sdk.server.Util.getHeadersBuilderFor; @@ -84,8 +86,8 @@ public AllData getAllData() throws IOException, HttpErrorException { static FullDataSet toFullDataSet(AllData allData) { return new FullDataSet(ImmutableMap.of( - DataModel.DataKinds.FEATURES, toKeyedItems(allData.flags), - DataModel.DataKinds.SEGMENTS, toKeyedItems(allData.segments) + FEATURES, toKeyedItems(allData.flags), + SEGMENTS, toKeyedItems(allData.segments) ).entrySet()); } diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClient.java b/src/main/java/com/launchdarkly/sdk/server/LDClient.java index f92a83a01..5fd4a14be 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClient.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClient.java @@ -9,12 +9,12 @@ import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; import com.launchdarkly.sdk.server.interfaces.Event; import com.launchdarkly.sdk.server.interfaces.EventProcessor; import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; import org.apache.commons.codec.binary.Hex; import org.slf4j.Logger; @@ -36,6 +36,8 @@ import javax.crypto.spec.SecretKeySpec; import static com.google.common.base.Preconditions.checkNotNull; +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; /** * A client for the LaunchDarkly API. Client instances are thread-safe. Applications should instantiate @@ -66,12 +68,12 @@ public LDClient(String sdkKey) { } private static final DataModel.FeatureFlag getFlag(DataStore store, String key) { - ItemDescriptor item = store.get(DataModel.DataKinds.FEATURES, key); + ItemDescriptor item = store.get(FEATURES, key); return item == null ? null : (DataModel.FeatureFlag)item.getItem(); } private static final DataModel.Segment getSegment(DataStore store, String key) { - ItemDescriptor item = store.get(DataModel.DataKinds.SEGMENTS, key); + ItemDescriptor item = store.get(SEGMENTS, key); return item == null ? null : (DataModel.Segment)item.getItem(); } @@ -200,7 +202,7 @@ public FeatureFlagsState allFlagsState(LDUser user, FlagsStateOption... options) } boolean clientSideOnly = FlagsStateOption.hasOption(options, FlagsStateOption.CLIENT_SIDE_ONLY); - KeyedItems flags = dataStore.getAll(DataModel.DataKinds.FEATURES); + KeyedItems flags = dataStore.getAll(FEATURES); for (Map.Entry entry : flags.getItems()) { DataModel.FeatureFlag flag = (DataModel.FeatureFlag)entry.getValue().getItem(); if (clientSideOnly && !flag.isClientSide()) { diff --git a/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java index d623ff4d0..1221e5643 100644 --- a/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java @@ -6,14 +6,14 @@ import com.google.gson.JsonElement; import com.launchdarkly.eventsource.ConnectionErrorHandler; import com.launchdarkly.eventsource.ConnectionErrorHandler.Action; -import com.launchdarkly.sdk.server.interfaces.DataSource; -import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.eventsource.EventHandler; import com.launchdarkly.eventsource.EventSource; import com.launchdarkly.eventsource.MessageEvent; import com.launchdarkly.eventsource.UnsuccessfulResponseException; +import com.launchdarkly.sdk.server.interfaces.DataSource; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -24,8 +24,8 @@ import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicBoolean; -import static com.launchdarkly.sdk.server.DataModel.DataKinds.FEATURES; -import static com.launchdarkly.sdk.server.DataModel.DataKinds.SEGMENTS; +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; import static com.launchdarkly.sdk.server.Util.configureHttpClientBuilder; import static com.launchdarkly.sdk.server.Util.getHeadersBuilderFor; import static com.launchdarkly.sdk.server.Util.httpErrorMessage; diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java index 08211c24f..5dafc9bdb 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java @@ -3,17 +3,16 @@ import com.google.common.collect.ImmutableList; import com.google.common.util.concurrent.Futures; import com.launchdarkly.sdk.LDValue; -import com.launchdarkly.sdk.server.DataModel; import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FileDataException; import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FlagFactory; import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FlagFileParser; import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FlagFileRep; import com.launchdarkly.sdk.server.interfaces.DataSource; -import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; +import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -38,6 +37,8 @@ import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicBoolean; +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; import static java.nio.file.StandardWatchEventKinds.ENTRY_CREATE; import static java.nio.file.StandardWatchEventKinds.ENTRY_DELETE; import static java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY; @@ -218,17 +219,17 @@ public void load(DataBuilder builder) throws FileDataException FlagFileRep fileContents = parser.parse(new ByteArrayInputStream(data)); if (fileContents.flags != null) { for (Map.Entry e: fileContents.flags.entrySet()) { - builder.add(DataModel.DataKinds.FEATURES, e.getKey(), FlagFactory.flagFromJson(e.getValue())); + builder.add(FEATURES, e.getKey(), FlagFactory.flagFromJson(e.getValue())); } } if (fileContents.flagValues != null) { for (Map.Entry e: fileContents.flagValues.entrySet()) { - builder.add(DataModel.DataKinds.FEATURES, e.getKey(), FlagFactory.flagWithValue(e.getKey(), e.getValue())); + builder.add(FEATURES, e.getKey(), FlagFactory.flagWithValue(e.getKey(), e.getValue())); } } if (fileContents.segments != null) { for (Map.Entry e: fileContents.segments.entrySet()) { - builder.add(DataModel.DataKinds.SEGMENTS, e.getKey(), FlagFactory.segmentFromJson(e.getValue())); + builder.add(SEGMENTS, e.getKey(), FlagFactory.segmentFromJson(e.getValue())); } } } catch (FileDataException e) { diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceParsing.java b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceParsing.java index bc4235d9e..74d3d46e3 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceParsing.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceParsing.java @@ -4,7 +4,6 @@ import com.google.gson.JsonElement; import com.google.gson.JsonSyntaxException; import com.launchdarkly.sdk.LDValue; -import com.launchdarkly.sdk.server.DataModel; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import org.yaml.snakeyaml.Yaml; @@ -18,6 +17,9 @@ import java.nio.file.Path; import java.util.Map; +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; + abstract class FileDataSourceParsing { /** * Indicates that the file processor encountered an error in one of the input files. This exception is @@ -182,7 +184,7 @@ public FlagFileRep parse(InputStream input) throws FileDataException, IOExceptio */ static final class FlagFactory { static ItemDescriptor flagFromJson(String jsonString) { - return DataModel.DataKinds.FEATURES.deserialize(jsonString); + return FEATURES.deserialize(jsonString); } static ItemDescriptor flagFromJson(LDValue jsonTree) { @@ -202,11 +204,11 @@ static ItemDescriptor flagWithValue(String key, LDValue jsonValue) { .build(); // 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. - return DataModel.DataKinds.FEATURES.deserialize(o.toJsonString()); + return FEATURES.deserialize(o.toJsonString()); } static ItemDescriptor segmentFromJson(String jsonString) { - return DataModel.DataKinds.SEGMENTS.deserialize(jsonString); + return SEGMENTS.deserialize(jsonString); } static ItemDescriptor segmentFromJson(LDValue jsonTree) { diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/VersionedData.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/VersionedData.java deleted file mode 100644 index b1f788322..000000000 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/VersionedData.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.launchdarkly.sdk.server.interfaces; - -/** - * Common interface for string-keyed, versioned objects that can be kept in a {@link DataStore}. - * @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/sdk/server/interfaces/VersionedDataKind.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/VersionedDataKind.java deleted file mode 100644 index 87dd3db84..000000000 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/VersionedDataKind.java +++ /dev/null @@ -1,91 +0,0 @@ -package com.launchdarkly.sdk.server.interfaces; - -import com.google.common.collect.ImmutableList; - -/** - * The descriptor for a specific kind of {@link VersionedData} objects that may exist in a {@link DataStore}. - * You will not need to refer to this type unless you are directly manipulating a {@link DataStore} - * or writing your own {@link DataStore} implementation. If you are implementing a custom store, for - * maximum forward compatibility you should only refer to {@link VersionedData} and {@link VersionedDataKind}, - * 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 { - - /** - * A short string that serves as the unique name for the collection of these objects, e.g. "features". - * @return a namespace string - */ - public abstract String getNamespace(); - - /** - * The Java class for objects of this type. - * @return a Java class - */ - public abstract Class getItemClass(); - - /** - * The path prefix for objects of this type in events received from the streaming API. - * @return the URL path - */ - public abstract String getStreamApiPath(); - - /** - * Return an instance of this type with the specified key and version, and deleted=true. - * @param key the unique key - * @param version the version number - * @return a new instance - */ - public abstract T makeDeletedItem(String key, int version); - - /** - * Deserialize an instance of this type from its string representation (normally JSON). - * @param serializedData the serialized data - * @return the deserialized object - */ - public abstract T deserialize(String serializedData); - - /** - * 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 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. - * - * @param item the item - * @return keys of dependencies of the item - * @since 4.7.0 - */ - public Iterable getDependencyKeys(VersionedData item) { - return ImmutableList.of(); - } - - @Override - public String toString() { - return "{" + getNamespace() + "}"; - } -} diff --git a/src/test/java/com/launchdarkly/sdk/server/DataStoreTestTypes.java b/src/test/java/com/launchdarkly/sdk/server/DataStoreTestTypes.java index f8c62ebb4..dd68112aa 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataStoreTestTypes.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataStoreTestTypes.java @@ -4,8 +4,7 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.Maps; -import com.launchdarkly.sdk.server.JsonHelpers; -import com.launchdarkly.sdk.server.interfaces.VersionedData; +import com.launchdarkly.sdk.server.DataModel.VersionedData; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; @@ -132,7 +131,12 @@ private static ItemDescriptor deserializeTestItem(String s) { public static class DataBuilder { private Map> data = new HashMap<>(); - public DataBuilder add(DataKind kind, VersionedData... items) { + public DataBuilder add(DataKind kind, TestItem... items) { + return addAny(kind, items); + } + + // This is defined separately because test code that's outside of this package can't see DataModel.VersionedData + public DataBuilder addAny(DataKind kind, VersionedData... items) { Map itemsMap = data.get(kind); if (itemsMap == null) { itemsMap = new HashMap<>(); diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java index 260751ceb..ecf92f682 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java @@ -5,29 +5,18 @@ import com.google.common.collect.Iterables; import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; -import com.launchdarkly.sdk.server.ClientContextImpl; -import com.launchdarkly.sdk.server.Components; -import com.launchdarkly.sdk.server.DataModel; -import com.launchdarkly.sdk.server.DefaultEventProcessor; -import com.launchdarkly.sdk.server.DiagnosticAccumulator; -import com.launchdarkly.sdk.server.InMemoryDataStore; -import com.launchdarkly.sdk.server.LDClient; -import com.launchdarkly.sdk.server.LDClientInterface; -import com.launchdarkly.sdk.server.LDConfig; -import com.launchdarkly.sdk.server.PollingProcessor; -import com.launchdarkly.sdk.server.StreamProcessor; import com.launchdarkly.sdk.server.DataStoreTestTypes.DataBuilder; import com.launchdarkly.sdk.server.interfaces.ClientContext; import com.launchdarkly.sdk.server.interfaces.DataSource; import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; import com.launchdarkly.sdk.server.interfaces.Event; import com.launchdarkly.sdk.server.interfaces.EventProcessor; import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import org.easymock.Capture; import org.easymock.EasyMock; @@ -45,8 +34,8 @@ import java.util.concurrent.TimeoutException; import static com.google.common.collect.Iterables.transform; -import static com.launchdarkly.sdk.server.DataModel.DataKinds.FEATURES; -import static com.launchdarkly.sdk.server.DataModel.DataKinds.SEGMENTS; +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; import static com.launchdarkly.sdk.server.DataStoreTestTypes.toDataMap; import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; import static com.launchdarkly.sdk.server.ModelBuilders.flagWithValue; @@ -460,7 +449,7 @@ private LDClientInterface createMockClient(LDConfig.Builder config) { private static FullDataSet DEPENDENCY_ORDERING_TEST_DATA = new DataBuilder() - .add(FEATURES, + .addAny(FEATURES, flagBuilder("a") .prerequisites(prerequisite("b", 0), prerequisite("c", 0)).build(), flagBuilder("b") @@ -469,7 +458,7 @@ private LDClientInterface createMockClient(LDConfig.Builder config) { flagBuilder("d").build(), flagBuilder("e").build(), flagBuilder("f").build()) - .add(SEGMENTS, + .addAny(SEGMENTS, segmentBuilder("o").build()) .build(); } \ No newline at end of file diff --git a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java index 243b85d9c..240527439 100644 --- a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java @@ -5,18 +5,6 @@ import com.launchdarkly.eventsource.EventSource; import com.launchdarkly.eventsource.MessageEvent; import com.launchdarkly.eventsource.UnsuccessfulResponseException; -import com.launchdarkly.sdk.server.Components; -import com.launchdarkly.sdk.server.DataModel; -import com.launchdarkly.sdk.server.DefaultFeatureRequestor; -import com.launchdarkly.sdk.server.DiagnosticAccumulator; -import com.launchdarkly.sdk.server.DiagnosticEvent; -import com.launchdarkly.sdk.server.DiagnosticId; -import com.launchdarkly.sdk.server.FeatureRequestor; -import com.launchdarkly.sdk.server.HttpConfiguration; -import com.launchdarkly.sdk.server.InMemoryDataStore; -import com.launchdarkly.sdk.server.LDClient; -import com.launchdarkly.sdk.server.LDConfig; -import com.launchdarkly.sdk.server.StreamProcessor; import com.launchdarkly.sdk.server.integrations.StreamingDataSourceBuilder; import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; @@ -37,8 +25,8 @@ import javax.net.ssl.SSLHandshakeException; -import static com.launchdarkly.sdk.server.DataModel.DataKinds.FEATURES; -import static com.launchdarkly.sdk.server.DataModel.DataKinds.SEGMENTS; +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; import static com.launchdarkly.sdk.server.ModelBuilders.segmentBuilder; import static com.launchdarkly.sdk.server.TestHttpUtil.eventStreamResponse; diff --git a/src/test/java/com/launchdarkly/sdk/server/TestUtil.java b/src/test/java/com/launchdarkly/sdk/server/TestUtil.java index 167f82b21..e1856edf6 100644 --- a/src/test/java/com/launchdarkly/sdk/server/TestUtil.java +++ b/src/test/java/com/launchdarkly/sdk/server/TestUtil.java @@ -33,6 +33,8 @@ import java.util.Set; import java.util.concurrent.Future; +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; import static org.hamcrest.Matchers.equalTo; @SuppressWarnings("javadoc") @@ -64,11 +66,11 @@ public static DataStore initedDataStore() { } public static void upsertFlag(DataStore store, FeatureFlag flag) { - store.upsert(DataModel.DataKinds.FEATURES, flag.getKey(), new ItemDescriptor(flag.getVersion(), flag)); + store.upsert(FEATURES, flag.getKey(), new ItemDescriptor(flag.getVersion(), flag)); } public static void upsertSegment(DataStore store, Segment segment) { - store.upsert(DataModel.DataKinds.SEGMENTS, segment.getKey(), new ItemDescriptor(segment.getVersion(), segment)); + store.upsert(SEGMENTS, segment.getKey(), new ItemDescriptor(segment.getVersion(), segment)); } public static EventProcessorFactory specificEventProcessor(final EventProcessor ep) { diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/DataLoaderTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/DataLoaderTest.java index 2ab0516eb..d9f2969db 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/DataLoaderTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/DataLoaderTest.java @@ -15,8 +15,8 @@ import java.util.Map; -import static com.launchdarkly.sdk.server.DataModel.DataKinds.FEATURES; -import static com.launchdarkly.sdk.server.DataModel.DataKinds.SEGMENTS; +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; import static com.launchdarkly.sdk.server.DataStoreTestTypes.toDataMap; import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.FLAG_VALUE_1_KEY; import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.resourceFilePath; diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java index cf12c9b94..3091fa197 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java @@ -2,8 +2,6 @@ import com.launchdarkly.sdk.server.LDConfig; import com.launchdarkly.sdk.server.TestUtil; -import com.launchdarkly.sdk.server.integrations.FileData; -import com.launchdarkly.sdk.server.integrations.FileDataSourceBuilder; import com.launchdarkly.sdk.server.interfaces.DataSource; import com.launchdarkly.sdk.server.interfaces.DataStore; @@ -16,8 +14,8 @@ import java.util.concurrent.Future; import static com.google.common.collect.Iterables.size; -import static com.launchdarkly.sdk.server.DataModel.DataKinds.FEATURES; -import static com.launchdarkly.sdk.server.DataModel.DataKinds.SEGMENTS; +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; import static com.launchdarkly.sdk.server.DataStoreTestTypes.toItemsMap; import static com.launchdarkly.sdk.server.TestUtil.clientContext; import static com.launchdarkly.sdk.server.TestUtil.dataStoreUpdates; From ad8313f5d2275a1319e19813f466c4832fe35a22 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 5 Mar 2020 17:55:06 -0800 Subject: [PATCH 335/673] fix merge error --- packaging-test/Makefile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packaging-test/Makefile b/packaging-test/Makefile index d6273253c..d2f52c210 100644 --- a/packaging-test/Makefile +++ b/packaging-test/Makefile @@ -107,7 +107,8 @@ test-default-jar-classes: $(SDK_DEFAULT_JAR) $(TEMP_DIR) @$(call verify_sdk_classes) @$(call classes_should_contain,'^com/launchdarkly/shaded/',shaded dependency jars) @$(call classes_should_not_contain,'^com/launchdarkly/shaded/com/launchdarkly/sdk',shaded SDK classes) - @$(call classes_should_not_contain,'com/google/gson/',Gson (shaded or unshaded)) + @$(call classes_should_contain,'^com/launchdarkly/shaded/com/google/gson',shaded Gson) + @$(call classes_should_not_contain,'^com/google/gson/',Gson (unshaded)) @$(call classes_should_not_contain,'org/slf4j/',SLF4j (shaded or unshaded)) test-thin-jar-classes: $(SDK_THIN_JAR) $(TEMP_DIR) From e7ed1029a26822b632dd1d78ca01514c7d506942 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 5 Mar 2020 22:07:38 -0800 Subject: [PATCH 336/673] remove Redis integration from SDK --- build.gradle | 17 +- .../src/main/java/testapp/TestApp.java | 7 - .../launchdarkly/sdk/server/Components.java | 4 +- .../sdk/server/integrations/Redis.java | 35 --- .../integrations/RedisDataStoreBuilder.java | 188 ---------------- .../integrations/RedisDataStoreImpl.java | 202 ------------------ .../sdk/server/integrations/package-info.java | 12 +- .../sdk/server/DiagnosticEventTest.java | 25 ++- .../RedisDataStoreBuilderTest.java | 84 -------- .../integrations/RedisDataStoreImplTest.java | 53 ----- 10 files changed, 37 insertions(+), 590 deletions(-) delete mode 100644 src/main/java/com/launchdarkly/sdk/server/integrations/Redis.java delete mode 100644 src/main/java/com/launchdarkly/sdk/server/integrations/RedisDataStoreBuilder.java delete mode 100644 src/main/java/com/launchdarkly/sdk/server/integrations/RedisDataStoreImpl.java delete mode 100644 src/test/java/com/launchdarkly/sdk/server/integrations/RedisDataStoreBuilderTest.java delete mode 100644 src/test/java/com/launchdarkly/sdk/server/integrations/RedisDataStoreImplTest.java diff --git a/build.gradle b/build.gradle index f96119015..d3bd7fa94 100644 --- a/build.gradle +++ b/build.gradle @@ -46,6 +46,12 @@ ext { sdkBasePackage = "com.launchdarkly.sdk" sdkBaseName = "launchdarkly-java-server-sdk" + commonsCodecVersion = "1.10" + gsonVersion = "2.7" + guavaVersion = "28.2-jre" + okHttpEventSourceVersion = "2.0.1" + snakeYamlVersion = "1.19" + // 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" ] @@ -57,12 +63,11 @@ ext.libraries = [:] // 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.code.gson:gson:2.7", - "com.google.guava:guava:28.2-jre", - "com.launchdarkly:okhttp-eventsource:2.0.1", - "org.yaml:snakeyaml:1.19", - "redis.clients:jedis:2.9.0" + "commons-codec:commons-codec:$commonsCodecVersion", + "com.google.code.gson:gson:$gsonVersion", + "com.google.guava:guava:$guavaVersion", + "com.launchdarkly:okhttp-eventsource:$okHttpEventSourceVersion", + "org.yaml:snakeyaml:$snakeYamlVersion" ] // Add dependencies to "libraries.external" that are exposed in our public API, or that have 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 d683dbfb9..d15bfb217 100644 --- a/packaging-test/test-app/src/main/java/testapp/TestApp.java +++ b/packaging-test/test-app/src/main/java/testapp/TestApp.java @@ -2,19 +2,12 @@ import com.launchdarkly.sdk.*; import com.launchdarkly.sdk.server.*; -import com.launchdarkly.sdk.server.integrations.*; import org.slf4j.*; 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); - } - LDConfig config = new LDConfig.Builder() .offline(true) .build(); diff --git a/src/main/java/com/launchdarkly/sdk/server/Components.java b/src/main/java/com/launchdarkly/sdk/server/Components.java index a8177f862..fcbeb0d5d 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Components.java +++ b/src/main/java/com/launchdarkly/sdk/server/Components.java @@ -71,11 +71,13 @@ public static DataStoreFactory inMemoryDataStore() { *

    * * See {@link PersistentDataStoreBuilder} for more on how this method is used. + *

    + * For more information on the available persistent data store implementations, see the reference + * guide on Using a persistent feature store. * * @param storeFactory the factory/builder for the specific kind of persistent data store * @return a {@link PersistentDataStoreBuilder} * @see LDConfig.Builder#dataStore(DataStoreFactory) - * @see com.launchdarkly.sdk.server.integrations.Redis * @since 4.12.0 */ public static PersistentDataStoreBuilder persistentDataStore(PersistentDataStoreFactory storeFactory) { diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/Redis.java b/src/main/java/com/launchdarkly/sdk/server/integrations/Redis.java deleted file mode 100644 index 3f3536e76..000000000 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/Redis.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.launchdarkly.sdk.server.integrations; - -/** - * Integration between the LaunchDarkly SDK and Redis. - * - * @since 4.12.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 Redis options. Then, pass it to {@link com.launchdarkly.sdk.server.Components#persistentDataStore(com.launchdarkly.sdk.server.interfaces.PersistentDataStoreFactory)} - * and set any desired caching options. Finally, pass the result to - * {@link com.launchdarkly.sdk.server.LDConfig.Builder#dataStore(com.launchdarkly.sdk.server.interfaces.DataStoreFactory)}. - * 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 - */ - public static RedisDataStoreBuilder dataStore() { - return new RedisDataStoreBuilder(); - } - - private Redis() {} -} diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/RedisDataStoreBuilder.java b/src/main/java/com/launchdarkly/sdk/server/integrations/RedisDataStoreBuilder.java deleted file mode 100644 index bf824184c..000000000 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/RedisDataStoreBuilder.java +++ /dev/null @@ -1,188 +0,0 @@ -package com.launchdarkly.sdk.server.integrations; - -import com.google.common.base.Joiner; -import com.launchdarkly.sdk.LDValue; -import com.launchdarkly.sdk.server.LDConfig; -import com.launchdarkly.sdk.server.interfaces.ClientContext; -import com.launchdarkly.sdk.server.interfaces.DiagnosticDescription; -import com.launchdarkly.sdk.server.interfaces.PersistentDataStore; -import com.launchdarkly.sdk.server.interfaces.PersistentDataStoreFactory; - -import java.net.URI; -import java.time.Duration; - -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.sdk.server.LDConfig.Builder#dataStore(com.launchdarkly.sdk.server.interfaces.DataStoreFactory)}. - * You do not need to call {@link #createPersistentDataStore(ClientContext)} 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(
    -   *             Components.persistentDataStore(
    -   *                 Redis.dataStore()
    -   *                     .url("redis://my-redis-host")
    -   *                     .database(1)
    -   *             ).cacheSeconds(15)
    -   *         )
    -   *         .build();
    - * 
    - * - * @since 4.12.0 - */ -public final class RedisDataStoreBuilder implements PersistentDataStoreFactory, DiagnosticDescription { - /** - * The default value for the Redis URI: {@code redis://localhost:6379} - */ - public static final URI DEFAULT_URI = makeDefaultRedisURI(); - - /** - * The default value for {@link #prefix(String)}. - */ - public static final String DEFAULT_PREFIX = "launchdarkly"; - - URI uri = DEFAULT_URI; - String prefix = DEFAULT_PREFIX; - Duration connectTimeout = Duration.ofMillis(Protocol.DEFAULT_TIMEOUT); - Duration socketTimeout = Duration.ofMillis(Protocol.DEFAULT_TIMEOUT); - Integer database = null; - String password = null; - 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() { - } - - /** - * 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(redisUri); - 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} milliseconds. - * - * @param connectTimeout the timeout - * @return the builder - */ - public RedisDataStoreBuilder connectTimeout(Duration connectTimeout) { - this.connectTimeout = connectTimeout == null ? Duration.ofMillis(Protocol.DEFAULT_TIMEOUT) : 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} milliseconds. - * - * @param socketTimeout the socket timeout - * @return the builder - */ - public RedisDataStoreBuilder socketTimeout(Duration socketTimeout) { - this.socketTimeout = socketTimeout == null ? Duration.ofMillis(Protocol.DEFAULT_TIMEOUT) : socketTimeout; - return this; - } - - /** - * Called internally by the SDK to create the actual data store instance. - * @return the data store configured by this builder - */ - @Override - public PersistentDataStore createPersistentDataStore(ClientContext context) { - return new RedisDataStoreImpl(this); - } - - @Override - public LDValue describeConfiguration(LDConfig config) { - return LDValue.of("Redis"); - } -} diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/RedisDataStoreImpl.java b/src/main/java/com/launchdarkly/sdk/server/integrations/RedisDataStoreImpl.java deleted file mode 100644 index 7d1133cfe..000000000 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/RedisDataStoreImpl.java +++ /dev/null @@ -1,202 +0,0 @@ -package com.launchdarkly.sdk.server.integrations; - -import com.google.common.annotations.VisibleForTesting; -import com.google.common.collect.Maps; -import com.launchdarkly.sdk.server.interfaces.PersistentDataStore; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.SerializedItemDescriptor; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.IOException; -import java.util.List; -import java.util.Map; - -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; - -final class RedisDataStoreImpl implements PersistentDataStore { - 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, - (int)builder.connectTimeout.toMillis(), - (int)builder.socketTimeout.toMillis(), - 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 SerializedItemDescriptor get(DataKind kind, String key) { - try (Jedis jedis = pool.getResource()) { - String item = getRedis(kind, key, jedis); - return item == null ? null : new SerializedItemDescriptor(0, false, item); - } - } - - @Override - public KeyedItems getAll(DataKind kind) { - try (Jedis jedis = pool.getResource()) { - Map allJson = jedis.hgetAll(itemsKey(kind)); - return new KeyedItems<>( - Maps.transformValues(allJson, itemJson -> new SerializedItemDescriptor(0, false, itemJson)).entrySet() - ); - } - } - - @Override - public void init(FullDataSet allData) { - try (Jedis jedis = pool.getResource()) { - Transaction t = jedis.multi(); - - for (Map.Entry> e0: allData.getData()) { - DataKind kind = e0.getKey(); - String baseKey = itemsKey(kind); - t.del(baseKey); - for (Map.Entry e1: e0.getValue().getItems()) { - t.hset(baseKey, e1.getKey(), jsonOrPlaceholder(kind, e1.getValue())); - } - } - - t.set(initedKey(), ""); - t.exec(); - } - } - - @Override - public boolean upsert(DataKind kind, String key, SerializedItemDescriptor newItem) { - while (true) { - Jedis jedis = null; - try { - jedis = pool.getResource(); - String baseKey = itemsKey(kind); - jedis.watch(baseKey); - - if (updateListener != null) { - updateListener.aboutToUpdate(baseKey, key); - } - - String oldItemJson = getRedis(kind, key, jedis); - // In this implementation, we have to parse the existing item in order to determine its version. - int oldVersion = oldItemJson == null ? -1 : kind.deserialize(oldItemJson).getVersion(); - - if (oldVersion >= newItem.getVersion()) { - logger.debug("Attempted to {} key: {} version: {}" + - " with a version that is the same or older: {} in \"{}\"", - newItem.getSerializedItem() == null ? "delete" : "update", - key, oldVersion, newItem.getVersion(), kind.getName()); - return false; - } - - Transaction tx = jedis.multi(); - tx.hset(baseKey, key, jsonOrPlaceholder(kind, 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 true; - } finally { - if (jedis != null) { - jedis.unwatch(); - jedis.close(); - } - } - } - } - - @Override - public boolean isInitialized() { - 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(DataKind kind) { - return prefix + ":" + kind.getName(); - } - - private String initedKey() { - return prefix + ":$inited"; - } - - private String getRedis(DataKind 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.getName()); - } - - return json; - } - - private static String jsonOrPlaceholder(DataKind kind, SerializedItemDescriptor serializedItem) { - String s = serializedItem.getSerializedItem(); - if (s != null) { - return s; - } - // For backward compatibility with previous implementations of the Redis integration, we must store a - // special placeholder string for deleted items. DataKind.serializeItem() will give us this string if - // we pass a deleted ItemDescriptor. - return kind.serialize(ItemDescriptor.deletedItem(serializedItem.getVersion())); - } - - static interface UpdateListener { - void aboutToUpdate(String baseKey, String itemKey); - } -} diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/package-info.java b/src/main/java/com/launchdarkly/sdk/server/integrations/package-info.java index 8e89a15e1..7c5d27cb6 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/package-info.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/package-info.java @@ -2,12 +2,10 @@ * 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.sdk.server.integrations.Redis} - * (for using Redis as a store for flag data) and {@link com.launchdarkly.sdk.server.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()}. + * In the current main LaunchDarkly Java SDK library, this package contains the configuration builders + * for the standard SDK components such as {@link com.launchdarkly.sdk.server.integrations.StreamingDataSourceBuilder}, + * the {@link com.launchdarkly.sdk.server.integrations.PersistentDataStoreBuilder} builder for use with + * database integrations (the specific database integrations themselves are provided by add-on libraries), + * and {@link com.launchdarkly.sdk.server.integrations.FileData} (for reading flags from a file in testing). */ package com.launchdarkly.sdk.server.integrations; diff --git a/src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java b/src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java index dfab347ec..65e04678b 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java @@ -5,11 +5,10 @@ import com.google.gson.JsonObject; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.ObjectBuilder; -import com.launchdarkly.sdk.server.Components; -import com.launchdarkly.sdk.server.DiagnosticEvent; -import com.launchdarkly.sdk.server.DiagnosticId; -import com.launchdarkly.sdk.server.LDConfig; -import com.launchdarkly.sdk.server.integrations.Redis; +import com.launchdarkly.sdk.server.interfaces.ClientContext; +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; +import com.launchdarkly.sdk.server.interfaces.DiagnosticDescription; import org.junit.Test; @@ -188,12 +187,12 @@ public void testCustomDiagnosticConfigurationForEvents() { public void testCustomDiagnosticConfigurationForDaemonMode() { LDConfig ldConfig = new LDConfig.Builder() .dataSource(Components.externalUpdatesOnly()) - .dataStore(Components.persistentDataStore(Redis.dataStore())) + .dataStore(new DataStoreFactoryWithComponentName()) .build(); LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig); LDValue expected = expectedDefaultPropertiesWithoutStreaming() - .put("dataStoreType", "Redis") + .put("dataStoreType", "my-test-store") .put("usingRelayDaemon", true) .build(); @@ -213,4 +212,16 @@ public void testCustomDiagnosticConfigurationForOffline() { assertEquals(expected, diagnosticJson); } + + private static class DataStoreFactoryWithComponentName implements DataStoreFactory, DiagnosticDescription { + @Override + public LDValue describeConfiguration(LDConfig config) { + return LDValue.of("my-test-store"); + } + + @Override + public DataStore createDataStore(ClientContext context) { + return null; + } + } } diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/RedisDataStoreBuilderTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/RedisDataStoreBuilderTest.java deleted file mode 100644 index 1c40b4cc8..000000000 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/RedisDataStoreBuilderTest.java +++ /dev/null @@ -1,84 +0,0 @@ -package com.launchdarkly.sdk.server.integrations; - -import com.launchdarkly.sdk.server.integrations.Redis; -import com.launchdarkly.sdk.server.integrations.RedisDataStoreBuilder; - -import org.junit.Test; - -import java.net.URI; -import java.net.URISyntaxException; -import java.time.Duration; - -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 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(Duration.ofMillis(Protocol.DEFAULT_TIMEOUT), conf.connectTimeout); - assertEquals(Duration.ofMillis(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"); - assertEquals("prefix", conf.prefix); - } - - @Test - public void testConnectTimeoutConfigured() throws URISyntaxException { - RedisDataStoreBuilder conf = Redis.dataStore().connectTimeout(Duration.ofSeconds(1)); - assertEquals(Duration.ofSeconds(1), conf.connectTimeout); - } - - @Test - public void testSocketTimeoutConfigured() throws URISyntaxException { - RedisDataStoreBuilder conf = Redis.dataStore().socketTimeout(Duration.ofSeconds(1)); - assertEquals(Duration.ofSeconds(1), 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/sdk/server/integrations/RedisDataStoreImplTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/RedisDataStoreImplTest.java deleted file mode 100644 index e352c678c..000000000 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/RedisDataStoreImplTest.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.launchdarkly.sdk.server.integrations; - -import com.launchdarkly.sdk.server.integrations.Redis; -import com.launchdarkly.sdk.server.integrations.RedisDataStoreImpl; -import com.launchdarkly.sdk.server.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(null); - } - - @Override - protected RedisDataStoreImpl makeStoreWithPrefix(String prefix) { - return (RedisDataStoreImpl)Redis.dataStore().uri(REDIS_URI).prefix(prefix).createPersistentDataStore(null); - } - - @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; - } -} From 37de0c0e7e6e133e4199a6dd45af926ce6924323 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 6 Mar 2020 18:02:58 -0800 Subject: [PATCH 337/673] implement flag change events --- .../launchdarkly/sdk/server/Components.java | 67 ++++ .../launchdarkly/sdk/server/DataModel.java | 29 +- .../sdk/server/DataModelDependencies.java | 280 +++++++++++++ .../sdk/server/DataStoreDataSetSorter.java | 116 ------ .../sdk/server/DataStoreUpdatesImpl.java | 122 +++++- .../launchdarkly/sdk/server/Evaluator.java | 48 +-- .../sdk/server/FlagChangeEventPublisher.java | 85 ++++ .../com/launchdarkly/sdk/server/LDClient.java | 24 +- .../sdk/server/LDClientInterface.java | 36 ++ .../server/interfaces/FlagChangeEvent.java | 38 ++ .../server/interfaces/FlagChangeListener.java | 29 ++ .../FlagChangeListenerRegistration.java | 33 ++ .../interfaces/FlagValueChangeEvent.java | 64 +++ .../interfaces/FlagValueChangeListener.java | 42 ++ .../sdk/server/DataStoreTestTypes.java | 7 + .../sdk/server/DataStoreUpdatesImplTest.java | 373 ++++++++++++++++++ .../launchdarkly/sdk/server/LDClientTest.java | 154 ++++---- .../sdk/server/ModelBuilders.java | 14 + .../com/launchdarkly/sdk/server/TestUtil.java | 87 +++- 19 files changed, 1416 insertions(+), 232 deletions(-) create mode 100644 src/main/java/com/launchdarkly/sdk/server/DataModelDependencies.java delete mode 100644 src/main/java/com/launchdarkly/sdk/server/DataStoreDataSetSorter.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/FlagChangeEventPublisher.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/interfaces/FlagChangeEvent.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/interfaces/FlagChangeListener.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/interfaces/FlagChangeListenerRegistration.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeEvent.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeListener.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/DataStoreUpdatesImplTest.java diff --git a/src/main/java/com/launchdarkly/sdk/server/Components.java b/src/main/java/com/launchdarkly/sdk/server/Components.java index a8177f862..7b55a0ee4 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Components.java +++ b/src/main/java/com/launchdarkly/sdk/server/Components.java @@ -1,5 +1,6 @@ package com.launchdarkly.sdk.server; +import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.server.DiagnosticEvent.ConfigProperty; import com.launchdarkly.sdk.server.integrations.EventProcessorBuilder; @@ -16,11 +17,17 @@ import com.launchdarkly.sdk.server.interfaces.Event; import com.launchdarkly.sdk.server.interfaces.EventProcessor; import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; +import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; +import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; +import com.launchdarkly.sdk.server.interfaces.FlagChangeListenerRegistration; +import com.launchdarkly.sdk.server.interfaces.FlagValueChangeEvent; +import com.launchdarkly.sdk.server.interfaces.FlagValueChangeListener; import com.launchdarkly.sdk.server.interfaces.PersistentDataStoreFactory; import java.io.IOException; import java.net.URI; import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicReference; import static com.google.common.util.concurrent.Futures.immediateFuture; @@ -207,6 +214,29 @@ public static DataSourceFactory externalUpdatesOnly() { return NullDataSourceFactory.INSTANCE; } + /** + * Convenience method for creating a {@link FlagChangeListener} that tracks a flag's value for a specific user. + *

    + * This listener instance should only be used with a single {@link LDClient} instance. When you first + * register it by calling {@link LDClientInterface#registerFlagChangeListener(FlagChangeListener)}, it + * immediately evaluates the flag. It then re-evaluates the flag whenever there is an update, and calls + * your {@link FlagValueChangeListener} if and only if the resulting value has changed. + *

    + * See {@link FlagValueChangeListener} for more information and examples. + * + * @param flagKey the flag key to be evaluated + * @param user the user properties for evaluation + * @param valueChangeListener an object that you provide which will be notified of changes + * @return a {@link FlagChangeListener} to be passed to {@link LDClientInterface#registerFlagChangeListener(FlagChangeListener)} + * + * @since 5.0.0 + * @see FlagValueChangeListener + * @see FlagChangeListener + */ + public static FlagChangeListener flagValueMonitoringListener(String flagKey, LDUser user, FlagValueChangeListener valueChangeListener) { + return new FlagValueMonitorImpl(flagKey, user, valueChangeListener); + } + private static final class InMemoryDataStoreFactory implements DataStoreFactory, DiagnosticDescription { static final DataStoreFactory INSTANCE = new InMemoryDataStoreFactory(); @Override @@ -441,4 +471,41 @@ public LDValue describeConfiguration(LDConfig config) { return LDValue.of("custom"); } } + + private static final class FlagValueMonitorImpl implements FlagChangeListener, FlagChangeListenerRegistration { + private volatile LDClientInterface client; + private AtomicReference currentValue = new AtomicReference<>(LDValue.ofNull()); + private final String flagKey; + private final LDUser user; + private final FlagValueChangeListener valueChangeListener; + + public FlagValueMonitorImpl(String flagKey, LDUser user, FlagValueChangeListener valueChangeListener) { + this.flagKey = flagKey; + this.user = user; + this.valueChangeListener = valueChangeListener; + } + + @Override + public void onRegister(LDClientInterface client) { + this.client = client; + currentValue.set(client.jsonValueVariation(flagKey, user, LDValue.ofNull())); + } + + @Override + public void onUnregister(LDClientInterface client) {} + + @Override + public void onFlagChange(FlagChangeEvent event) { + if (event.getKey().equals(flagKey)) { + LDClientInterface c = client; + if (c != null) { // shouldn't be possible to be null since we wouldn't get an event if we were never registered + LDValue newValue = c.jsonValueVariation(flagKey, user, LDValue.ofNull()); + LDValue previousValue = currentValue.getAndSet(newValue); + if (!newValue.equals(previousValue)) { + valueChangeListener.onFlagValueChange(new FlagValueChangeEvent(flagKey, previousValue, newValue)); + } + } + } + } + } } diff --git a/src/main/java/com/launchdarkly/sdk/server/DataModel.java b/src/main/java/com/launchdarkly/sdk/server/DataModel.java index 69e26445a..3a4466efd 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DataModel.java +++ b/src/main/java/com/launchdarkly/sdk/server/DataModel.java @@ -10,6 +10,8 @@ import java.util.List; +import static java.util.Collections.emptyList; + /** * Contains information about the internal data model for feature flags and user segments. *

    @@ -149,28 +151,32 @@ boolean isOn() { return on; } + // Guaranteed non-null List getPrerequisites() { - return prerequisites; + return prerequisites == null ? emptyList() : prerequisites; } String getSalt() { return salt; } + // Guaranteed non-null List getTargets() { - return targets; + return targets == null ? emptyList() : targets; } + // Guaranteed non-null List getRules() { - return rules; + return rules == null ? emptyList() : rules; } VariationOrRollout getFallthrough() { return fallthrough; } + // Guaranteed non-null List getVariations() { - return variations; + return variations == null ? emptyList() : variations; } Integer getOffVariation() { @@ -239,8 +245,9 @@ static final class Target { this.variation = variation; } + // Guaranteed non-null List getValues() { - return values; + return values == null ? emptyList() : values; } int getVariation() { @@ -275,8 +282,9 @@ String getId() { return id; } + // Guaranteed non-null Iterable getClauses() { - return clauses; + return clauses == null ? emptyList() : clauses; } boolean isTrackEvents() { @@ -415,12 +423,14 @@ public String getKey() { return key; } + // Guaranteed non-null Iterable getIncluded() { - return included; + return included == null ? emptyList() : included; } + // Guaranteed non-null Iterable getExcluded() { - return excluded; + return excluded == null ? emptyList() : excluded; } String getSalt() { @@ -451,8 +461,9 @@ static final class SegmentRule { this.bucketBy = bucketBy; } + // Guaranteed non-null List getClauses() { - return clauses; + return clauses == null ? emptyList() : clauses; } Integer getWeight() { diff --git a/src/main/java/com/launchdarkly/sdk/server/DataModelDependencies.java b/src/main/java/com/launchdarkly/sdk/server/DataModelDependencies.java new file mode 100644 index 000000000..4f881b007 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/DataModelDependencies.java @@ -0,0 +1,280 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.ImmutableSortedMap; +import com.google.common.collect.Iterables; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.DataModel.Operator; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; + +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import static com.google.common.collect.Iterables.concat; +import static com.google.common.collect.Iterables.isEmpty; +import static com.google.common.collect.Iterables.transform; +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; +import static java.util.Collections.emptyList; +import static java.util.Collections.emptySet; + +/** + * Implements a dependency graph ordering for data to be stored in a data store. + *

    + * We use this to order the data that we pass to {@link com.launchdarkly.sdk.server.interfaces.DataStore#init(FullDataSet)}, + * and also to determine which flags are affected by a change if the application is listening for flag change events. + *

    + * Dependencies are defined as follows: there is a dependency from flag F to flag G if F is a prerequisite flag for + * G, or transitively for any of G's prerequisites; there is a dependency from flag F to segment S if F contains a + * rule with a segmentMatch clause that uses S. Therefore, if G or S is modified or deleted then F may be affected, + * and if we must populate the store non-atomically then G and S should be added before F. + * + * @since 4.6.1 + */ +abstract class DataModelDependencies { + static class KindAndKey { + final DataKind kind; + final String key; + + public KindAndKey(DataKind kind, String key) { + this.kind = kind; + this.key = key; + } + + @Override + public boolean equals(Object other) { + if (other instanceof KindAndKey) { + KindAndKey o = (KindAndKey)other; + return kind == o.kind && key.equals(o.key); + } + return false; + } + + @Override + public int hashCode() { + return kind.hashCode() * 31 + key.hashCode(); + } + } + + /** + * Returns the immediate dependencies from the given item. + * + * @param fromKind the item's kind + * @param fromItem the item descriptor + * @return the flags and/or segments that this item depends on + */ + public static Set computeDependenciesFrom(DataKind fromKind, ItemDescriptor fromItem) { + if (fromItem == null || fromItem.getItem() == null) { + return emptySet(); + } + if (fromKind == FEATURES) { + DataModel.FeatureFlag flag = (DataModel.FeatureFlag)fromItem.getItem(); + + Iterable prereqFlagKeys = transform(flag.getPrerequisites(), p -> p.getKey()); + + Iterable segmentKeys = concat( + transform( + flag.getRules(), + rule -> concat( + Iterables.>transform( + rule.getClauses(), + clause -> clause.getOp() == Operator.segmentMatch ? + transform(clause.getValues(), LDValue::stringValue) : + emptyList() + ) + ) + ) + ); + + return ImmutableSet.copyOf( + concat( + transform(prereqFlagKeys, key -> new KindAndKey(FEATURES, key)), + transform(segmentKeys, key -> new KindAndKey(SEGMENTS, key)) + ) + ); + } + return emptySet(); + } + + /** + * Returns a copy of the input data set that guarantees that if you iterate through it the outer list and + * the inner list in the order provided, any object that depends on another object will be updated after it. + * + * @param allData the unordered data set + * @return a map with a defined ordering + */ + public static FullDataSet sortAllCollections(FullDataSet allData) { + ImmutableSortedMap.Builder> builder = + ImmutableSortedMap.orderedBy(dataKindPriorityOrder); + for (Map.Entry> entry: allData.getData()) { + DataKind kind = entry.getKey(); + builder.put(kind, sortCollection(kind, entry.getValue())); + } + return new FullDataSet<>(builder.build().entrySet()); + } + + private static KeyedItems sortCollection(DataKind kind, KeyedItems input) { + if (!isDependencyOrdered(kind) || isEmpty(input.getItems())) { + return input; + } + + Map remainingItems = new HashMap<>(); + for (Map.Entry e: input.getItems()) { + remainingItems.put(e.getKey(), e.getValue()); + } + 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(kind, entry.getKey(), entry.getValue(), remainingItems, builder); + break; + } + } + + return new KeyedItems<>(builder.build().entrySet()); + } + + private static void addWithDependenciesFirst(DataKind kind, + String key, + ItemDescriptor item, + Map remainingItems, + ImmutableMap.Builder builder) { + remainingItems.remove(key); // we won't need to visit this item again + for (KindAndKey dependency: computeDependenciesFrom(kind, item)) { + if (dependency.kind == kind) { + ItemDescriptor prereqItem = remainingItems.get(dependency.key); + if (prereqItem != null) { + addWithDependenciesFirst(kind, dependency.key, prereqItem, remainingItems, builder); + } + } + } + builder.put(key, item); + } + + private static boolean isDependencyOrdered(DataKind kind) { + return kind == FEATURES; + } + + private static int getPriority(DataKind kind) { + if (kind == FEATURES) { + return 1; + } else if (kind == SEGMENTS) { + return 0; + } else { + return kind.getName().length() + 2; + } + } + + private static Comparator dataKindPriorityOrder = new Comparator() { + @Override + public int compare(DataKind o1, DataKind o2) { + return getPriority(o1) - getPriority(o2); + } + }; + + // General data structure that provides two levels of Map + static final class TwoLevelMap { + private final Map> data = new HashMap<>(); + + public C get(A a, B b) { + Map innerMap = data.get(a); + return innerMap == null ? null : innerMap.get(b); + } + + public void put(A a, B b, C c) { + Map innerMap = data.get(a); + if (innerMap == null) { + innerMap = new HashMap<>(); + data.put(a, innerMap); + } + innerMap.put(b, c); + } + + public void remove(A a, B b) { + Map innerMap = data.get(a); + if (innerMap != null) { + innerMap.remove(b); + } + } + + public void clear() { + data.clear(); + } + } + + /** + * Maintains a bidirectional dependency graph that can be updated whenever an item has changed. + */ + static final class DependencyTracker { + private final Map> dependenciesFrom = new HashMap<>(); + private final Map> dependenciesTo = new HashMap<>(); + + /** + * Updates the dependency graph when an item has changed. + * + * @param fromKind the changed item's kind + * @param fromKey the changed item's key + * @param fromItem the changed item + */ + public void updateDependenciesFrom(DataKind fromKind, String fromKey, ItemDescriptor fromItem) { + KindAndKey fromWhat = new KindAndKey(fromKind, fromKey); + Set updatedDependencies = computeDependenciesFrom(fromKind, fromItem); + + Set oldDependencySet = dependenciesFrom.get(fromWhat); + if (oldDependencySet != null) { + for (KindAndKey oldDep: oldDependencySet) { + Set depsToThisOldDep = dependenciesTo.get(oldDep); + if (depsToThisOldDep != null) { + depsToThisOldDep.remove(fromWhat); + } + } + } + if (updatedDependencies == null) { + dependenciesFrom.remove(fromKind, fromKey); + } else { + dependenciesFrom.put(fromWhat, updatedDependencies); + for (KindAndKey newDep: updatedDependencies) { + Set depsToThisNewDep = dependenciesTo.get(newDep); + if (depsToThisNewDep == null) { + depsToThisNewDep = new HashSet<>(); + dependenciesTo.put(newDep, depsToThisNewDep); + } + depsToThisNewDep.add(fromWhat); + } + } + } + + public void reset() { + dependenciesFrom.clear(); + dependenciesTo.clear(); + } + + /** + * Populates the given set with the union of the initial item and all items that directly or indirectly + * depend on it (based on the current state of the dependency graph). + * + * @param itemsOut an existing set to be updated + * @param initialModifiedItem an item that has been modified + */ + public void addAffectedItems(Set itemsOut, KindAndKey initialModifiedItem) { + if (!itemsOut.contains(initialModifiedItem)) { + itemsOut.add(initialModifiedItem); + Set affectedItems = dependenciesTo.get(initialModifiedItem); + if (affectedItems != null) { + for (KindAndKey affectedItem: affectedItems) { + addAffectedItems(itemsOut, affectedItem); + } + } + } + } + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/DataStoreDataSetSorter.java b/src/main/java/com/launchdarkly/sdk/server/DataStoreDataSetSorter.java deleted file mode 100644 index 3638e437f..000000000 --- a/src/main/java/com/launchdarkly/sdk/server/DataStoreDataSetSorter.java +++ /dev/null @@ -1,116 +0,0 @@ -package com.launchdarkly.sdk.server; - -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableMap; -import com.google.common.collect.ImmutableSortedMap; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; - -import java.util.Comparator; -import java.util.HashMap; -import java.util.Map; - -import static com.google.common.collect.Iterables.isEmpty; -import static com.google.common.collect.Iterables.transform; -import static com.launchdarkly.sdk.server.DataModel.FEATURES; -import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; - -/** - * Implements a dependency graph ordering for data to be stored in a data store. We must use this - * on every data set that will be passed to {@link com.launchdarkly.sdk.server.interfaces.DataStore#init(Map)}. - * - * @since 4.6.1 - */ -abstract class DataStoreDataSetSorter { - /** - * Returns a copy of the input data set that guarantees that if you iterate through it the outer list and - * the inner list in the order provided, any object that depends on another object will be updated after it. - * - * @param allData the unordered data set - * @return a map with a defined ordering - */ - public static FullDataSet sortAllCollections(FullDataSet allData) { - ImmutableSortedMap.Builder> builder = - ImmutableSortedMap.orderedBy(dataKindPriorityOrder); - for (Map.Entry> entry: allData.getData()) { - DataKind kind = entry.getKey(); - builder.put(kind, sortCollection(kind, entry.getValue())); - } - return new FullDataSet<>(builder.build().entrySet()); - } - - private static KeyedItems sortCollection(DataKind kind, KeyedItems input) { - if (!isDependencyOrdered(kind) || isEmpty(input.getItems())) { - return input; - } - - Map remainingItems = new HashMap<>(); - for (Map.Entry e: input.getItems()) { - remainingItems.put(e.getKey(), e.getValue()); - } - 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(kind, entry.getKey(), entry.getValue(), remainingItems, builder); - break; - } - } - - return new KeyedItems<>(builder.build().entrySet()); - } - - private static void addWithDependenciesFirst(DataKind kind, - String key, - ItemDescriptor item, - Map remainingItems, - ImmutableMap.Builder builder) { - remainingItems.remove(key); // we won't need to visit this item again - for (String prereqKey: getDependencyKeys(kind, item.getItem())) { - ItemDescriptor prereqItem = remainingItems.get(prereqKey); - if (prereqItem != null) { - addWithDependenciesFirst(kind, prereqKey, prereqItem, remainingItems, builder); - } - } - builder.put(key, item); - } - - private static boolean isDependencyOrdered(DataKind kind) { - return kind == FEATURES; - } - - private static Iterable getDependencyKeys(DataKind kind, Object item) { - if (item == null) { - return null; - } - if (kind == FEATURES) { - DataModel.FeatureFlag flag = (DataModel.FeatureFlag)item; - if (flag.getPrerequisites() == null || flag.getPrerequisites().isEmpty()) { - return ImmutableList.of(); - } - return transform(flag.getPrerequisites(), p -> p.getKey()); - } - return null; - } - - private static int getPriority(DataKind kind) { - if (kind == FEATURES) { - return 1; - } else if (kind == SEGMENTS) { - return 0; - } else { - return kind.getName().length() + 2; - } - } - - private static Comparator dataKindPriorityOrder = new Comparator() { - @Override - public int compare(DataKind o1, DataKind o2) { - return getPriority(o1) - getPriority(o2); - } - }; -} diff --git a/src/main/java/com/launchdarkly/sdk/server/DataStoreUpdatesImpl.java b/src/main/java/com/launchdarkly/sdk/server/DataStoreUpdatesImpl.java index 754ef6ecc..74fcd3ef0 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DataStoreUpdatesImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/DataStoreUpdatesImpl.java @@ -1,33 +1,141 @@ package com.launchdarkly.sdk.server; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.launchdarkly.sdk.server.DataModelDependencies.KindAndKey; import com.launchdarkly.sdk.server.interfaces.DataStore; -import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; +import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; +import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import static com.google.common.collect.Iterables.concat; +import static com.launchdarkly.sdk.server.DataModel.ALL_DATA_KINDS; +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static java.util.Collections.emptyMap; /** * The data source will push updates into this component. We then apply any necessary * transformations before putting them into the data store; currently that just means sorting - * the data set for init(). In the future we may also use this to provide an update listener - * capability. + * the data set for init(). We also generate flag change events for any updates or deletions. * * @since 4.11.0 */ final class DataStoreUpdatesImpl implements DataStoreUpdates { private final DataStore store; + private final FlagChangeEventPublisher flagChangeEventPublisher; + private final DataModelDependencies.DependencyTracker dependencyTracker = new DataModelDependencies.DependencyTracker(); - DataStoreUpdatesImpl(DataStore store) { + DataStoreUpdatesImpl(DataStore store, FlagChangeEventPublisher flagChangeEventPublisher) { this.store = store; + this.flagChangeEventPublisher = flagChangeEventPublisher; } @Override public void init(FullDataSet allData) { - store.init(DataStoreDataSetSorter.sortAllCollections(allData)); + Map> oldData = null; + + if (hasFlagChangeEventListeners()) { + // Query the existing data if any, so that after the update we can send events for whatever was changed + oldData = new HashMap<>(); + for (DataKind kind: ALL_DATA_KINDS) { + KeyedItems items = store.getAll(kind); + oldData.put(kind, ImmutableMap.copyOf(items.getItems())); + } + } + + store.init(DataModelDependencies.sortAllCollections(allData)); + + // We must always update the dependency graph even if we don't currently have any event listeners, because if + // listeners are added later, we don't want to have to reread the whole data store to compute the graph + updateDependencyTrackerFromFullDataSet(allData); + + // Now, if we previously queried the old data because someone is listening for flag change events, compare + // the versions of all items and generate events for those (and any other items that depend on them) + if (oldData != null) { + sendChangeEvents(computeChangedItemsForFullDataSet(oldData, fullDataSetToMap(allData))); + } } @Override public void upsert(DataKind kind, String key, ItemDescriptor item) { - store.upsert(kind, key, item); + boolean successfullyUpdated = store.upsert(kind, key, item); + + if (successfullyUpdated) { + dependencyTracker.updateDependenciesFrom(kind, key, item); + if (hasFlagChangeEventListeners()) { + Set affectedItems = new HashSet<>(); + dependencyTracker.addAffectedItems(affectedItems, new KindAndKey(kind, key)); + sendChangeEvents(affectedItems); + } + } + } + + private boolean hasFlagChangeEventListeners() { + return flagChangeEventPublisher != null && flagChangeEventPublisher.hasListeners(); + } + + private void sendChangeEvents(Iterable affectedItems) { + if (flagChangeEventPublisher == null) { + return; + } + for (KindAndKey item: affectedItems) { + if (item.kind == FEATURES) { + flagChangeEventPublisher.publishEvent(new FlagChangeEvent(item.key)); + } + } + } + + private void updateDependencyTrackerFromFullDataSet(FullDataSet allData) { + dependencyTracker.reset(); + for (Map.Entry> e0: allData.getData()) { + DataKind kind = e0.getKey(); + for (Map.Entry e1: e0.getValue().getItems()) { + String key = e1.getKey(); + dependencyTracker.updateDependenciesFrom(kind, key, e1.getValue()); + } + } + } + + private Map> fullDataSetToMap(FullDataSet allData) { + Map> ret = new HashMap<>(); + for (Map.Entry> e: allData.getData()) { + ret.put(e.getKey(), ImmutableMap.copyOf(e.getValue().getItems())); + } + return ret; + } + + private Set computeChangedItemsForFullDataSet(Map> oldDataMap, + Map> newDataMap) { + Set affectedItems = new HashSet<>(); + for (DataKind kind: ALL_DATA_KINDS) { + Map oldItems = oldDataMap.get(kind); + Map newItems = newDataMap.get(kind); + if (oldItems == null) { + oldItems = emptyMap(); + } + if (newItems == null) { + newItems = emptyMap(); + } + Set allKeys = ImmutableSet.copyOf(concat(oldItems.keySet(), newItems.keySet())); + for (String key: allKeys) { + ItemDescriptor oldItem = oldItems.get(key); + ItemDescriptor newItem = newItems.get(key); + if (oldItem == null && newItem == null) { // shouldn't be possible due to how we computed allKeys + continue; + } + if (oldItem == null || newItem == null || oldItem.getVersion() < newItem.getVersion()) { + dependencyTracker.addAffectedItems(affectedItems, new KindAndKey(kind, key)); + } + } + } + return affectedItems; } -} \ No newline at end of file +} diff --git a/src/main/java/com/launchdarkly/sdk/server/Evaluator.java b/src/main/java/com/launchdarkly/sdk/server/Evaluator.java index c43cf3fa2..7ab85c643 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Evaluator.java +++ b/src/main/java/com/launchdarkly/sdk/server/Evaluator.java @@ -118,8 +118,8 @@ EvalResult evaluate(DataModel.FeatureFlag flag, LDUser user, EventFactory eventF // If the flag doesn't have any prerequisites (which most flags don't) then it cannot generate any feature // request events for prerequisites and we can skip allocating a List. - List prerequisiteEvents = (flag.getPrerequisites() == null || flag.getPrerequisites().isEmpty()) ? - null : new ArrayList(); + List prerequisiteEvents = flag.getPrerequisites().isEmpty() ? + null : new ArrayList(); // note, getPrerequisites() is guaranteed non-null EvalResult result = evaluateInternal(flag, user, eventFactory, prerequisiteEvents); if (prerequisiteEvents != null) { result.setPrerequisiteEvents(prerequisiteEvents); @@ -139,26 +139,21 @@ private EvalResult evaluateInternal(DataModel.FeatureFlag flag, LDUser user, Eve } // Check to see if targets match - List targets = flag.getTargets(); - if (targets != null) { - for (DataModel.Target target: targets) { - for (String v : target.getValues()) { - if (v.equals(user.getKey())) { - return getVariation(flag, target.getVariation(), EvaluationReason.targetMatch()); - } + for (DataModel.Target target: flag.getTargets()) { // getTargets() and getValues() are guaranteed non-null + for (String v : target.getValues()) { + if (v.equals(user.getKey())) { + return getVariation(flag, target.getVariation(), EvaluationReason.targetMatch()); } } } // Now walk through the rules and see if any match - List rules = flag.getRules(); - if (rules != null) { - for (int i = 0; i < rules.size(); i++) { - DataModel.Rule rule = rules.get(i); - if (ruleMatchesUser(flag, rule, user)) { - EvaluationReason.RuleMatch precomputedReason = rule.getRuleMatchReason(); - EvaluationReason.RuleMatch reason = precomputedReason != null ? precomputedReason : EvaluationReason.ruleMatch(i, rule.getId()); - return getValueForVariationOrRollout(flag, rule, user, reason); - } + List rules = flag.getRules(); // guaranteed non-null + for (int i = 0; i < rules.size(); i++) { + DataModel.Rule rule = rules.get(i); + if (ruleMatchesUser(flag, rule, user)) { + EvaluationReason.RuleMatch precomputedReason = rule.getRuleMatchReason(); + EvaluationReason.RuleMatch reason = precomputedReason != null ? precomputedReason : EvaluationReason.ruleMatch(i, rule.getId()); + return getValueForVariationOrRollout(flag, rule, user, reason); } } // Walk through the fallthrough and see if it matches @@ -169,11 +164,7 @@ private EvalResult evaluateInternal(DataModel.FeatureFlag flag, LDUser user, Eve // short-circuit due to a prerequisite failure. private EvaluationReason checkPrerequisites(DataModel.FeatureFlag flag, LDUser user, EventFactory eventFactory, List eventsOut) { - List prerequisites = flag.getPrerequisites(); - if (prerequisites == null) { - return null; - } - for (DataModel.Prerequisite prereq: prerequisites) { + for (DataModel.Prerequisite prereq: flag.getPrerequisites()) { // getPrerequisites() is guaranteed non-null boolean prereqOk = true; DataModel.FeatureFlag prereqFeatureFlag = getters.getFlag(prereq.getKey()); if (prereqFeatureFlag == null) { @@ -228,12 +219,9 @@ private EvalResult getValueForVariationOrRollout(DataModel.FeatureFlag flag, Dat } private boolean ruleMatchesUser(DataModel.FeatureFlag flag, DataModel.Rule rule, LDUser user) { - Iterable clauses = rule.getClauses(); - if (clauses != null) { - for (DataModel.Clause clause: clauses) { - if (!clauseMatchesUser(clause, user)) { - return false; - } + for (DataModel.Clause clause: rule.getClauses()) { // getClauses() is guaranteed non-null + if (!clauseMatchesUser(clause, user)) { + return false; } } return true; @@ -305,7 +293,7 @@ private boolean segmentMatchesUser(DataModel.Segment segment, LDUser user) { if (userKey == null) { return false; } - if (Iterables.contains(segment.getIncluded(), userKey)) { + if (Iterables.contains(segment.getIncluded(), userKey)) { // getIncluded(), getExcluded(), and getRules() are guaranteed non-null return true; } if (Iterables.contains(segment.getExcluded(), userKey)) { diff --git a/src/main/java/com/launchdarkly/sdk/server/FlagChangeEventPublisher.java b/src/main/java/com/launchdarkly/sdk/server/FlagChangeEventPublisher.java new file mode 100644 index 000000000..56b8789b3 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/FlagChangeEventPublisher.java @@ -0,0 +1,85 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.util.concurrent.ThreadFactoryBuilder; +import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; +import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; + +import java.io.Closeable; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +final class FlagChangeEventPublisher implements Closeable { + private final List listeners = new ArrayList<>(); + private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); + private ExecutorService executor; + + public FlagChangeEventPublisher() { + + } + + public void register(FlagChangeListener listener) { + lock.writeLock().lock(); + try { + listeners.add(listener); + if (executor == null) { + executor = createExecutorService(); + } + } finally { + lock.writeLock().unlock(); + } + } + + public void unregister(FlagChangeListener listener) { + lock.writeLock().lock(); + try { + listeners.remove(listener); + } finally { + lock.writeLock().unlock(); + } + } + + public boolean hasListeners() { + lock.readLock().lock(); + try { + return !listeners.isEmpty(); + } finally { + lock.readLock().unlock(); + } + } + + public void publishEvent(FlagChangeEvent event) { + FlagChangeListener[] ll; + lock.readLock().lock(); + try { + ll = listeners.toArray(new FlagChangeListener[listeners.size()]); + } finally { + lock.readLock().unlock(); + } + for (FlagChangeListener l: ll) { + executor.execute(() -> { + l.onFlagChange(event); + }); + } + } + + @Override + public void close() throws IOException { + if (executor != null) { + executor.shutdown(); + } + } + + private ExecutorService createExecutorService() { + ThreadFactory threadFactory = new ThreadFactoryBuilder() + .setDaemon(true) + .setNameFormat("LaunchDarkly-FlagChangeEventPublisher-%d") + .setPriority(Thread.MIN_PRIORITY) + .build(); + return Executors.newCachedThreadPool(threadFactory); + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClient.java b/src/main/java/com/launchdarkly/sdk/server/LDClient.java index 5fd4a14be..535133cbc 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClient.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClient.java @@ -15,6 +15,8 @@ import com.launchdarkly.sdk.server.interfaces.Event; import com.launchdarkly.sdk.server.interfaces.EventProcessor; import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; +import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; +import com.launchdarkly.sdk.server.interfaces.FlagChangeListenerRegistration; import org.apache.commons.codec.binary.Hex; import org.slf4j.Logger; @@ -53,6 +55,7 @@ public final class LDClient implements LDClientInterface { private final LDConfig config; private final String sdkKey; private final Evaluator evaluator; + private final FlagChangeEventPublisher flagChangeEventPublisher; final EventProcessor eventProcessor; final DataSource dataSource; final DataStore dataStore; @@ -113,9 +116,11 @@ public DataModel.Segment getSegment(String key) { } }); + this.flagChangeEventPublisher = new FlagChangeEventPublisher(); + DataSourceFactory dataSourceFactory = this.config.dataSourceFactory == null ? Components.streamingDataSource() : this.config.dataSourceFactory; - DataStoreUpdates dataStoreUpdates = new DataStoreUpdatesImpl(dataStore); + DataStoreUpdates dataStoreUpdates = new DataStoreUpdatesImpl(dataStore, flagChangeEventPublisher); this.dataSource = dataSourceFactory.createDataSource(context, dataStoreUpdates); Future startFuture = dataSource.start(); @@ -376,12 +381,29 @@ private Evaluator.EvalResult evaluateInternal(String featureKey, LDUser user, LD } } + @Override + public void registerFlagChangeListener(FlagChangeListener listener) { + if (listener instanceof FlagChangeListenerRegistration) { + ((FlagChangeListenerRegistration)listener).onRegister(this); + } + flagChangeEventPublisher.register(listener); + } + + @Override + public void unregisterFlagChangeListener(FlagChangeListener listener) { + flagChangeEventPublisher.unregister(listener); + if (listener instanceof FlagChangeListenerRegistration) { + ((FlagChangeListenerRegistration)listener).onUnregister(this); + } + } + @Override public void close() throws IOException { logger.info("Closing LaunchDarkly Client"); this.dataStore.close(); this.eventProcessor.close(); this.dataSource.close(); + this.flagChangeEventPublisher.close(); } @Override diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClientInterface.java b/src/main/java/com/launchdarkly/sdk/server/LDClientInterface.java index 5d6be9ff7..c2a3f3b02 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClientInterface.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClientInterface.java @@ -3,6 +3,7 @@ import com.launchdarkly.sdk.EvaluationDetail; import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; import java.io.Closeable; import java.io.IOException; @@ -223,6 +224,41 @@ public interface LDClientInterface extends Closeable { */ boolean isOffline(); + /** + * Registers a listener to be notified of feature flag changes. + *

    + * The listener will be notified whenever the SDK receives any change to any feature flag's configuration, + * or to a user segment that is referenced by a feature flag. If the updated flag is used as a prerequisite + * for other flags, the SDK assumes that those flags may now behave differently and sends events for them + * as well. + *

    + * Note that this does not necessarily mean the flag's value has changed for any particular user, only that + * some part of the flag configuration was changed so that it may return a different value than it + * previously returned for some user. + *

    + * The listener will be called from a worker thread. + *

    + * Calling this method for an already-registered listener has no effect. + * + * @param listener the event listener to register + * @see #unregisterFlagChangeListener(FlagChangeListener) + * @see FlagChangeListener + * @since 5.0.0 + */ + void registerFlagChangeListener(FlagChangeListener listener); + + /** + * Unregisters a listener so that it will no longer be notified of feature flag changes. + *

    + * Calling this method for a listener that was not previously registered has no effect. + * + * @param listener the event listener to unregister + * @see #registerFlagChangeListener(FlagChangeListener) + * @see FlagChangeListener + * @since 5.0.0 + */ + void unregisterFlagChangeListener(FlagChangeListener listener); + /** * For more info: https://github.com/launchdarkly/js-client#secure-mode * @param user the user to be hashed along with the SDK key diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagChangeEvent.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagChangeEvent.java new file mode 100644 index 000000000..b72a49c4f --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagChangeEvent.java @@ -0,0 +1,38 @@ +package com.launchdarkly.sdk.server.interfaces; + +/** + * Parameter class used with {@link FlagChangeListener}. + *

    + * This is not an analytics event to be sent to LaunchDarkly; it is a notification to the application. + * + * @since 5.0.0 + * @see FlagChangeListener + * @see FlagValueChangeEvent + * @see com.launchdarkly.sdk.server.LDClientInterface#registerFlagChangeListener(FlagChangeListener) + * @see com.launchdarkly.sdk.server.LDClientInterface#unregisterFlagChangeListener(FlagChangeListener) + */ +public class FlagChangeEvent { + private final String key; + + /** + * Constructs a new instance. + * + * @param key the feature flag key + */ + public FlagChangeEvent(String key) { + this.key = key; + } + + /** + * Returns the key of the feature flag whose configuration has changed. + *

    + * The specified flag may have been modified directly, or this may be an indirect change due to a change + * in some other flag that is a prerequisite for this flag, or a user segment that is referenced in the + * flag's rules. + * + * @return the flag key + */ + public String getKey() { + return key; + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagChangeListener.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagChangeListener.java new file mode 100644 index 000000000..42f8093cd --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagChangeListener.java @@ -0,0 +1,29 @@ +package com.launchdarkly.sdk.server.interfaces; + +/** + * An event listener that is notified when a feature flag's configuration has changed. + *

    + * As described in {@link com.launchdarkly.sdk.server.LDClientInterface#registerFlagChangeListener(FlagChangeListener)}, + * this notification does not mean that the flag now returns a different value for any particular user, + * only that it may do so. LaunchDarkly feature flags can be configured to return a single value + * for all users, or to have complex targeting behavior. To know what effect the change would have for + * any given set of user properties, you would need to re-evaluate the flag by calling one of the + * {@code variation} methods on the client. + *

    + * In simple use cases where you know that the flag configuration does not vary per user, or where you + * know ahead of time what user properties you will evaluate the flag with, it may be more convenient + * to use {@link FlagValueChangeListener}. + * + * @since 5.0.0 + * @see FlagValueChangeListener + * @see com.launchdarkly.sdk.server.LDClientInterface#registerFlagChangeListener(FlagChangeListener) + * @see com.launchdarkly.sdk.server.LDClientInterface#unregisterFlagChangeListener(FlagChangeListener) + */ +public interface FlagChangeListener { + /** + * The SDK calls this method when a feature flag's configuration has changed in some way. + * + * @param event the event parameters + */ + void onFlagChange(FlagChangeEvent event); +} diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagChangeListenerRegistration.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagChangeListenerRegistration.java new file mode 100644 index 000000000..74f792295 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagChangeListenerRegistration.java @@ -0,0 +1,33 @@ +package com.launchdarkly.sdk.server.interfaces; + +import com.launchdarkly.sdk.server.LDClientInterface; + +/** + * This interface is implemented by any {@link FlagChangeListener} that needs to know when it has been + * registered or unregistered with a client instance. + * + * @see LDClientInterface#registerFlagChangeListener(FlagChangeListener) + * @see LDClientInterface#unregisterFlagChangeListener(FlagChangeListener) + * @since 5.0.0 + */ +public interface FlagChangeListenerRegistration { + /** + * The SDK calls this method when {@link LDClientInterface#registerFlagChangeListener(FlagChangeListener)} + * has been called for this listener. + *

    + * The listener is not yet registered at this point so it cannot have received any events. + * + * @param client the client instance that is registering the listener + */ + void onRegister(LDClientInterface client); + + /** + * The SDK calls this method when {@link LDClientInterface#unregisterFlagChangeListener(FlagChangeListener)} + * has been called for this listener. + *

    + * The listener is already unregistered at this point so it will not receive any more events. + * + * @param client the client instance that has unregistered the listener + */ + void onUnregister(LDClientInterface client); +} diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeEvent.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeEvent.java new file mode 100644 index 000000000..3b75600cc --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeEvent.java @@ -0,0 +1,64 @@ +package com.launchdarkly.sdk.server.interfaces; + +import com.launchdarkly.sdk.LDValue; + +/** + * Parameter class used with {@link FlagValueChangeListener}. + *

    + * This is not an analytics event to be sent to LaunchDarkly; it is a notification to the application. + * + * @since 5.0.0 + * @see FlagValueChangeListener + * @see com.launchdarkly.sdk.server.LDClientInterface#registerFlagChangeListener(FlagChangeListener) + * @see com.launchdarkly.sdk.server.LDClientInterface#unregisterFlagChangeListener(FlagChangeListener) + * @see com.launchdarkly.sdk.server.Components#flagValueMonitoringListener(String, com.launchdarkly.sdk.LDUser, FlagValueChangeListener) + */ +public class FlagValueChangeEvent extends FlagChangeEvent { + private final LDValue oldValue; + private final LDValue newValue; + + /** + * Constructs a new instance. + * + * @param key the feature flag key + * @param oldValue the previous flag value + * @param newValue the new flag value + */ + public FlagValueChangeEvent(String key, LDValue oldValue, LDValue newValue) { + super(key); + this.oldValue = LDValue.normalize(oldValue); + this.newValue = LDValue.normalize(newValue); + } + + /** + * Returns the last known value of the flag for the specified user prior to the update. + *

    + * Since flag values can be of any JSON data type, this is represented as {@link LDValue}. That class + * has methods for converting to a primitive Java type such {@link LDValue#booleanValue()}. + *

    + * If the flag did not exist before or could not be evaluated, this will be {@link LDValue#ofNull()}. + * Note that there is no application default value parameter as there is for the {@code variation} + * methods; it is up to your code to substitute whatever fallback value is appropriate. + * + * @return the previous flag value + */ + public LDValue getOldValue() { + return oldValue; + } + + /** + * Returns the new value of the flag for the specified user. + *

    + * Since flag values can be of any JSON data type, this is represented as {@link LDValue}. That class + * has methods for converting to a primitive Java type such {@link LDValue#booleanValue()}. + *

    + * If the flag was deleted or could not be evaluated, this will be {@link LDValue#ofNull()}. + * Note that there is no application default value parameter as there is for the {@code variation} + * methods; it is up to your code to substitute whatever fallback value is appropriate. + * + * @return the new flag value + */ + public LDValue getNewValue() { + return newValue; + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeListener.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeListener.java new file mode 100644 index 000000000..a2eb446e6 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeListener.java @@ -0,0 +1,42 @@ +package com.launchdarkly.sdk.server.interfaces; + +/** + * An event listener that is notified when a feature flag's value has changed for a specific user. + *

    + * Use this in conjunction with + * {@link com.launchdarkly.sdk.server.Components#flagValueMonitoringListener(String, com.launchdarkly.sdk.LDUser, FlagValueChangeListener)} + * if you want the client to re-evaluate a flag for a specific set of user properties whenever + * the flag's configuration has changed, and notify you only if the new value is different from the old + * value. The listener will not be notified if the flag's configuration is changed in some way that does + * not affect its value for that user. + * + *

    
    + *     String flagKey = "my-important-flag";
    + *     LDUser userForFlagEvaluation = new LDUser("user-key-for-global-flag-state");
    + *     FlagValueChangeListener listenForNewValue = event -> {
    + *         if (event.getKey().equals(flagKey)) {
    + *             doSomethingWithNewValue(event.getNewValue().booleanValue());
    + *         }
    + *     };
    + *     client.registerFlagChangeListener(Components.flagValueMonitoringListener(
    + *         flagKey, userForFlagEvaluation, listenForNewValue));
    + * 
    + * + * In the above example, the value provided in {@code event.getNewValue()} is the result of calling + * {@code client.jsonValueVariation(flagKey, userForFlagEvaluation, LDValue.ofNull())} after the flag + * has changed. + * + * @since 5.0.0 + * @see FlagChangeListener + * @see com.launchdarkly.sdk.server.LDClientInterface#registerFlagChangeListener(FlagChangeListener) + * @see com.launchdarkly.sdk.server.LDClientInterface#unregisterFlagChangeListener(FlagChangeListener) + * @see com.launchdarkly.sdk.server.Components#flagValueMonitoringListener(String, com.launchdarkly.sdk.LDUser, FlagValueChangeListener) + */ +public interface FlagValueChangeListener { + /** + * The SDK calls this method when a feature flag's value has changed with regard to the specified user. + * + * @param event the event parameters + */ + void onFlagValueChange(FlagValueChangeEvent event); +} diff --git a/src/test/java/com/launchdarkly/sdk/server/DataStoreTestTypes.java b/src/test/java/com/launchdarkly/sdk/server/DataStoreTestTypes.java index dd68112aa..6bfe82bd4 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataStoreTestTypes.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataStoreTestTypes.java @@ -148,6 +148,13 @@ public DataBuilder addAny(DataKind kind, VersionedData... items) { return this; } + public DataBuilder remove(DataKind kind, String key) { + if (data.get(kind) != null) { + data.get(kind).remove(key); + } + return this; + } + public FullDataSet build() { return new FullDataSet<>( ImmutableMap.copyOf( diff --git a/src/test/java/com/launchdarkly/sdk/server/DataStoreUpdatesImplTest.java b/src/test/java/com/launchdarkly/sdk/server/DataStoreUpdatesImplTest.java new file mode 100644 index 000000000..16015a967 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/DataStoreUpdatesImplTest.java @@ -0,0 +1,373 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.base.Joiner; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Iterables; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.DataModel.Operator; +import com.launchdarkly.sdk.server.DataStoreTestTypes.DataBuilder; +import com.launchdarkly.sdk.server.TestUtil.FlagChangeEventSink; +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; + +import org.easymock.Capture; +import org.easymock.EasyMock; +import org.easymock.EasyMockSupport; +import org.junit.Test; + +import java.util.List; +import java.util.Map; + +import static com.google.common.collect.Iterables.transform; +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; +import static com.launchdarkly.sdk.server.DataStoreTestTypes.toDataMap; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.prerequisite; +import static com.launchdarkly.sdk.server.ModelBuilders.ruleBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.segmentBuilder; +import static com.launchdarkly.sdk.server.TestUtil.inMemoryDataStore; +import static org.easymock.EasyMock.replay; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +@SuppressWarnings("javadoc") +public class DataStoreUpdatesImplTest extends EasyMockSupport { + // Note that these tests must use the actual data model types for flags and segments, rather than the + // TestItem type from DataStoreTestTypes, because the dependency behavior is based on the real data model. + + @Test + public void doesNotTryToSendEventsIfThereIsNoEventPublisher() { + DataStore store = inMemoryDataStore(); + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, null); + storeUpdates.upsert(DataModel.FEATURES, "key", new ItemDescriptor(1, flagBuilder("key").build())); + // the test is just that this doesn't cause an exception + } + + @Test + public void sendsEventsOnInitForNewlyAddedFlags() throws Exception { + DataStore store = inMemoryDataStore(); + DataBuilder builder = new DataBuilder() + .addAny(FEATURES, + flagBuilder("flag1").version(1).build()) + .addAny(SEGMENTS, + segmentBuilder("segment1").version(1).build()); + + try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); + + storeUpdates.init(builder.build()); + + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + publisher.register(eventSink); + + builder.addAny(FEATURES, flagBuilder("flag2").version(1).build()) + .addAny(SEGMENTS, segmentBuilder("segment2").version(1).build()); + // the new segment triggers no events since nothing is using it + + storeUpdates.init(builder.build()); + + eventSink.expectEvents("flag2"); + } + } + + @Test + public void sendsEventOnUpdateForNewlyAddedFlag() throws Exception { + DataStore store = inMemoryDataStore(); + DataBuilder builder = new DataBuilder() + .addAny(FEATURES, + flagBuilder("flag1").version(1).build()) + .addAny(SEGMENTS, + segmentBuilder("segment1").version(1).build()); + + try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); + + storeUpdates.init(builder.build()); + + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + publisher.register(eventSink); + + storeUpdates.upsert(FEATURES, "flag2", new ItemDescriptor(1, flagBuilder("flag2").version(1).build())); + + eventSink.expectEvents("flag2"); + } + } + + @Test + public void sendsEventsOnInitForUpdatedFlags() throws Exception { + DataStore store = inMemoryDataStore(); + DataBuilder builder = new DataBuilder() + .addAny(FEATURES, + flagBuilder("flag1").version(1).build(), + flagBuilder("flag2").version(1).build()) + .addAny(SEGMENTS, + segmentBuilder("segment1").version(1).build(), + segmentBuilder("segment2").version(1).build()); + + try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); + + storeUpdates.init(builder.build()); + + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + publisher.register(eventSink); + + builder.addAny(FEATURES, flagBuilder("flag2").version(2).build()) // modified flag + .addAny(SEGMENTS, segmentBuilder("segment2").version(2).build()); // modified segment, but it's irrelevant + storeUpdates.init(builder.build()); + + eventSink.expectEvents("flag2"); + } + } + + @Test + public void sendsEventOnUpdateForUpdatedFlag() throws Exception { + DataStore store = inMemoryDataStore(); + DataBuilder builder = new DataBuilder() + .addAny(FEATURES, + flagBuilder("flag1").version(1).build(), + flagBuilder("flag2").version(1).build()) + .addAny(SEGMENTS, + segmentBuilder("segment1").version(1).build()); + + try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); + + storeUpdates.init(builder.build()); + + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + publisher.register(eventSink); + + storeUpdates.upsert(FEATURES, "flag2", new ItemDescriptor(2, flagBuilder("flag2").version(2).build())); + + eventSink.expectEvents("flag2"); + } + } + + @Test + public void sendsEventsOnInitForDeletedFlags() throws Exception { + DataStore store = inMemoryDataStore(); + DataBuilder builder = new DataBuilder() + .addAny(FEATURES, + flagBuilder("flag1").version(1).build(), + flagBuilder("flag2").version(1).build()) + .addAny(SEGMENTS, + segmentBuilder("segment1").version(1).build()); + + try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); + + storeUpdates.init(builder.build()); + + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + publisher.register(eventSink); + + builder.remove(FEATURES, "flag2"); + builder.remove(SEGMENTS, "segment1"); // deleted segment isn't being used so it's irrelevant + // note that the full data set for init() will never include deleted item placeholders + + storeUpdates.init(builder.build()); + + eventSink.expectEvents("flag2"); + } + } + + @Test + public void sendsEventOnUpdateForDeletedFlag() throws Exception { + DataStore store = inMemoryDataStore(); + DataBuilder builder = new DataBuilder() + .addAny(FEATURES, + flagBuilder("flag1").version(1).build(), + flagBuilder("flag2").version(1).build()) + .addAny(SEGMENTS, + segmentBuilder("segment1").version(1).build()); + + try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); + + storeUpdates.init(builder.build()); + + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + publisher.register(eventSink); + + storeUpdates.upsert(FEATURES, "flag2", ItemDescriptor.deletedItem(2)); + + eventSink.expectEvents("flag2"); + } + } + + @Test + public void sendsEventsOnInitForFlagsWhosePrerequisitesChanged() throws Exception { + DataStore store = inMemoryDataStore(); + DataBuilder builder = new DataBuilder() + .addAny(FEATURES, + flagBuilder("flag1").version(1).build(), + flagBuilder("flag2").version(1).prerequisites(prerequisite("flag1", 0)).build(), + flagBuilder("flag3").version(1).build(), + flagBuilder("flag4").version(1).prerequisites(prerequisite("flag1", 0)).build(), + flagBuilder("flag5").version(1).prerequisites(prerequisite("flag4", 0)).build(), + flagBuilder("flag6").version(1).build()); + + try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); + + storeUpdates.init(builder.build()); + + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + publisher.register(eventSink); + + builder.addAny(FEATURES, flagBuilder("flag1").version(2).build()); + storeUpdates.init(builder.build()); + + eventSink.expectEvents("flag1", "flag2", "flag4", "flag5"); + } + } + + @Test + public void sendsEventsOnUpdateForFlagsWhosePrerequisitesChanged() throws Exception { + DataStore store = inMemoryDataStore(); + DataBuilder builder = new DataBuilder() + .addAny(FEATURES, + flagBuilder("flag1").version(1).build(), + flagBuilder("flag2").version(1).prerequisites(prerequisite("flag1", 0)).build(), + flagBuilder("flag3").version(1).build(), + flagBuilder("flag4").version(1).prerequisites(prerequisite("flag1", 0)).build(), + flagBuilder("flag5").version(1).prerequisites(prerequisite("flag4", 0)).build(), + flagBuilder("flag6").version(1).build()); + + try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); + + storeUpdates.init(builder.build()); + + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + publisher.register(eventSink); + + storeUpdates.upsert(FEATURES, "flag1", new ItemDescriptor(2, flagBuilder("flag1").version(2).build())); + + eventSink.expectEvents("flag1", "flag2", "flag4", "flag5"); + } + } + + @Test + public void sendsEventsOnInitForFlagsWhoseSegmentsChanged() throws Exception { + DataStore store = inMemoryDataStore(); + DataBuilder builder = new DataBuilder() + .addAny(FEATURES, + flagBuilder("flag1").version(1).build(), + flagBuilder("flag2").version(1).rules( + ruleBuilder().clauses( + ModelBuilders.clause(null, Operator.segmentMatch, LDValue.of("segment1")) + ).build() + ).build(), + flagBuilder("flag3").version(1).build(), + flagBuilder("flag4").version(1).prerequisites(prerequisite("flag2", 0)).build()) + .addAny(SEGMENTS, + segmentBuilder("segment1").version(1).build(), + segmentBuilder("segment2").version(1).build()); + + try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); + + storeUpdates.init(builder.build()); + + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + publisher.register(eventSink); + + storeUpdates.upsert(SEGMENTS, "segment1", new ItemDescriptor(2, segmentBuilder("segment1").version(2).build())); + + eventSink.expectEvents("flag2", "flag4"); + } + } + + @Test + public void sendsEventsOnUpdateForFlagsWhoseSegmentsChanged() throws Exception { + DataStore store = inMemoryDataStore(); + DataBuilder builder = new DataBuilder() + .addAny(FEATURES, + flagBuilder("flag1").version(1).build(), + flagBuilder("flag2").version(1).rules( + ruleBuilder().clauses( + ModelBuilders.clause(null, Operator.segmentMatch, LDValue.of("segment1")) + ).build() + ).build(), + flagBuilder("flag3").version(1).build(), + flagBuilder("flag4").version(1).prerequisites(prerequisite("flag2", 0)).build()) + .addAny(SEGMENTS, + segmentBuilder("segment1").version(1).build(), + segmentBuilder("segment2").version(1).build()); + + try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); + + storeUpdates.init(builder.build()); + + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + publisher.register(eventSink); + + builder.addAny(SEGMENTS, segmentBuilder("segment1").version(2).build()); + storeUpdates.init(builder.build()); + + eventSink.expectEvents("flag2", "flag4"); + } + } + + @Test + public void dataSetIsPassedToDataStoreInCorrectOrder() throws Exception { + // This verifies that the client is using DataStoreClientWrapper and that it is applying the + // correct ordering for flag prerequisites, etc. This should work regardless of what kind of + // DataSource we're using. + + Capture> captureData = Capture.newInstance(); + DataStore store = createStrictMock(DataStore.class); + store.init(EasyMock.capture(captureData)); + replay(store); + + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, null); + storeUpdates.init(DEPENDENCY_ORDERING_TEST_DATA); + + Map> dataMap = toDataMap(captureData.getValue()); + assertEquals(2, dataMap.size()); + Map> inputDataMap = toDataMap(DEPENDENCY_ORDERING_TEST_DATA); + + // Segments should always come first + assertEquals(SEGMENTS, Iterables.get(dataMap.keySet(), 0)); + assertEquals(inputDataMap.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(transform(map1.values(), d -> (DataModel.FeatureFlag)d.getItem())); + assertEquals(inputDataMap.get(FEATURES).size(), map1.size()); + for (int itemIndex = 0; itemIndex < list1.size(); itemIndex++) { + DataModel.FeatureFlag item = list1.get(itemIndex); + for (DataModel.Prerequisite prereq: item.getPrerequisites()) { + DataModel.FeatureFlag depFlag = (DataModel.FeatureFlag)map1.get(prereq.getKey()).getItem(); + int depIndex = list1.indexOf(depFlag); + if (depIndex > itemIndex) { + 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(map1.keySet()))); + } + } + } + } + + private static FullDataSet DEPENDENCY_ORDERING_TEST_DATA = + new DataBuilder() + .addAny(FEATURES, + flagBuilder("a") + .prerequisites(prerequisite("b", 0), prerequisite("c", 0)).build(), + flagBuilder("b") + .prerequisites(prerequisite("c", 0), prerequisite("e", 0)).build(), + flagBuilder("c").build(), + flagBuilder("d").build(), + flagBuilder("e").build(), + flagBuilder("f").build()) + .addAny(SEGMENTS, + segmentBuilder("o").build()) + .build(); +} diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java index ecf92f682..614fe4f9c 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java @@ -1,22 +1,22 @@ package com.launchdarkly.sdk.server; -import com.google.common.base.Joiner; -import com.google.common.collect.ImmutableList; -import com.google.common.collect.Iterables; import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.DataModel.FeatureFlag; import com.launchdarkly.sdk.server.DataStoreTestTypes.DataBuilder; +import com.launchdarkly.sdk.server.TestUtil.DataSourceFactoryThatExposesUpdater; +import com.launchdarkly.sdk.server.TestUtil.FlagChangeEventSink; +import com.launchdarkly.sdk.server.TestUtil.FlagValueChangeEventSink; import com.launchdarkly.sdk.server.interfaces.ClientContext; import com.launchdarkly.sdk.server.interfaces.DataSource; import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; import com.launchdarkly.sdk.server.interfaces.DataStore; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; import com.launchdarkly.sdk.server.interfaces.Event; import com.launchdarkly.sdk.server.interfaces.EventProcessor; import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; +import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; +import com.launchdarkly.sdk.server.interfaces.FlagValueChangeEvent; import org.easymock.Capture; import org.easymock.EasyMock; @@ -27,21 +27,12 @@ import java.io.IOException; import java.net.URI; import java.time.Duration; -import java.util.List; -import java.util.Map; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; -import static com.google.common.collect.Iterables.transform; -import static com.launchdarkly.sdk.server.DataModel.FEATURES; -import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; -import static com.launchdarkly.sdk.server.DataStoreTestTypes.toDataMap; import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; import static com.launchdarkly.sdk.server.ModelBuilders.flagWithValue; -import static com.launchdarkly.sdk.server.ModelBuilders.prerequisite; -import static com.launchdarkly.sdk.server.ModelBuilders.segmentBuilder; -import static com.launchdarkly.sdk.server.TestUtil.dataSourceWithData; import static com.launchdarkly.sdk.server.TestUtil.failedDataSource; import static com.launchdarkly.sdk.server.TestUtil.initedDataStore; import static com.launchdarkly.sdk.server.TestUtil.specificDataStore; @@ -51,7 +42,8 @@ import static org.easymock.EasyMock.expect; import static org.easymock.EasyMock.expectLastCall; import static org.easymock.EasyMock.isA; -import static org.easymock.EasyMock.replay; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; @@ -389,49 +381,90 @@ public void evaluationUsesStoreIfStoreIsInitializedButClientIsNot() throws Excep } @Test - public void dataSetIsPassedToDataStoreInCorrectOrder() throws Exception { - // This verifies that the client is using DataStoreClientWrapper and that it is applying the - // correct ordering for flag prerequisites, etc. This should work regardless of what kind of - // DataSource we're using. + public void clientSendsFlagChangeEvents() throws Exception { + // The logic for sending change events is tested in detail in DataStoreUpdatesImplTest, but here we'll + // verify that the client is actually telling DataStoreUpdatesImpl about updates, and managing the + // listener list. + DataStore testDataStore = initedDataStore(); + DataBuilder initialData = new DataBuilder().addAny(DataModel.FEATURES, + flagBuilder("flagkey").version(1).build()); + DataSourceFactoryThatExposesUpdater updatableSource = new DataSourceFactoryThatExposesUpdater(initialData.build()); + LDConfig config = new LDConfig.Builder() + .dataStore(specificDataStore(testDataStore)) + .dataSource(updatableSource) + .events(Components.noEvents()) + .build(); - Capture> captureData = Capture.newInstance(); - DataStore store = createStrictMock(DataStore.class); - store.init(EasyMock.capture(captureData)); - replay(store); + client = new LDClient(SDK_KEY, config); - LDConfig.Builder config = new LDConfig.Builder() - .dataSource(dataSourceWithData(DEPENDENCY_ORDERING_TEST_DATA)) - .dataStore(specificDataStore(store)) - .events(Components.noEvents()); - client = new LDClient(SDK_KEY, config.build()); - - Map> dataMap = toDataMap(captureData.getValue()); - assertEquals(2, dataMap.size()); - Map> inputDataMap = toDataMap(DEPENDENCY_ORDERING_TEST_DATA); + FlagChangeEventSink eventSink1 = new FlagChangeEventSink(); + FlagChangeEventSink eventSink2 = new FlagChangeEventSink(); + client.registerFlagChangeListener(eventSink1); + client.registerFlagChangeListener(eventSink2); - // Segments should always come first - assertEquals(SEGMENTS, Iterables.get(dataMap.keySet(), 0)); - assertEquals(inputDataMap.get(SEGMENTS).size(), Iterables.get(dataMap.values(), 0).size()); + eventSink1.expectNoEvents(); + eventSink2.expectNoEvents(); - // 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(transform(map1.values(), d -> (DataModel.FeatureFlag)d.getItem())); - assertEquals(inputDataMap.get(FEATURES).size(), map1.size()); - for (int itemIndex = 0; itemIndex < list1.size(); itemIndex++) { - DataModel.FeatureFlag item = list1.get(itemIndex); - for (DataModel.Prerequisite prereq: item.getPrerequisites()) { - DataModel.FeatureFlag depFlag = (DataModel.FeatureFlag)map1.get(prereq.getKey()).getItem(); - int depIndex = list1.indexOf(depFlag); - if (depIndex > itemIndex) { - 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(map1.keySet()))); - } - } - } + updatableSource.updateFlag(flagBuilder("flagkey").version(2).build()); + + FlagChangeEvent event1 = eventSink1.awaitEvent(); + FlagChangeEvent event2 = eventSink2.awaitEvent(); + assertThat(event1.getKey(), equalTo("flagkey")); + assertThat(event2.getKey(), equalTo("flagkey")); + eventSink1.expectNoEvents(); + eventSink2.expectNoEvents(); + + client.unregisterFlagChangeListener(eventSink1); + + updatableSource.updateFlag(flagBuilder("flagkey").version(3).build()); + + FlagChangeEvent event3 = eventSink2.awaitEvent(); + assertThat(event3.getKey(), equalTo("flagkey")); + eventSink1.expectNoEvents(); + eventSink2.expectNoEvents(); } + @Test + public void clientSendsFlagValueChangeEvents() throws Exception { + String flagKey = "important-flag"; + LDUser user = new LDUser("important-user"); + LDUser otherUser = new LDUser("unimportant-user"); + DataStore testDataStore = initedDataStore(); + + FeatureFlag alwaysFalseFlag = flagBuilder(flagKey).version(1).on(true).variations(false, true) + .fallthroughVariation(0).build(); + DataBuilder initialData = new DataBuilder().addAny(DataModel.FEATURES, alwaysFalseFlag); + + DataSourceFactoryThatExposesUpdater updatableSource = new DataSourceFactoryThatExposesUpdater(initialData.build()); + LDConfig config = new LDConfig.Builder() + .dataStore(specificDataStore(testDataStore)) + .dataSource(updatableSource) + .events(Components.noEvents()) + .build(); + + client = new LDClient(SDK_KEY, config); + FlagValueChangeEventSink eventSink1 = new FlagValueChangeEventSink(); + FlagValueChangeEventSink eventSink2 = new FlagValueChangeEventSink(); + client.registerFlagChangeListener(Components.flagValueMonitoringListener(flagKey, user, eventSink1)); + client.registerFlagChangeListener(Components.flagValueMonitoringListener(flagKey, otherUser, eventSink2)); + + eventSink1.expectNoEvents(); + eventSink2.expectNoEvents(); + + FeatureFlag flagIsTrueForMyUserOnly = flagBuilder(flagKey).version(2).on(true).variations(false, true) + .targets(ModelBuilders.target(1, user.getKey())).fallthroughVariation(0).build(); + updatableSource.updateFlag(flagIsTrueForMyUserOnly); + + // eventSink1 receives a value change event; eventSink2 doesn't because the flag's value hasn't changed for otherUser + FlagValueChangeEvent event1 = eventSink1.awaitEvent(); + assertThat(event1.getKey(), equalTo(flagKey)); + assertThat(event1.getOldValue(), equalTo(LDValue.of(false))); + assertThat(event1.getNewValue(), equalTo(LDValue.of(true))); + eventSink1.expectNoEvents(); + + eventSink2.expectNoEvents(); + } + private void expectEventsSent(int count) { eventProcessor.sendEvent(anyObject(Event.class)); if (count > 0) { @@ -446,19 +479,4 @@ private LDClientInterface createMockClient(LDConfig.Builder config) { config.events(TestUtil.specificEventProcessor(eventProcessor)); return new LDClient(SDK_KEY, config.build()); } - - private static FullDataSet DEPENDENCY_ORDERING_TEST_DATA = - new DataBuilder() - .addAny(FEATURES, - flagBuilder("a") - .prerequisites(prerequisite("b", 0), prerequisite("c", 0)).build(), - flagBuilder("b") - .prerequisites(prerequisite("c", 0), prerequisite("e", 0)).build(), - flagBuilder("c").build(), - flagBuilder("d").build(), - flagBuilder("e").build(), - flagBuilder("f").build()) - .addAny(SEGMENTS, - segmentBuilder("o").build()) - .build(); } \ No newline at end of file diff --git a/src/test/java/com/launchdarkly/sdk/server/ModelBuilders.java b/src/test/java/com/launchdarkly/sdk/server/ModelBuilders.java index cb4976c8d..3c2ea3246 100644 --- a/src/test/java/com/launchdarkly/sdk/server/ModelBuilders.java +++ b/src/test/java/com/launchdarkly/sdk/server/ModelBuilders.java @@ -154,6 +154,11 @@ FlagBuilder rules(DataModel.Rule... rules) { return this; } + FlagBuilder fallthroughVariation(int fallthroughVariation) { + this.fallthrough = new DataModel.VariationOrRollout(fallthroughVariation, null); + return this; + } + FlagBuilder fallthrough(DataModel.VariationOrRollout fallthrough) { this.fallthrough = fallthrough; return this; @@ -169,6 +174,15 @@ FlagBuilder variations(LDValue... variations) { return this; } + FlagBuilder variations(boolean... variations) { + List values = new ArrayList<>(); + for (boolean v: variations) { + values.add(LDValue.of(v)); + } + this.variations = values; + return this; + } + FlagBuilder clientSide(boolean clientSide) { this.clientSide = clientSide; return this; diff --git a/src/test/java/com/launchdarkly/sdk/server/TestUtil.java b/src/test/java/com/launchdarkly/sdk/server/TestUtil.java index e1856edf6..536cbf41c 100644 --- a/src/test/java/com/launchdarkly/sdk/server/TestUtil.java +++ b/src/test/java/com/launchdarkly/sdk/server/TestUtil.java @@ -1,5 +1,6 @@ package com.launchdarkly.sdk.server; +import com.google.common.collect.ImmutableSet; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.SettableFuture; import com.launchdarkly.sdk.EvaluationReason; @@ -22,6 +23,10 @@ import com.launchdarkly.sdk.server.interfaces.Event; import com.launchdarkly.sdk.server.interfaces.EventProcessor; import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; +import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; +import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; +import com.launchdarkly.sdk.server.interfaces.FlagValueChangeEvent; +import com.launchdarkly.sdk.server.interfaces.FlagValueChangeListener; import org.hamcrest.Description; import org.hamcrest.Matcher; @@ -29,13 +34,21 @@ import java.io.IOException; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import java.util.Set; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; import static com.launchdarkly.sdk.server.DataModel.FEATURES; import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; +import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.fail; @SuppressWarnings("javadoc") public class TestUtil { @@ -48,7 +61,7 @@ public static ClientContext clientContext(final String sdkKey, final LDConfig co } public static DataStoreUpdates dataStoreUpdates(final DataStore store) { - return new DataStoreUpdatesImpl(store); + return new DataStoreUpdatesImpl(store, null); } public static DataStoreFactory specificDataStore(final DataStore store) { @@ -146,7 +159,26 @@ public void close() throws IOException { } }; } + + public static class DataSourceFactoryThatExposesUpdater implements DataSourceFactory { + private final FullDataSet initialData; + private DataStoreUpdates dataStoreUpdates; + + public DataSourceFactoryThatExposesUpdater(FullDataSet initialData) { + this.initialData = initialData; + } + + @Override + public DataSource createDataSource(ClientContext context, DataStoreUpdates dataStoreUpdates) { + this.dataStoreUpdates = dataStoreUpdates; + return dataSourceWithData(initialData).createDataSource(context, dataStoreUpdates); + } + public void updateFlag(FeatureFlag flag) { + dataStoreUpdates.upsert(FEATURES, flag.getKey(), new ItemDescriptor(flag.getVersion(), flag)); + } + } + public static class TestEventProcessor implements EventProcessor { List events = new ArrayList<>(); @@ -162,6 +194,59 @@ public void sendEvent(Event e) { public void flush() {} } + public static class FlagChangeEventSink extends FlagChangeEventSinkBase implements FlagChangeListener { + @Override + public void onFlagChange(FlagChangeEvent event) { + events.add(event); + } + } + + public static class FlagValueChangeEventSink extends FlagChangeEventSinkBase implements FlagValueChangeListener { + @Override + public void onFlagValueChange(FlagValueChangeEvent event) { + events.add(event); + } + } + + private static class FlagChangeEventSinkBase { + protected final BlockingQueue events = new ArrayBlockingQueue<>(100); + + public T awaitEvent() { + try { + T event = events.poll(1, TimeUnit.SECONDS); + assertNotNull("expected flag change event", event); + return event; + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + + public void expectEvents(String... flagKeys) { + Set expectedChangedFlagKeys = ImmutableSet.copyOf(flagKeys); + Set actualChangedFlagKeys = new HashSet<>(); + for (int i = 0; i < expectedChangedFlagKeys.size(); i++) { + try { + T e = events.poll(1, TimeUnit.SECONDS); + if (e == null) { + fail("expected change events for " + expectedChangedFlagKeys + " but got " + actualChangedFlagKeys); + } + actualChangedFlagKeys.add(e.getKey()); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + assertThat(actualChangedFlagKeys, equalTo(expectedChangedFlagKeys)); + expectNoEvents(); + } + + public void expectNoEvents() { + try { + T event = events.poll(100, TimeUnit.MILLISECONDS); + assertNull("expected no more flag change events", event); + } catch (InterruptedException e) {} + } + } + public static Evaluator.EvalResult simpleEvaluation(int variation, LDValue value) { return new Evaluator.EvalResult(value, variation, EvaluationReason.fallthrough()); } From 857a639bb76c981092290ef6d321f799d5d6272c Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 6 Mar 2020 18:08:01 -0800 Subject: [PATCH 338/673] clarify comment --- .../java/com/launchdarkly/sdk/server/LDClientInterface.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClientInterface.java b/src/main/java/com/launchdarkly/sdk/server/LDClientInterface.java index c2a3f3b02..416781bdc 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClientInterface.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClientInterface.java @@ -236,6 +236,10 @@ public interface LDClientInterface extends Closeable { * some part of the flag configuration was changed so that it may return a different value than it * previously returned for some user. *

    + * Change events only work if the SDK is actually connecting to LaunchDarkly (or using the file data source). + * If the SDK is only reading flags from a database ({@link Components#externalUpdatesOnly()}) then it cannot + * know when there is a change, because flags are read on an as-needed basis. + *

    * The listener will be called from a worker thread. *

    * Calling this method for an already-registered listener has no effect. From f3a8201ffedd22d6ff1bbad43aeeddf288ebb0ea Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 6 Mar 2020 18:13:08 -0800 Subject: [PATCH 339/673] javadoc fix --- .../sdk/server/interfaces/FlagValueChangeListener.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeListener.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeListener.java index a2eb446e6..d2f058bf6 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeListener.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeListener.java @@ -13,7 +13,7 @@ *

    
      *     String flagKey = "my-important-flag";
      *     LDUser userForFlagEvaluation = new LDUser("user-key-for-global-flag-state");
    - *     FlagValueChangeListener listenForNewValue = event -> {
    + *     FlagValueChangeListener listenForNewValue = event -> {
      *         if (event.getKey().equals(flagKey)) {
      *             doSomethingWithNewValue(event.getNewValue().booleanValue());
      *         }
    
    From eb3579c0deb515e65319d432ba14d4e9bfa7a012 Mon Sep 17 00:00:00 2001
    From: Eli Bishop 
    Date: Fri, 6 Mar 2020 18:20:29 -0800
    Subject: [PATCH 340/673] rm unused
    
    ---
     .../sdk/server/DataModelDependencies.java     | 30 -------------------
     1 file changed, 30 deletions(-)
    
    diff --git a/src/main/java/com/launchdarkly/sdk/server/DataModelDependencies.java b/src/main/java/com/launchdarkly/sdk/server/DataModelDependencies.java
    index 4f881b007..6eba3c432 100644
    --- a/src/main/java/com/launchdarkly/sdk/server/DataModelDependencies.java
    +++ b/src/main/java/com/launchdarkly/sdk/server/DataModelDependencies.java
    @@ -181,36 +181,6 @@ public int compare(DataKind o1, DataKind o2) {
         }
       };
       
    -  // General data structure that provides two levels of Map
    -  static final class TwoLevelMap {
    -    private final Map> data = new HashMap<>();
    -    
    -    public C get(A a, B b) {
    -      Map innerMap = data.get(a);
    -      return innerMap == null ? null : innerMap.get(b);
    -    }
    -    
    -    public void put(A a, B b, C c) {
    -      Map innerMap = data.get(a);
    -      if (innerMap == null) {
    -        innerMap = new HashMap<>();
    -        data.put(a, innerMap);
    -      }
    -      innerMap.put(b, c);
    -    }
    -    
    -    public void remove(A a, B b) {
    -      Map innerMap = data.get(a);
    -      if (innerMap != null) {
    -        innerMap.remove(b);
    -      }
    -    }
    -    
    -    public void clear() {
    -      data.clear();
    -    }
    -  }
    -  
       /**
        * Maintains a bidirectional dependency graph that can be updated whenever an item has changed.
        */
    
    From 4dac4343085c9cdaba547cb2ca4e134293d171c3 Mon Sep 17 00:00:00 2001
    From: Eli Bishop 
    Date: Fri, 6 Mar 2020 18:22:42 -0800
    Subject: [PATCH 341/673] rm unnecessary constructor, make field volatile
    
    ---
     .../launchdarkly/sdk/server/FlagChangeEventPublisher.java   | 6 +-----
     1 file changed, 1 insertion(+), 5 deletions(-)
    
    diff --git a/src/main/java/com/launchdarkly/sdk/server/FlagChangeEventPublisher.java b/src/main/java/com/launchdarkly/sdk/server/FlagChangeEventPublisher.java
    index 56b8789b3..3381a0508 100644
    --- a/src/main/java/com/launchdarkly/sdk/server/FlagChangeEventPublisher.java
    +++ b/src/main/java/com/launchdarkly/sdk/server/FlagChangeEventPublisher.java
    @@ -16,11 +16,7 @@
     final class FlagChangeEventPublisher implements Closeable {
       private final List listeners = new ArrayList<>();
       private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
    -  private ExecutorService executor;
    -  
    -  public FlagChangeEventPublisher() {
    -    
    -  }
    +  private volatile ExecutorService executor = null;
       
       public void register(FlagChangeListener listener) {
         lock.writeLock().lock();
    
    From 484256a0dfb44876290fb77f98d02fb21b442237 Mon Sep 17 00:00:00 2001
    From: Eli Bishop 
    Date: Fri, 6 Mar 2020 18:24:47 -0800
    Subject: [PATCH 342/673] rename interface, clarify comments
    
    ---
     .../com/launchdarkly/sdk/server/Components.java   |  4 ++--
     .../com/launchdarkly/sdk/server/LDClient.java     | 10 +++++-----
     ...egistration.java => ListenerRegistration.java} | 15 ++++++---------
     3 files changed, 13 insertions(+), 16 deletions(-)
     rename src/main/java/com/launchdarkly/sdk/server/interfaces/{FlagChangeListenerRegistration.java => ListenerRegistration.java} (53%)
    
    diff --git a/src/main/java/com/launchdarkly/sdk/server/Components.java b/src/main/java/com/launchdarkly/sdk/server/Components.java
    index 7b55a0ee4..8f1d6ccf5 100644
    --- a/src/main/java/com/launchdarkly/sdk/server/Components.java
    +++ b/src/main/java/com/launchdarkly/sdk/server/Components.java
    @@ -19,7 +19,7 @@
     import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory;
     import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent;
     import com.launchdarkly.sdk.server.interfaces.FlagChangeListener;
    -import com.launchdarkly.sdk.server.interfaces.FlagChangeListenerRegistration;
    +import com.launchdarkly.sdk.server.interfaces.ListenerRegistration;
     import com.launchdarkly.sdk.server.interfaces.FlagValueChangeEvent;
     import com.launchdarkly.sdk.server.interfaces.FlagValueChangeListener;
     import com.launchdarkly.sdk.server.interfaces.PersistentDataStoreFactory;
    @@ -472,7 +472,7 @@ public LDValue describeConfiguration(LDConfig config) {
         }
       }
       
    -  private static final class FlagValueMonitorImpl implements FlagChangeListener, FlagChangeListenerRegistration {
    +  private static final class FlagValueMonitorImpl implements FlagChangeListener, ListenerRegistration {
         private volatile LDClientInterface client;
         private AtomicReference currentValue = new AtomicReference<>(LDValue.ofNull());
         private final String flagKey;
    diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClient.java b/src/main/java/com/launchdarkly/sdk/server/LDClient.java
    index 535133cbc..d0e41bd29 100644
    --- a/src/main/java/com/launchdarkly/sdk/server/LDClient.java
    +++ b/src/main/java/com/launchdarkly/sdk/server/LDClient.java
    @@ -16,7 +16,7 @@
     import com.launchdarkly.sdk.server.interfaces.EventProcessor;
     import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory;
     import com.launchdarkly.sdk.server.interfaces.FlagChangeListener;
    -import com.launchdarkly.sdk.server.interfaces.FlagChangeListenerRegistration;
    +import com.launchdarkly.sdk.server.interfaces.ListenerRegistration;
     
     import org.apache.commons.codec.binary.Hex;
     import org.slf4j.Logger;
    @@ -383,8 +383,8 @@ private Evaluator.EvalResult evaluateInternal(String featureKey, LDUser user, LD
     
       @Override
       public void registerFlagChangeListener(FlagChangeListener listener) {
    -    if (listener instanceof FlagChangeListenerRegistration) {
    -      ((FlagChangeListenerRegistration)listener).onRegister(this);
    +    if (listener instanceof ListenerRegistration) {
    +      ((ListenerRegistration)listener).onRegister(this);
         }
         flagChangeEventPublisher.register(listener);
       }
    @@ -392,8 +392,8 @@ public void registerFlagChangeListener(FlagChangeListener listener) {
       @Override
       public void unregisterFlagChangeListener(FlagChangeListener listener) {
         flagChangeEventPublisher.unregister(listener);
    -    if (listener instanceof FlagChangeListenerRegistration) {
    -      ((FlagChangeListenerRegistration)listener).onUnregister(this);
    +    if (listener instanceof ListenerRegistration) {
    +      ((ListenerRegistration)listener).onUnregister(this);
         }
       }
       
    diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagChangeListenerRegistration.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/ListenerRegistration.java
    similarity index 53%
    rename from src/main/java/com/launchdarkly/sdk/server/interfaces/FlagChangeListenerRegistration.java
    rename to src/main/java/com/launchdarkly/sdk/server/interfaces/ListenerRegistration.java
    index 74f792295..d70681970 100644
    --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagChangeListenerRegistration.java
    +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/ListenerRegistration.java
    @@ -3,29 +3,26 @@
     import com.launchdarkly.sdk.server.LDClientInterface;
     
     /**
    - * This interface is implemented by any {@link FlagChangeListener} that needs to know when it has been
    + * This interface can be implemented by any event listener that needs to know when it has been
      * registered or unregistered with a client instance.
      * 
      * @see LDClientInterface#registerFlagChangeListener(FlagChangeListener)
      * @see LDClientInterface#unregisterFlagChangeListener(FlagChangeListener)
      * @since 5.0.0
      */
    -public interface FlagChangeListenerRegistration {
    +public interface ListenerRegistration {
       /**
    -   * The SDK calls this method when {@link LDClientInterface#registerFlagChangeListener(FlagChangeListener)}
    -   * has been called for this listener.
    +   * The SDK calls this method when the listener is being registered with a client so it will receive events.
        * 

    - * The listener is not yet registered at this point so it cannot have received any events. + * The listener is not yet registered at this point so it cannot have received any events yet. * * @param client the client instance that is registering the listener */ void onRegister(LDClientInterface client); /** - * The SDK calls this method when {@link LDClientInterface#unregisterFlagChangeListener(FlagChangeListener)} - * has been called for this listener. - *

    - * The listener is already unregistered at this point so it will not receive any more events. + * The SDK calls this method when the listener has been unregistered with a client so it will no longer + * receive events. * * @param client the client instance that has unregistered the listener */ From b6d7e606339d1e0b0798f08edeff51b16cc0b2e8 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 9 Mar 2020 10:33:29 -0700 Subject: [PATCH 343/673] 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 344/673] 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 634efd39376d6b614595c630ece5fd0f9765babf Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 9 Mar 2020 12:15:34 -0700 Subject: [PATCH 345/673] test improvements --- .../com/launchdarkly/client/DataModel.java | 2 +- .../com/launchdarkly/client/Evaluator.java | 12 +-- .../client/DataModelSerializationTest.java | 87 +++++++++++++++++-- 3 files changed, 89 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/DataModel.java b/src/main/java/com/launchdarkly/client/DataModel.java index a8d4bc6a1..de9465f90 100644 --- a/src/main/java/com/launchdarkly/client/DataModel.java +++ b/src/main/java/com/launchdarkly/client/DataModel.java @@ -403,7 +403,7 @@ String getSalt() { return salt; } - Iterable getRules() { + List getRules() { return rules; } diff --git a/src/main/java/com/launchdarkly/client/Evaluator.java b/src/main/java/com/launchdarkly/client/Evaluator.java index 1a800f4cd..d7130065c 100644 --- a/src/main/java/com/launchdarkly/client/Evaluator.java +++ b/src/main/java/com/launchdarkly/client/Evaluator.java @@ -299,15 +299,17 @@ private boolean segmentMatchesUser(DataModel.Segment segment, LDUser user) { if (userKey == null) { return false; } - if (segment.getIncluded().contains(userKey)) { + if (segment.getIncluded() != null && segment.getIncluded().contains(userKey)) { return true; } - if (segment.getExcluded().contains(userKey)) { + if (segment.getExcluded() != null && segment.getExcluded().contains(userKey)) { return false; } - for (DataModel.SegmentRule rule: segment.getRules()) { - if (segmentRuleMatchesUser(rule, user, segment.getKey(), segment.getSalt())) { - return true; + if (segment.getRules() != null) { + for (DataModel.SegmentRule rule: segment.getRules()) { + if (segmentRuleMatchesUser(rule, user, segment.getKey(), segment.getSalt())) { + return true; + } } } return false; diff --git a/src/test/java/com/launchdarkly/client/DataModelSerializationTest.java b/src/test/java/com/launchdarkly/client/DataModelSerializationTest.java index d1560d43c..1cce195ed 100644 --- a/src/test/java/com/launchdarkly/client/DataModelSerializationTest.java +++ b/src/test/java/com/launchdarkly/client/DataModelSerializationTest.java @@ -6,12 +6,16 @@ import com.launchdarkly.client.DataModel.FeatureFlag; import com.launchdarkly.client.DataModel.Operator; import com.launchdarkly.client.DataModel.Rule; +import com.launchdarkly.client.DataModel.Segment; +import com.launchdarkly.client.DataModel.SegmentRule; import com.launchdarkly.client.DataModel.Target; +import com.launchdarkly.client.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.client.value.LDValue; import org.junit.Test; -import static com.launchdarkly.client.JsonHelpers.gsonInstance; +import static com.launchdarkly.client.DataModel.DataKinds.FEATURES; +import static com.launchdarkly.client.DataModel.DataKinds.SEGMENTS; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; @@ -22,18 +26,19 @@ public class DataModelSerializationTest { @Test public void flagIsDeserializedWithAllProperties() { - String json = flagWithAllPropertiesJson().toJsonString(); - FeatureFlag flag0 = gsonInstance().fromJson(json, FeatureFlag.class); + String json0 = flagWithAllPropertiesJson().toJsonString(); + FeatureFlag flag0 = (FeatureFlag)FEATURES.deserialize(json0).getItem(); assertFlagHasAllProperties(flag0); - FeatureFlag flag1 = gsonInstance().fromJson(gsonInstance().toJson(flag0), FeatureFlag.class); + String json1 = FEATURES.serialize(new ItemDescriptor(flag0.getVersion(), flag0)); + FeatureFlag flag1 = (FeatureFlag)FEATURES.deserialize(json1).getItem(); 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 = (FeatureFlag)FEATURES.deserialize(json).getItem(); assertEquals("flag-key", flag.getKey()); assertEquals(99, flag.getVersion()); assertFalse(flag.isOn()); @@ -49,6 +54,27 @@ public void flagIsDeserializedWithMinimalProperties() { assertNull(flag.getDebugEventsUntilDate()); } + @Test + public void segmentIsDeserializedWithAllProperties() { + String json0 = segmentWithAllPropertiesJson().toJsonString(); + Segment segment0 = (Segment)SEGMENTS.deserialize(json0).getItem(); + assertSegmentHasAllProperties(segment0); + + String json1 = SEGMENTS.serialize(new ItemDescriptor(segment0.getVersion(), segment0)); + Segment segment1 = (Segment)SEGMENTS.deserialize(json1).getItem(); + assertSegmentHasAllProperties(segment1); + } + + @Test + public void segmentIsDeserializedWithMinimalProperties() { + String json = LDValue.buildObject().put("key", "segment-key").put("version", 99).build().toJsonString(); + Segment segment = (Segment)SEGMENTS.deserialize(json).getItem(); + assertEquals("segment-key", segment.getKey()); + assertEquals(99, segment.getVersion()); + assertNull(segment.getIncluded()); + assertNull(segment.getExcluded()); + assertNull(segment.getRules()); + } private LDValue flagWithAllPropertiesJson() { return LDValue.buildObject() @@ -100,7 +126,7 @@ private LDValue flagWithAllPropertiesJson() { .put("trackEvents", true) .put("trackEventsFallthrough", true) .put("debugEventsUntilDate", 1000) - .build(); + .build(); } private void assertFlagHasAllProperties(FeatureFlag flag) { @@ -151,4 +177,53 @@ private void assertFlagHasAllProperties(FeatureFlag flag) { assertTrue(flag.isTrackEventsFallthrough()); assertEquals(new Long(1000), flag.getDebugEventsUntilDate()); } + + private LDValue segmentWithAllPropertiesJson() { + return LDValue.buildObject() + .put("key", "segment-key") + .put("version", 99) + .put("included", LDValue.buildArray().add("key1").add("key2").build()) + .put("excluded", LDValue.buildArray().add("key3").add("key4").build()) + .put("salt", "123") + .put("rules", LDValue.buildArray() + .add(LDValue.buildObject() + .put("weight", 50000) + .put("bucketBy", "email") + .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() + .build()) + .build()) + .build(); + } + + private void assertSegmentHasAllProperties(Segment segment) { + assertEquals("segment-key", segment.getKey()); + assertEquals(99, segment.getVersion()); + assertEquals("123", segment.getSalt()); + assertEquals(ImmutableSet.of("key1", "key2"), segment.getIncluded()); + assertEquals(ImmutableSet.of("key3", "key4"), segment.getExcluded()); + + assertNotNull(segment.getRules()); + assertEquals(2, segment.getRules().size()); + SegmentRule r0 = segment.getRules().get(0); + assertEquals(new Integer(50000), r0.getWeight()); + assertNotNull(r0.getClauses()); + assertEquals(1, r0.getClauses().size()); + 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()); + SegmentRule r1 = segment.getRules().get(1); + assertNull(r1.getWeight()); + assertNull(r1.getBucketBy()); + } } From 334a68d7e3540fc8b5ea81dcf4e00479f2d19d85 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 9 Mar 2020 13:37:15 -0700 Subject: [PATCH 346/673] 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 347/673] 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 4414125c5286f21503ff6e6bda20a85af639407f Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 9 Mar 2020 15:36:56 -0700 Subject: [PATCH 348/673] simplify JSON parsing in test slightly --- .../com/launchdarkly/client/EventUserSerializationTest.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/test/java/com/launchdarkly/client/EventUserSerializationTest.java b/src/test/java/com/launchdarkly/client/EventUserSerializationTest.java index 3d0f606b7..51da6f747 100644 --- a/src/test/java/com/launchdarkly/client/EventUserSerializationTest.java +++ b/src/test/java/com/launchdarkly/client/EventUserSerializationTest.java @@ -10,7 +10,6 @@ import java.lang.reflect.Type; import java.util.HashSet; -import java.util.List; import java.util.Map; import java.util.Set; @@ -141,7 +140,7 @@ public void privateAttributeEncodingWorksForMinimalUser() { } private Set getPrivateAttrs(JsonObject o) { - Type type = new TypeToken>(){}.getType(); - return new HashSet(gsonInstance().>fromJson(o.get("privateAttrs"), type)); + Type type = new TypeToken>(){}.getType(); + return gsonInstance().>fromJson(o.get("privateAttrs"), type); } } From 784f8794b0c7dce68716cb8de2c1011ab51f8c7f Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 9 Mar 2020 19:53:01 -0700 Subject: [PATCH 349/673] (5.0) remove com.launchdarkly.sdk classes, get them from common package instead --- build.gradle | 39 +- .../com/launchdarkly/sdk/ArrayBuilder.java | 88 --- .../launchdarkly/sdk/EvaluationDetail.java | 110 --- .../launchdarkly/sdk/EvaluationReason.java | 371 ---------- .../java/com/launchdarkly/sdk/LDUser.java | 686 ------------------ .../java/com/launchdarkly/sdk/LDValue.java | 608 ---------------- .../com/launchdarkly/sdk/LDValueArray.java | 52 -- .../com/launchdarkly/sdk/LDValueBool.java | 41 -- .../com/launchdarkly/sdk/LDValueNull.java | 29 - .../com/launchdarkly/sdk/LDValueNumber.java | 68 -- .../com/launchdarkly/sdk/LDValueObject.java | 57 -- .../com/launchdarkly/sdk/LDValueString.java | 39 - .../com/launchdarkly/sdk/LDValueType.java | 34 - .../launchdarkly/sdk/LDValueTypeAdapter.java | 53 -- .../com/launchdarkly/sdk/ObjectBuilder.java | 106 --- .../com/launchdarkly/sdk/UserAttribute.java | 148 ---- .../com/launchdarkly/sdk/package-info.java | 4 - .../sdk/EvaluationReasonTest.java | 89 --- .../java/com/launchdarkly/sdk/LDUserTest.java | 270 ------- .../com/launchdarkly/sdk/LDValueTest.java | 421 ----------- .../launchdarkly/sdk/UserAttributeTest.java | 70 -- 21 files changed, 32 insertions(+), 3351 deletions(-) delete mode 100644 src/main/java/com/launchdarkly/sdk/ArrayBuilder.java delete mode 100644 src/main/java/com/launchdarkly/sdk/EvaluationDetail.java delete mode 100644 src/main/java/com/launchdarkly/sdk/EvaluationReason.java delete mode 100644 src/main/java/com/launchdarkly/sdk/LDUser.java delete mode 100644 src/main/java/com/launchdarkly/sdk/LDValue.java delete mode 100644 src/main/java/com/launchdarkly/sdk/LDValueArray.java delete mode 100644 src/main/java/com/launchdarkly/sdk/LDValueBool.java delete mode 100644 src/main/java/com/launchdarkly/sdk/LDValueNull.java delete mode 100644 src/main/java/com/launchdarkly/sdk/LDValueNumber.java delete mode 100644 src/main/java/com/launchdarkly/sdk/LDValueObject.java delete mode 100644 src/main/java/com/launchdarkly/sdk/LDValueString.java delete mode 100644 src/main/java/com/launchdarkly/sdk/LDValueType.java delete mode 100644 src/main/java/com/launchdarkly/sdk/LDValueTypeAdapter.java delete mode 100644 src/main/java/com/launchdarkly/sdk/ObjectBuilder.java delete mode 100644 src/main/java/com/launchdarkly/sdk/UserAttribute.java delete mode 100644 src/main/java/com/launchdarkly/sdk/package-info.java delete mode 100644 src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java delete mode 100644 src/test/java/com/launchdarkly/sdk/LDUserTest.java delete mode 100644 src/test/java/com/launchdarkly/sdk/LDValueTest.java delete mode 100644 src/test/java/com/launchdarkly/sdk/UserAttributeTest.java diff --git a/build.gradle b/build.gradle index f96119015..c87f76e85 100644 --- a/build.gradle +++ b/build.gradle @@ -30,6 +30,12 @@ repositories { mavenCentral() } +configurations { + doc { + transitive false + } +} + configurations.all { // check for updates every build for dependencies with: 'changing: true' resolutionStrategy.cacheChangingModulesFor 0, 'seconds' @@ -46,6 +52,15 @@ ext { sdkBasePackage = "com.launchdarkly.sdk" sdkBaseName = "launchdarkly-java-server-sdk" + sdkCommonVersion = "1.0.0-SNAPSHOT" + commonsCodecVersion = "1.10" + gsonVersion = "2.7" + guavaVersion = "28.2-jre" + jedisVersion = "2.9.0" + okhttpEventsourceVersion = "2.0.1" + slf4jVersion = "1.7.21" + snakeyamlVersion = "1.19" + // 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" ] @@ -57,18 +72,19 @@ ext.libraries = [:] // 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.code.gson:gson:2.7", - "com.google.guava:guava:28.2-jre", - "com.launchdarkly:okhttp-eventsource:2.0.1", - "org.yaml:snakeyaml:1.19", - "redis.clients:jedis:2.9.0" + "com.launchdarkly:launchdarkly-java-sdk-common:$sdkCommonVersion", + "commons-codec:commons-codec:$commonsCodecVersion", + "com.google.code.gson:gson:$gsonVersion", + "com.google.guava:guava:$guavaVersion", + "com.launchdarkly:okhttp-eventsource:$okhttpEventsourceVersion", + "org.yaml:snakeyaml:$snakeyamlVersion", + "redis.clients:jedis:$jedisVersion" ] // 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 = [ - "org.slf4j:slf4j-api:1.7.21" + "org.slf4j:slf4j-api:$slf4jVersion" ] // Add dependencies to "libraries.test" that are used only in unit tests. @@ -87,6 +103,8 @@ dependencies { runtime libraries.internal, libraries.external testImplementation libraries.test, libraries.internal, libraries.external + doc "com.launchdarkly:launchdarkly-java-sdk-common:$sdkCommonVersion:sources" + // 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 @@ -174,6 +192,13 @@ task javadocJar(type: Jar, dependsOn: javadoc) { from javadoc.destinationDir } +javadoc { + source configurations.doc.collect { + System.out.println(it) + zipTree(it) + } + include '**/*.java' +} // 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()) { diff --git a/src/main/java/com/launchdarkly/sdk/ArrayBuilder.java b/src/main/java/com/launchdarkly/sdk/ArrayBuilder.java deleted file mode 100644 index fa5302738..000000000 --- a/src/main/java/com/launchdarkly/sdk/ArrayBuilder.java +++ /dev/null @@ -1,88 +0,0 @@ -package com.launchdarkly.sdk; - -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; - } - - /** - * 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 - * 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/sdk/EvaluationDetail.java b/src/main/java/com/launchdarkly/sdk/EvaluationDetail.java deleted file mode 100644 index 656132f60..000000000 --- a/src/main/java/com/launchdarkly/sdk/EvaluationDetail.java +++ /dev/null @@ -1,110 +0,0 @@ -package com.launchdarkly.sdk; - -import com.google.common.base.Objects; - -/** - * An object returned by the SDK's "variation detail" methods such as {@code boolVariationDetail}, - * 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 { - - private final EvaluationReason reason; - private final Integer variationIndex; - private final T value; - - /** - * 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 - */ - public EvaluationDetail(EvaluationReason reason, Integer variationIndex, T value) { - this.value = value; - this.variationIndex = variationIndex; - this.reason = reason; - } - - /** - * 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) - * @return an {@link EvaluationDetail} - * @since 4.8.0 - */ - public static EvaluationDetail fromValue(T value, Integer variationIndex, EvaluationReason reason) { - return new EvaluationDetail(reason, variationIndex, value); - } - - /** - * Shortcut for creating an instance with an error result. - * - * @param errorKind the type of error - * @param defaultValue the application default value - * @return an {@link EvaluationDetail} - */ - public static EvaluationDetail error(EvaluationReason.ErrorKind errorKind, LDValue defaultValue) { - return new EvaluationDetail(EvaluationReason.error(errorKind), null, LDValue.normalize(defaultValue)); - } - - /** - * An object describing the main factor that influenced the flag evaluation value. - * @return an {@link EvaluationReason} - */ - public EvaluationReason 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; - } - - /** - * 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) { - @SuppressWarnings("unchecked") - EvaluationDetail o = (EvaluationDetail)other; - 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); - } - - @Override - public String toString() { - return "{" + reason + "," + variationIndex + "," + value + "}"; - } -} diff --git a/src/main/java/com/launchdarkly/sdk/EvaluationReason.java b/src/main/java/com/launchdarkly/sdk/EvaluationReason.java deleted file mode 100644 index 704817db7..000000000 --- a/src/main/java/com/launchdarkly/sdk/EvaluationReason.java +++ /dev/null @@ -1,371 +0,0 @@ -package com.launchdarkly.sdk; - -import com.launchdarkly.sdk.server.LDClientInterface; - -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 within {@link EvaluationDetail} by the SDK's "variation detail" methods such as - * {@code boolVariationDetail}. - *

    - * 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 { - - /** - * 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. - */ - 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. - */ - 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. - */ - PREREQUISITE_FAILED, - /** - * 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; - } - - /** - * 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 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. - */ - 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. An error message will always be logged - * in this case, and the exception should be available via {@link EvaluationReason.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, 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; - - /** - * Returns an enum indicating the general category of the reason. - * @return a {@link Kind} value - */ - public Kind getKind() - { - return kind; - } - - @Override - public String toString() { - return getKind().name(); - } - - protected EvaluationReason(Kind kind) - { - this.kind = kind; - } - - /** - * Returns an instance of {@link Off}. - * @return a reason object - */ - public static Off off() { - return Off.instance; - } - - /** - * Returns an instance of {@link TargetMatch}. - * @return a reason object - */ - public static TargetMatch targetMatch() { - return TargetMatch.instance; - } - - /** - * 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) { - return new RuleMatch(ruleIndex, ruleId); - } - - /** - * Returns an instance of {@link PrerequisiteFailed}. - * @param prerequisiteKey the flag key of the prerequisite that failed - * @return a reason object - */ - public static PrerequisiteFailed prerequisiteFailed(String prerequisiteKey) { - return new PrerequisiteFailed(prerequisiteKey); - } - - /** - * Returns an instance of {@link Fallthrough}. - * @return a reason object - */ - public static Fallthrough fallthrough() { - return Fallthrough.instance; - } - - /** - * Returns an instance of {@link Error}. - * @param errorKind describes the type of error - * @return a reason object - */ - public static Error error(ErrorKind 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, 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 - * its configured off value. - * @since 4.3.0 - */ - public static class Off extends EvaluationReason { - private Off() { - super(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. - * @since 4.3.0 - */ - public static class TargetMatch extends EvaluationReason { - private TargetMatch() - { - super(Kind.TARGET_MATCH); - } - - 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; - private final String ruleId; - - private RuleMatch(int ruleIndex, String ruleId) { - super(Kind.RULE_MATCH); - this.ruleIndex = ruleIndex; - 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; - } - - @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() + "(" + ruleIndex + (ruleId == null ? "" : ("," + 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. - * @since 4.3.0 - */ - public static class PrerequisiteFailed extends EvaluationReason { - private final String prerequisiteKey; - - private PrerequisiteFailed(String prerequisiteKey) { - super(Kind.PREREQUISITE_FAILED); - 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; - } - - @Override - public boolean equals(Object other) { - return (other instanceof PrerequisiteFailed) && - ((PrerequisiteFailed)other).prerequisiteKey.equals(prerequisiteKey); - } - - @Override - public int hashCode() { - return prerequisiteKey.hashCode(); - } - - @Override - public String toString() { - return getKind().name() + "(" + prerequisiteKey + ")"; - } - } - - /** - * 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 { - private Fallthrough() - { - super(Kind.FALLTHROUGH); - } - - private static final Fallthrough instance = new Fallthrough(); - } - - /** - * Subclass of {@link EvaluationReason} that indicates that the flag could not be evaluated. - * @since 4.3.0 - */ - public static class Error extends EvaluationReason { - private final ErrorKind errorKind; - 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); - checkNotNull(errorKind); - this.errorKind = errorKind; - this.exception = exception; - } - - /** - * An enumeration value indicating the general category of error. - * @return the error kind - */ - 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 && Objects.equals(exception, ((Error) other).exception); - } - - @Override - public int hashCode() { - return Objects.hash(errorKind, exception); - } - - @Override - public String toString() { - return getKind().name() + "(" + errorKind.name() + (exception == null ? "" : ("," + exception)) + ")"; - } - } -} diff --git a/src/main/java/com/launchdarkly/sdk/LDUser.java b/src/main/java/com/launchdarkly/sdk/LDUser.java deleted file mode 100644 index 196eb090f..000000000 --- a/src/main/java/com/launchdarkly/sdk/LDUser.java +++ /dev/null @@ -1,686 +0,0 @@ -package com.launchdarkly.sdk; - -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableMap; -import com.google.common.collect.ImmutableSet; -import com.google.gson.Gson; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; -import java.util.Objects; -import java.util.Set; - -/** - * A collection of attributes that can affect flag evaluation, usually corresponding to a user of your application. - *

    - * The only mandatory property is the {@code key}, which must uniquely identify each user; this could be a username - * or email address for authenticated users, or a session ID for anonymous users. All other built-in properties are - * optional. You may also define custom properties with arbitrary names and values. - *

    - * For a fuller description of user attributes and how they can be referenced in feature flag rules, see the reference - * guides on Setting user attributes - * and Targeting users. - *

    - * If you want to pass an LDUser object to the front end to be used with the JavaScript SDK, call {@link #toJsonString()} - * to get its JSON encoding. Do not try to pass an LDUser instance to a reflection-based encoder such as Gson; its - * internal structure does not correspond directly to the JSON encoding, and an external instance of Gson will not - * recognize the Gson annotations used inside the SDK. - */ -public class LDUser { - private static final Logger logger = LoggerFactory.getLogger(LDUser.class); - private static final Gson defaultGson = new Gson(); - - // 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. - final LDValue key; - final LDValue secondary; - final LDValue ip; - final LDValue email; - final LDValue name; - final LDValue avatar; - final LDValue firstName; - final LDValue lastName; - final LDValue anonymous; - final LDValue country; - final 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 = LDValue.of(builder.key); - this.ip = LDValue.of(builder.ip); - this.country = LDValue.of(builder.country); - 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.privateAttributes == null ? null : ImmutableSet.copyOf(builder.privateAttributes); - } - - /** - * Create a user with the given key - * - * @param key a {@code String} that uniquely identifies a user - */ - 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; - } - - /** - * Returns the user's unique key. - * - * @return the user key as a string - */ - public String getKey() { - return key.stringValue(); - } - - /** - * Returns the value of the secondary key property for the user, if set. - * - * @return a string or null - */ - public String getSecondary() { - return secondary.stringValue(); - } - - /** - * Returns the value of the IP property for the user, if set. - * - * @return a string or null - */ - public String getIp() { - return ip.stringValue(); - } - - /** - * Returns the value of the country property for the user, if set. - * - * @return a string or null - */ - public String getCountry() { - return country.stringValue(); - } - - /** - * Returns the value of the full name property for the user, if set. - * - * @return a string or null - */ - public String getName() { - return name.stringValue(); - } - - /** - * Returns the value of the first name property for the user, if set. - * - * @return a string or null - */ - public String getFirstName() { - return firstName.stringValue(); - } - - /** - * Returns the value of the last name property for the user, if set. - * - * @return a string or null - */ - public String getLastName() { - return lastName.stringValue(); - } - - /** - * Returns the value of the email property for the user, if set. - * - * @return a string or null - */ - public String getEmail() { - return email.stringValue(); - } - - /** - * Returns the value of the avatar property for the user, if set. - * - * @return a string or null - */ - public String getAvatar() { - return avatar.stringValue(); - } - - /** - * Returns true if this user was marked anonymous. - * - * @return true for an anonymous user - */ - public boolean isAnonymous() { - return anonymous.booleanValue(); - } - - /** - * Gets the value of a user attribute, if present. - *

    - * This can be either a built-in attribute or a custom one. It returns the value using the {@link LDValue} - * type, which can have any type that is supported in JSON. If the attribute does not exist, it returns - * {@link LDValue#ofNull()}. - * - * @param attribute the attribute to get - * @return the attribute value or {@link LDValue#ofNull()}; will never be an actual null reference - */ - public LDValue getAttribute(UserAttribute attribute) { - if (attribute.isBuiltIn()) { - return attribute.builtInGetter.apply(this); - } else { - return custom == null ? LDValue.ofNull() : LDValue.normalize(custom.get(attribute)); - } - } - - /** - * Returns an enumeration of all custom attribute names that were set for this user. - * - * @return the custom attribute names - */ - public Iterable getCustomAttributes() { - return custom == null ? ImmutableList.of() : custom.keySet(); - } - - /** - * Returns an enumeration of all attributes that were marked private for this user. - *

    - * This does not include any attributes that were globally marked private in your SDK configuration. - * - * @return the names of private attributes for this user - */ - public Iterable getPrivateAttributes() { - return privateAttributeNames == null ? ImmutableList.of() : privateAttributeNames; - } - - /** - * Tests whether an attribute has been marked private for this user. - * - * @param attribute a built-in or custom attribute - * @return true if the attribute was marked private on a per-user level - */ - public boolean isAttributePrivate(UserAttribute attribute) { - return privateAttributeNames != null && privateAttributeNames.contains(attribute); - } - - /** - * Converts the user data to its standard JSON representation. - *

    - * This is the same format that the LaunchDarkly JavaScript browser SDK uses to represent users, so - * it is the simplest way to pass user data to front-end code. - *

    - * Do not pass the {@link LDUser} object to a reflection-based JSON encoder such as Gson. Although the - * SDK uses Gson internally, it uses shading so that the Gson types are not exposed, so an external - * instance of Gson will not recognize the type adapters that provide the correct format. - * - * @return a JSON representation of the user - */ - public String toJsonString() { - return defaultGson.toJson(this); - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - 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) && - 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); - } - - /** - * A builder that helps construct {@link LDUser} objects. Builder - * calls can be chained, enabling the following pattern: - *

    -   * LDUser user = new LDUser.Builder("key")
    -   *      .country("US")
    -   *      .ip("192.168.0.1")
    -   *      .build()
    -   * 
    - */ - public static class Builder { - private String key; - private String secondary; - private String ip; - private String firstName; - private String lastName; - private String email; - private String name; - private String avatar; - private Boolean anonymous; - private String country; - private Map custom; - private Set privateAttributes; - - /** - * Creates a builder with the specified key. - * - * @param key the unique key for this user - */ - public Builder(String key) { - this.key = key; - } - - /** - * Creates a builder based on an existing user. - * - * @param user an existing {@code LDUser} - */ - public Builder(LDUser user) { - this.key = user.key.stringValue(); - this.secondary = user.secondary.stringValue(); - this.ip = user.ip.stringValue(); - this.firstName = user.firstName.stringValue(); - this.lastName = user.lastName.stringValue(); - this.email = user.email.stringValue(); - this.name = user.name.stringValue(); - this.avatar = user.avatar.stringValue(); - this.anonymous = user.anonymous.isNull() ? null : user.anonymous.booleanValue(); - this.country = user.country.stringValue(); - this.custom = user.custom == null ? null : new HashMap<>(user.custom); - this.privateAttributes = user.privateAttributeNames == null ? null : new HashSet<>(user.privateAttributeNames); - } - - /** - * Sets the IP for a user. - * - * @param s the IP address for the user - * @return the builder - */ - public Builder ip(String s) { - this.ip = s; - return this; - } - - /** - * Sets the IP for a user, and ensures that the IP attribute is not sent back to LaunchDarkly. - * - * @param s the IP address for the user - * @return the builder - */ - public Builder privateIp(String s) { - addPrivate(UserAttribute.IP); - return ip(s); - } - - /** - * Sets the secondary key for a user. This affects - * feature flag targeting - * as follows: if you have chosen to bucket users by a specific attribute, the secondary key (if set) - * is used to further distinguish between users who are otherwise identical according to that attribute. - * @param s the secondary key for the user - * @return the builder - */ - public Builder secondary(String s) { - this.secondary = s; - return this; - } - - /** - * Sets the secondary key for a user, and ensures that the secondary key attribute is not sent back to - * LaunchDarkly. - * @param s the secondary key for the user - * @return the builder - */ - public Builder privateSecondary(String s) { - addPrivate(UserAttribute.SECONDARY_KEY); - return secondary(s); - } - - /** - * Set the country for a user. Before version 5.0.0, this field was validated and normalized by the SDK - * as an ISO-3166-1 country code before assignment. This behavior has been removed so that the SDK can - * treat this field as a normal string, leaving the meaning of this field up to the application. - * - * @param s the country for the user - * @return the builder - */ - public Builder country(String s) { - this.country = s; - return this; - } - - /** - * Set the country for a user, and ensures that the country attribute will not be sent back to LaunchDarkly. - * Before version 5.0.0, this field was validated and normalized by the SDK as an ISO-3166-1 country code - * before assignment. This behavior has been removed so that the SDK can treat this field as a normal string, - * leaving the meaning of this field up to the application. - * - * @param s the country for the user - * @return the builder - */ - public Builder privateCountry(String s) { - addPrivate(UserAttribute.COUNTRY); - return country(s); - } - - /** - * Sets the user's first name - * - * @param firstName the user's first name - * @return the builder - */ - public Builder firstName(String firstName) { - this.firstName = firstName; - return this; - } - - - /** - * Sets the user's first name, and ensures that the first name attribute will not be sent back to LaunchDarkly. - * - * @param firstName the user's first name - * @return the builder - */ - public Builder privateFirstName(String firstName) { - addPrivate(UserAttribute.FIRST_NAME); - return firstName(firstName); - } - - - /** - * Sets whether this user is anonymous. - * - * @param anonymous whether the user is anonymous - * @return the builder - */ - public Builder anonymous(boolean anonymous) { - this.anonymous = anonymous; - return this; - } - - /** - * Sets the user's last name. - * - * @param lastName the user's last name - * @return the builder - */ - public Builder lastName(String lastName) { - this.lastName = lastName; - return this; - } - - /** - * Sets the user's last name, and ensures that the last name attribute will not be sent back to LaunchDarkly. - * - * @param lastName the user's last name - * @return the builder - */ - public Builder privateLastName(String lastName) { - addPrivate(UserAttribute.LAST_NAME); - return lastName(lastName); - } - - - /** - * Sets the user's full name. - * - * @param name the user's full name - * @return the builder - */ - public Builder name(String name) { - this.name = name; - return this; - } - - /** - * Sets the user's full name, and ensures that the name attribute will not be sent back to LaunchDarkly. - * - * @param name the user's full name - * @return the builder - */ - public Builder privateName(String name) { - addPrivate(UserAttribute.NAME); - return name(name); - } - - /** - * Sets the user's avatar. - * - * @param avatar the user's avatar - * @return the builder - */ - public Builder avatar(String avatar) { - this.avatar = avatar; - return this; - } - - /** - * Sets the user's avatar, and ensures that the avatar attribute will not be sent back to LaunchDarkly. - * - * @param avatar the user's avatar - * @return the builder - */ - public Builder privateAvatar(String avatar) { - addPrivate(UserAttribute.AVATAR); - return avatar(avatar); - } - - - /** - * Sets the user's e-mail address. - * - * @param email the e-mail address - * @return the builder - */ - public Builder email(String email) { - this.email = email; - return this; - } - - /** - * Sets the user's e-mail address, and ensures that the e-mail address attribute will not be sent back to LaunchDarkly. - * - * @param email the e-mail address - * @return the builder - */ - public Builder privateEmail(String email) { - addPrivate(UserAttribute.EMAIL); - return email(email); - } - - /** - * Adds a {@link java.lang.String}-valued custom attribute. 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 - */ - public Builder custom(String k, String v) { - return custom(k, LDValue.of(v)); - } - - /** - * Adds an integer-valued custom attribute. 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 n the value for the custom attribute - * @return the builder - */ - public Builder custom(String k, int n) { - return custom(k, LDValue.of(n)); - } - - /** - * Adds a double-precision numeric custom attribute. 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 n the value for the custom attribute - * @return the builder - */ - public Builder custom(String k, double n) { - return custom(k, LDValue.of(n)); - } - - /** - * Add a boolean-valued custom attribute. 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 b the value for the custom attribute - * @return the builder - */ - public Builder custom(String k, boolean b) { - return custom(k, LDValue.of(b)); - } - - /** - * 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, LDValue v) { - if (k != null) { - return customInternal(UserAttribute.forName(k), v); - } - return this; - } - - private Builder customInternal(UserAttribute a, LDValue v) { - if (a.isBuiltIn()) { - logger.warn("Built-in attribute key: " + a.getName() + " added as custom attribute! This custom attribute will be ignored during feature flag evaluation"); - } - if (custom == null) { - custom = new HashMap<>(); - } - custom.put(a, LDValue.normalize(v)); - return this; - } - - /** - * Add a {@link java.lang.String}-valued custom attribute 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 - */ - public Builder privateCustom(String k, String v) { - return privateCustom(k, LDValue.of(v)); - } - - /** - * Add an int-valued custom attribute 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 n the value for the custom attribute - * @return the builder - */ - public Builder privateCustom(String k, int n) { - return privateCustom(k, LDValue.of(n)); - } - - /** - * Add a double-precision numeric custom attribute 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 n the value for the custom attribute - * @return the builder - */ - public Builder privateCustom(String k, double n) { - return privateCustom(k, LDValue.of(n)); - } - - /** - * Add a boolean-valued custom attribute 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 b the value for the custom attribute - * @return the builder - */ - public Builder privateCustom(String k, boolean b) { - return privateCustom(k, LDValue.of(b)); - } - - /** - * 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 - * @since 4.8.0 - */ - public Builder privateCustom(String k, LDValue v) { - if (k != null) { - UserAttribute a = UserAttribute.forName(k); - addPrivate(a); - return customInternal(a, v); - } - return this; - } - - private void addPrivate(UserAttribute attribute) { - if (privateAttributes == null) { - privateAttributes = new HashSet<>(); - } - privateAttributes.add(attribute); - } - - /** - * Builds the configured {@link LDUser} object. - * - * @return the {@link LDUser} configured by this builder - */ - public LDUser build() { - return new LDUser(this); - } - } -} diff --git a/src/main/java/com/launchdarkly/sdk/LDValue.java b/src/main/java/com/launchdarkly/sdk/LDValue.java deleted file mode 100644 index f4281d069..000000000 --- a/src/main/java/com/launchdarkly/sdk/LDValue.java +++ /dev/null @@ -1,608 +0,0 @@ -package com.launchdarkly.sdk; - -import com.google.common.collect.ImmutableList; -import com.google.common.collect.Iterables; -import com.google.gson.Gson; -import com.google.gson.annotations.JsonAdapter; -import com.google.gson.stream.JsonWriter; - -import java.io.IOException; -import java.util.Map; - -/** - * An immutable instance of any data type that is allowed in JSON. - *

    - * An {@link LDValue} instance can be a null (that is, an instance that represents a JSON null value, - * rather than a Java null reference), a boolean, a number (always encoded internally as double-precision - * floating-point, but can be treated as an integer), a string, an ordered list of {@link LDValue} - * values (a JSON array), or a map of strings to {@link LDValue} values (a JSON object). It is easily - * convertible to standard Java types. - *

    - * This can be used to represent complex data in a user custom attribute (see {@link LDUser.Builder#custom(String, LDValue)}), - * or to get a feature flag value that uses a complex type or that does not always use the same - * type (see the client's {@code jsonValueVariation} methods). - *

    - * While the LaunchDarkly SDK uses Gson internally for JSON parsing, it uses {@link LDValue} rather - * than Gson's {@code JsonElement} type for two reasons. First, this allows Gson types to be excluded - * from the API, so the SDK does not expose this dependency and cannot cause version conflicts in - * applications that use Gson themselves. Second, Gson's array and object types are mutable, which can - * cause concurrency risks. - * - * @since 4.8.0 - */ -@JsonAdapter(LDValueTypeAdapter.class) -public abstract class LDValue { - static final Gson gson = new Gson(); - - /** - * 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. - *

    - * 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. - * - * @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():
    -   * 
    - * 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() { - return new ArrayBuilder(); - } - - /** - * 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() { - return new ObjectBuilder(); - } - - /** - * Parses an LDValue from a JSON representation. - * @param json a JSON string - * @return an LDValue - */ - public static LDValue parse(String json) { - return gson.fromJson(json, LDValue.class); - } - - /** - * 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 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. - * - * @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; - } - - /** - * 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. - * - * @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(); - } - - /** - * 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 the desired type - * @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(), converter::toType); - } - - /** - * 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); - } - - abstract void write(JsonWriter writer) throws IOException; - - static boolean isInteger(double value) { - return value == (double)((int)value); - } - - @Override - public String toString() { - return toJsonString(); - } - - @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; - } - } - - /** - * 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<Integer> listOfInts = ImmutableList.<Integer>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<String, Integer> mapOfInts = ImmutableMap.<String, Integer>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 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. - */ - 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/main/java/com/launchdarkly/sdk/LDValueArray.java b/src/main/java/com/launchdarkly/sdk/LDValueArray.java deleted file mode 100644 index 966eae9af..000000000 --- a/src/main/java/com/launchdarkly/sdk/LDValueArray.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.launchdarkly.sdk; - -import com.google.common.collect.ImmutableList; -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(); - } -} diff --git a/src/main/java/com/launchdarkly/sdk/LDValueBool.java b/src/main/java/com/launchdarkly/sdk/LDValueBool.java deleted file mode 100644 index f68789ee7..000000000 --- a/src/main/java/com/launchdarkly/sdk/LDValueBool.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.launchdarkly.sdk; - -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 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); - } -} diff --git a/src/main/java/com/launchdarkly/sdk/LDValueNull.java b/src/main/java/com/launchdarkly/sdk/LDValueNull.java deleted file mode 100644 index 1b3246dab..000000000 --- a/src/main/java/com/launchdarkly/sdk/LDValueNull.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.launchdarkly.sdk; - -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(); - } -} diff --git a/src/main/java/com/launchdarkly/sdk/LDValueNumber.java b/src/main/java/com/launchdarkly/sdk/LDValueNumber.java deleted file mode 100644 index 4c5c2baae..000000000 --- a/src/main/java/com/launchdarkly/sdk/LDValueNumber.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.launchdarkly.sdk; - -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 long longValue() { - return (long)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); - } - } -} diff --git a/src/main/java/com/launchdarkly/sdk/LDValueObject.java b/src/main/java/com/launchdarkly/sdk/LDValueObject.java deleted file mode 100644 index a0d4e88f6..000000000 --- a/src/main/java/com/launchdarkly/sdk/LDValueObject.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.launchdarkly.sdk; - -import com.google.common.collect.ImmutableMap; -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(); - } -} diff --git a/src/main/java/com/launchdarkly/sdk/LDValueString.java b/src/main/java/com/launchdarkly/sdk/LDValueString.java deleted file mode 100644 index dcdeb4e65..000000000 --- a/src/main/java/com/launchdarkly/sdk/LDValueString.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.launchdarkly.sdk; - -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 boolean isString() { - return true; - } - - @Override - public String stringValue() { - return value; - } - - @Override - void write(JsonWriter writer) throws IOException { - writer.value(value); - } -} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/sdk/LDValueType.java b/src/main/java/com/launchdarkly/sdk/LDValueType.java deleted file mode 100644 index 11804f610..000000000 --- a/src/main/java/com/launchdarkly/sdk/LDValueType.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.launchdarkly.sdk; - -/** - * 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/sdk/LDValueTypeAdapter.java b/src/main/java/com/launchdarkly/sdk/LDValueTypeAdapter.java deleted file mode 100644 index acdfa7c3d..000000000 --- a/src/main/java/com/launchdarkly/sdk/LDValueTypeAdapter.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.launchdarkly.sdk; - -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/sdk/ObjectBuilder.java b/src/main/java/com/launchdarkly/sdk/ObjectBuilder.java deleted file mode 100644 index cb0157e93..000000000 --- a/src/main/java/com/launchdarkly/sdk/ObjectBuilder.java +++ /dev/null @@ -1,106 +0,0 @@ -package com.launchdarkly.sdk; - -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; - } - - /** - * 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 - * 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/main/java/com/launchdarkly/sdk/UserAttribute.java b/src/main/java/com/launchdarkly/sdk/UserAttribute.java deleted file mode 100644 index ab1102616..000000000 --- a/src/main/java/com/launchdarkly/sdk/UserAttribute.java +++ /dev/null @@ -1,148 +0,0 @@ -package com.launchdarkly.sdk; - -import com.google.common.collect.ImmutableList; -import com.google.common.collect.Maps; -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; -import java.util.function.Function; - -/** - * Represents a built-in or custom attribute name supported by {@link LDUser}. - *

    - * This abstraction helps to distinguish attribute names from other {@link String} values, and also - * improves efficiency in feature flag data structures and evaluations because built-in attributes - * always reuse the same instances. - *

    - * For a fuller description of user attributes and how they can be referenced in feature flag rules, see the reference - * guides on Setting user attributes - * and Targeting users. - * - * @since 5.0.0 - */ -@JsonAdapter(UserAttribute.UserAttributeTypeAdapter.class) -public final class UserAttribute { - /** - * Represents the user key attribute. - */ - public static final UserAttribute KEY = new UserAttribute("key", u -> u.key); - /** - * Represents the secondary key attribute. - */ - public static final UserAttribute SECONDARY_KEY = new UserAttribute("secondary", u -> u.secondary); - /** - * Represents the IP address attribute. - */ - public static final UserAttribute IP = new UserAttribute("ip", u -> u.ip); - /** - * Represents the user key attribute. - */ - public static final UserAttribute EMAIL = new UserAttribute("email", u -> u.email); - /** - * Represents the full name attribute. - */ - public static final UserAttribute NAME = new UserAttribute("name", u -> u.name); - /** - * Represents the avatar URL attribute. - */ - public static final UserAttribute AVATAR = new UserAttribute("avatar", u -> u.avatar); - /** - * Represents the first name attribute. - */ - public static final UserAttribute FIRST_NAME = new UserAttribute("firstName", u -> u.firstName); - /** - * Represents the last name attribute. - */ - public static final UserAttribute LAST_NAME = new UserAttribute("lastName", u -> u.lastName); - /** - * Represents the country attribute. - */ - public static final UserAttribute COUNTRY = new UserAttribute("country", u -> u.country); - /** - * Represents the anonymous attribute. - */ - public static final UserAttribute ANONYMOUS = new UserAttribute("anonymous", u -> u.anonymous); - - private static final Map BUILTINS = Maps.uniqueIndex( - ImmutableList.of(KEY, SECONDARY_KEY, IP, EMAIL, NAME, AVATAR, FIRST_NAME, LAST_NAME, COUNTRY, ANONYMOUS), - a -> a.getName()); - - private final String name; - final Function builtInGetter; - - private UserAttribute(String name, Function builtInGetter) { - this.name = name; - this.builtInGetter = builtInGetter; - } - - /** - * Returns a UserAttribute instance for the specified attribute name. - *

    - * For built-in attributes, the same instances are always reused and {@link #isBuiltIn()} will - * return true. For custom attributes, a new instance is created and {@link #isBuiltIn()} will - * return false. - * - * @param name the attribute name - * @return a {@link UserAttribute} - */ - public static UserAttribute forName(String name) { - UserAttribute a = BUILTINS.get(name); - return a != null ? a : new UserAttribute(name, null); - } - - /** - * Returns the case-sensitive attribute name. - * - * @return the attribute name - */ - public String getName() { - return name; - } - - /** - * Returns true for a built-in attribute or false for a custom attribute. - * - * @return true if it is a built-in attribute - */ - public boolean isBuiltIn() { - return builtInGetter != null; - } - - @Override - public boolean equals(Object other) { - if (other instanceof UserAttribute) { - UserAttribute o = (UserAttribute)other; - if (isBuiltIn() || o.isBuiltIn()) { - return this == o; // faster comparison since built-in instances are interned - } - return name.equals(o.name); - } - return false; - } - - @Override - public int hashCode() { - return isBuiltIn() ? super.hashCode() : name.hashCode(); - } - - @Override - public String toString() { - return name; - } - - static final class UserAttributeTypeAdapter extends TypeAdapter{ - @Override - public UserAttribute read(JsonReader reader) throws IOException { - return UserAttribute.forName(reader.nextString()); - } - - @Override - public void write(JsonWriter writer, UserAttribute value) throws IOException { - writer.value(value.getName()); - } - } -} diff --git a/src/main/java/com/launchdarkly/sdk/package-info.java b/src/main/java/com/launchdarkly/sdk/package-info.java deleted file mode 100644 index 922165523..000000000 --- a/src/main/java/com/launchdarkly/sdk/package-info.java +++ /dev/null @@ -1,4 +0,0 @@ -/** - * Base namespace for LaunchDarkly Java-based SDKs, containing common types. - */ -package com.launchdarkly.sdk; diff --git a/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java b/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java deleted file mode 100644 index d319bbcdd..000000000 --- a/src/test/java/com/launchdarkly/sdk/EvaluationReasonTest.java +++ /dev/null @@ -1,89 +0,0 @@ -package com.launchdarkly.sdk; - -import com.google.gson.Gson; -import com.launchdarkly.sdk.EvaluationReason; -import com.launchdarkly.sdk.LDValue; - -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(); - - @Test - public void testOffReasonSerialization() { - EvaluationReason reason = EvaluationReason.off(); - String json = "{\"kind\":\"OFF\"}"; - 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() { - 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 testPrerequisiteFailedSerialization() { - EvaluationReason reason = EvaluationReason.prerequisiteFailed("key"); - String json = "{\"kind\":\"PREREQUISITE_FAILED\",\"prerequisiteKey\":\"key\"}"; - assertJsonEqual(json, gson.toJson(reason)); - assertEquals("PREREQUISITE_FAILED(key)", reason.toString()); - } - - @Test - public void testErrorSerialization() { - 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,java.lang.Exception: something happened)", 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) { - LDValue expected = LDValue.parse(expectedString); - LDValue actual = LDValue.parse(actualString); - assertEquals(expected, actual); - } -} diff --git a/src/test/java/com/launchdarkly/sdk/LDUserTest.java b/src/test/java/com/launchdarkly/sdk/LDUserTest.java deleted file mode 100644 index 3ac454f5c..000000000 --- a/src/test/java/com/launchdarkly/sdk/LDUserTest.java +++ /dev/null @@ -1,270 +0,0 @@ -package com.launchdarkly.sdk; - -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableSet; -import com.google.common.collect.Iterables; - -import org.junit.Test; - -import java.util.function.BiFunction; -import java.util.function.Function; - -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.contains; -import static org.hamcrest.Matchers.emptyIterable; -import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.is; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; - -@SuppressWarnings("javadoc") -public class LDUserTest { - private static enum OptionalStringAttributes { - secondary(LDUser::getSecondary, LDUser.Builder::secondary, LDUser.Builder::privateSecondary), - ip(LDUser::getIp, LDUser.Builder::ip, LDUser.Builder::privateIp), - firstName(LDUser::getFirstName, LDUser.Builder::firstName, LDUser.Builder::privateFirstName), - lastName(LDUser::getLastName, LDUser.Builder::lastName, LDUser.Builder::privateLastName), - email(LDUser::getEmail, LDUser.Builder::email, LDUser.Builder::privateEmail), - name(LDUser::getName, LDUser.Builder::name, LDUser.Builder::privateName), - avatar(LDUser::getAvatar, LDUser.Builder::avatar, LDUser.Builder::privateAvatar), - country(LDUser::getCountry, LDUser.Builder::country, LDUser.Builder::privateCountry); - - final UserAttribute attribute; - final Function getter; - final BiFunction setter; - final BiFunction privateSetter; - - OptionalStringAttributes( - Function getter, - BiFunction setter, - BiFunction privateSetter - ) { - this.attribute = UserAttribute.forName(this.name()); - this.getter = getter; - this.setter = setter; - this.privateSetter = privateSetter; - } - }; - - @Test - public void simpleConstructorSetsKey() { - LDUser user = new LDUser("key"); - assertEquals("key", user.getKey()); - assertEquals(LDValue.of("key"), user.getAttribute(UserAttribute.KEY)); - for (OptionalStringAttributes a: OptionalStringAttributes.values()) { - assertNull(a.toString(), a.getter.apply(user)); - assertEquals(a.toString(), LDValue.ofNull(), user.getAttribute(a.attribute)); - } - assertThat(user.isAnonymous(), is(false)); - assertThat(user.getAttribute(UserAttribute.ANONYMOUS), equalTo(LDValue.ofNull())); - assertThat(user.getAttribute(UserAttribute.forName("custom-attr")), equalTo(LDValue.ofNull())); - assertThat(user.getCustomAttributes(), emptyIterable()); - assertThat(user.getPrivateAttributes(), emptyIterable()); - } - - @Test - public void builderSetsOptionalStringAttribute() { - for (OptionalStringAttributes a: OptionalStringAttributes.values()) { - String value = "value-of-" + a.name(); - LDUser.Builder builder = new LDUser.Builder("key"); - a.setter.apply(builder, value); - LDUser user = builder.build(); - for (OptionalStringAttributes a1: OptionalStringAttributes.values()) { - if (a1 == a) { - assertEquals(a.toString(), value, a1.getter.apply(user)); - assertEquals(a.toString(), LDValue.of(value), user.getAttribute(a1.attribute)); - } else { - assertNull(a.toString(), a1.getter.apply(user)); - assertEquals(a.toString(), LDValue.ofNull(), user.getAttribute(a1.attribute)); - } - } - assertThat(user.isAnonymous(), is(false)); - assertThat(user.getAttribute(UserAttribute.ANONYMOUS), equalTo(LDValue.ofNull())); - assertThat(user.getAttribute(UserAttribute.forName("custom-attr")), equalTo(LDValue.ofNull())); - assertThat(user.getCustomAttributes(), emptyIterable()); - assertThat(user.getPrivateAttributes(), emptyIterable()); - assertFalse(user.isAttributePrivate(a.attribute)); - } - } - - @Test - public void builderSetsPrivateOptionalStringAttribute() { - for (OptionalStringAttributes a: OptionalStringAttributes.values()) { - String value = "value-of-" + a.name(); - LDUser.Builder builder = new LDUser.Builder("key"); - a.privateSetter.apply(builder, value); - LDUser user = builder.build(); - for (OptionalStringAttributes a1: OptionalStringAttributes.values()) { - if (a1 == a) { - assertEquals(a.toString(), value, a1.getter.apply(user)); - assertEquals(a.toString(), LDValue.of(value), user.getAttribute(a1.attribute)); - } else { - assertNull(a.toString(), a1.getter.apply(user)); - assertEquals(a.toString(), LDValue.ofNull(), user.getAttribute(a1.attribute)); - } - } - assertThat(user.isAnonymous(), is(false)); - assertThat(user.getAttribute(UserAttribute.ANONYMOUS), equalTo(LDValue.ofNull())); - assertThat(user.getAttribute(UserAttribute.forName("custom-attr")), equalTo(LDValue.ofNull())); - assertThat(user.getCustomAttributes(), emptyIterable()); - assertThat(user.getPrivateAttributes(), contains(a.attribute)); - assertTrue(user.isAttributePrivate(a.attribute)); - } - } - - @Test - public void builderSetsCustomAttributes() { - LDValue boolValue = LDValue.of(true), - intValue = LDValue.of(2), - floatValue = LDValue.of(2.5), - stringValue = LDValue.of("x"), - jsonValue = LDValue.buildArray().build(); - LDUser user = new LDUser.Builder("key") - .custom("custom-bool", boolValue.booleanValue()) - .custom("custom-int", intValue.intValue()) - .custom("custom-float", floatValue.floatValue()) - .custom("custom-double", floatValue.doubleValue()) - .custom("custom-string", stringValue.stringValue()) - .custom("custom-json", jsonValue) - .build(); - Iterable names = ImmutableList.of("custom-bool", "custom-int", "custom-float", "custom-double", "custom-string", "custom-json"); - assertThat(user.getAttribute(UserAttribute.forName("custom-bool")), equalTo(boolValue)); - assertThat(user.getAttribute(UserAttribute.forName("custom-int")), equalTo(intValue)); - assertThat(user.getAttribute(UserAttribute.forName("custom-float")), equalTo(floatValue)); - assertThat(user.getAttribute(UserAttribute.forName("custom-double")), equalTo(floatValue)); - assertThat(user.getAttribute(UserAttribute.forName("custom-string")), equalTo(stringValue)); - assertThat(user.getAttribute(UserAttribute.forName("custom-json")), equalTo(jsonValue)); - assertThat(ImmutableSet.copyOf(user.getCustomAttributes()), - equalTo(ImmutableSet.copyOf(Iterables.transform(names, UserAttribute::forName)))); - assertThat(user.getPrivateAttributes(), emptyIterable()); - for (String name: names) { - assertThat(name, user.isAttributePrivate(UserAttribute.forName(name)), is(false)); - } - } - - @Test - public void builderSetsPrivateCustomAttributes() { - LDValue boolValue = LDValue.of(true), - intValue = LDValue.of(2), - floatValue = LDValue.of(2.5), - stringValue = LDValue.of("x"), - jsonValue = LDValue.buildArray().build(); - LDUser user = new LDUser.Builder("key") - .privateCustom("custom-bool", boolValue.booleanValue()) - .privateCustom("custom-int", intValue.intValue()) - .privateCustom("custom-float", floatValue.floatValue()) - .privateCustom("custom-double", floatValue.doubleValue()) - .privateCustom("custom-string", stringValue.stringValue()) - .privateCustom("custom-json", jsonValue) - .build(); - Iterable names = ImmutableList.of("custom-bool", "custom-int", "custom-float", "custom-double", "custom-string", "custom-json"); - assertThat(user.getAttribute(UserAttribute.forName("custom-bool")), equalTo(boolValue)); - assertThat(user.getAttribute(UserAttribute.forName("custom-int")), equalTo(intValue)); - assertThat(user.getAttribute(UserAttribute.forName("custom-float")), equalTo(floatValue)); - assertThat(user.getAttribute(UserAttribute.forName("custom-double")), equalTo(floatValue)); - assertThat(user.getAttribute(UserAttribute.forName("custom-string")), equalTo(stringValue)); - assertThat(user.getAttribute(UserAttribute.forName("custom-json")), equalTo(jsonValue)); - assertThat(ImmutableSet.copyOf(user.getCustomAttributes()), - equalTo(ImmutableSet.copyOf(Iterables.transform(names, UserAttribute::forName)))); - assertThat(ImmutableSet.copyOf(user.getPrivateAttributes()), equalTo(ImmutableSet.copyOf(user.getCustomAttributes()))); - for (String name: names) { - assertThat(name, user.isAttributePrivate(UserAttribute.forName(name)), is(true)); - } - } - - @Test - public void canCopyUserWithBuilder() { - LDUser user = new LDUser.Builder("key") - .secondary("secondary") - .ip("127.0.0.1") - .firstName("Bob") - .lastName("Loblaw") - .email("bob@example.com") - .name("Bob Loblaw") - .avatar("image") - .anonymous(false) - .country("US") - .custom("org", "LaunchDarkly") - .build(); - - assert(user.equals(new LDUser.Builder(user).build())); - } - - @Test - public void canSetAnonymous() { - LDUser user1 = new LDUser.Builder("key").anonymous(true).build(); - assertThat(user1.isAnonymous(), is(true)); - assertThat(user1.getAttribute(UserAttribute.ANONYMOUS), equalTo(LDValue.of(true))); - - LDUser user2 = new LDUser.Builder("key").anonymous(false).build(); - assertThat(user2.isAnonymous(), is(false)); - assertThat(user2.getAttribute(UserAttribute.ANONYMOUS), equalTo(LDValue.of(false))); - } - - @Test - public void getAttributeGetsBuiltInAttributeEvenIfCustomAttrHasSameName() { - LDUser user = new LDUser.Builder("key") - .name("Jane") - .custom("name", "Joan") - .build(); - assertEquals(LDValue.of("Jane"), user.getAttribute(UserAttribute.forName("name"))); - } - - @Test - public void testMinimalJsonEncoding() { - LDUser user = new LDUser("userkey"); - String json = user.toJsonString(); - assertThat(json, equalTo("{\"key\":\"userkey\"}")); - } - - @Test - public void testDefaultJsonEncodingWithoutPrivateAttributes() { - LDUser user = new LDUser.Builder("userkey") - .secondary("s") - .ip("i") - .email("e") - .name("n") - .avatar("a") - .firstName("f") - .lastName("l") - .country("c") - .anonymous(true) - .custom("c1", "v1") - .build(); - LDValue json = LDValue.parse(user.toJsonString()); - assertThat(json, equalTo( - LDValue.buildObject() - .put("key", "userkey") - .put("secondary", "s") - .put("ip", "i") - .put("email", "e") - .put("name", "n") - .put("avatar", "a") - .put("firstName", "f") - .put("lastName", "l") - .put("country", "c") - .put("anonymous", true) - .put("custom", LDValue.buildObject().put("c1", "v1").build()) - .build() - )); - } - - @Test - public void testDefaultJsonEncodingWithPrivateAttributes() { - LDUser user = new LDUser.Builder("userkey") - .email("e") - .privateName("n") - .build(); - LDValue json = LDValue.parse(user.toJsonString()); - assertThat(json, equalTo( - LDValue.buildObject() - .put("key", "userkey") - .put("email", "e") - .put("name", "n") - .put("privateAttributeNames", LDValue.buildArray().add("name").build()) - .build() - )); - } -} diff --git a/src/test/java/com/launchdarkly/sdk/LDValueTest.java b/src/test/java/com/launchdarkly/sdk/LDValueTest.java deleted file mode 100644 index d3fb71b5a..000000000 --- a/src/test/java/com/launchdarkly/sdk/LDValueTest.java +++ /dev/null @@ -1,421 +0,0 @@ -package com.launchdarkly.sdk; - -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableMap; -import com.google.gson.Gson; -import com.launchdarkly.sdk.ArrayBuilder; -import com.launchdarkly.sdk.LDValue; -import com.launchdarkly.sdk.LDValueType; -import com.launchdarkly.sdk.ObjectBuilder; - -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.assertTrue; -import static org.junit.Assert.fail; - -@SuppressWarnings("javadoc") -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); - 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(); - - @Test - public void canGetValueAsBoolean() { - assertEquals(LDValueType.BOOLEAN, aTrueBoolValue.getType()); - assertTrue(aTrueBoolValue.booleanValue()); - } - - @Test - public void nonBooleanValueAsBooleanIsFalse() { - LDValue[] values = new LDValue[] { - LDValue.ofNull(), - aStringValue, - anIntValue, - aLongValue, - aFloatValue, - aDoubleValue, - anArrayValue, - anObjectValue, - }; - 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()); - } - - @Test - public void nonStringValueAsStringIsNull() { - LDValue[] values = new LDValue[] { - LDValue.ofNull(), - aTrueBoolValue, - anIntValue, - aLongValue, - aFloatValue, - aDoubleValue, - anArrayValue, - anObjectValue - }; - 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(3L), - 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()); - assertEquals(value.toString(), 3L, value.longValue()); - } - } - - @Test - public void canGetFloatValueOfAnyNumericType() { - LDValue[] values = new LDValue[] { - LDValue.of(3), - LDValue.of(3L), - 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(3L), - 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, - aStringValue, - aNumericLookingStringValue, - anArrayValue, - anObjectValue - }; - 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 canGetSizeOfArray() { - assertEquals(1, anArrayValue.size()); - } - - @Test - public void arrayCanGetItemByIndex() { - assertEquals(LDValueType.ARRAY, anArrayValue.getType()); - assertEquals(LDValue.of(3), anArrayValue.get(0)); - assertEquals(LDValue.ofNull(), anArrayValue.get(-1)); - assertEquals(LDValue.ofNull(), anArrayValue.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, - anIntValue, - aLongValue, - 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()); - } - - @Test - public void objectCanGetValueByName() { - assertEquals(LDValueType.OBJECT, anObjectValue.getType()); - assertEquals(LDValue.of("x"), anObjectValue.get("1")); - assertEquals(LDValue.ofNull(), anObjectValue.get(null)); - assertEquals(LDValue.ofNull(), anObjectValue.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, - anIntValue, - aLongValue, - 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 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 equalsUsesDeepEqualityForArrays() - { - LDValue a1 = LDValue.buildArray().add("a") - .add(LDValue.buildArray().add("b").add("c").build()) - .build(); - - LDValue a2 = LDValue.buildArray().add("a").build(); - assertValueAndHashNotEqual(a1, a2); - - LDValue a3 = LDValue.buildArray().add("a").add("b").add("c").build(); - assertValueAndHashNotEqual(a1, a3); - - LDValue a4 = LDValue.buildArray().add("a") - .add(LDValue.buildArray().add("b").add("x").build()) - .build(); - assertValueAndHashNotEqual(a1, a4); - } - - @Test - public void equalsUsesDeepEqualityForObjects() - { - LDValue o1 = LDValue.buildObject() - .put("a", "b") - .put("c", LDValue.buildObject().put("d", "e").build()) - .build(); - - LDValue o2 = LDValue.buildObject() - .put("a", "b") - .build(); - assertValueAndHashNotEqual(o1, o2); - - LDValue o3 = LDValue.buildObject() - .put("a", "b") - .put("c", LDValue.buildObject().put("d", "e").build()) - .put("f", "g") - .build(); - assertValueAndHashNotEqual(o1, o3); - - LDValue o4 = LDValue.buildObject() - .put("a", "b") - .put("c", LDValue.buildObject().put("d", "f").build()) - .build(); - assertValueAndHashNotEqual(o1, o4); - } - - @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()); - assertEquals("true", aTrueBoolValue.toJsonString()); - assertEquals("false", LDValue.of(false).toJsonString()); - assertEquals(String.valueOf(someInt), anIntValue.toJsonString()); - assertEquals(String.valueOf(someLong), aLongValue.toJsonString()); - assertEquals(String.valueOf(someFloat), aFloatValue.toJsonString()); - assertEquals(String.valueOf(someDouble), aDoubleValue.toJsonString()); - assertEquals("\"hi\"", aStringValue.toJsonString()); - assertEquals("[3]", anArrayValue.toJsonString()); - assertEquals("{\"1\":\"x\"}", anObjectValue.toJsonString()); - } - - @Test - public void testDefaultGsonSerialization() { - LDValue[] values = new LDValue[] { - LDValue.ofNull(), - aTrueBoolValue, - anIntValue, - aLongValue, - aFloatValue, - aDoubleValue, - aStringValue, - anArrayValue, - anObjectValue - }; - 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 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")); - } - - 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)); - } -} diff --git a/src/test/java/com/launchdarkly/sdk/UserAttributeTest.java b/src/test/java/com/launchdarkly/sdk/UserAttributeTest.java deleted file mode 100644 index 04a2d5f77..000000000 --- a/src/test/java/com/launchdarkly/sdk/UserAttributeTest.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.launchdarkly.sdk; - -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -@SuppressWarnings("javadoc") -public class UserAttributeTest { - @Test - public void keyAttribute() { - assertEquals("key", UserAttribute.KEY.getName()); - assertTrue(UserAttribute.KEY.isBuiltIn()); - } - - @Test - public void secondaryKeyAttribute() { - assertEquals("secondary", UserAttribute.SECONDARY_KEY.getName()); - assertTrue(UserAttribute.SECONDARY_KEY.isBuiltIn()); - } - - @Test - public void ipAttribute() { - assertEquals("ip", UserAttribute.IP.getName()); - assertTrue(UserAttribute.IP.isBuiltIn()); - } - - @Test - public void emailAttribute() { - assertEquals("email", UserAttribute.EMAIL.getName()); - assertTrue(UserAttribute.EMAIL.isBuiltIn()); - } - - @Test - public void nameAttribute() { - assertEquals("name", UserAttribute.NAME.getName()); - assertTrue(UserAttribute.NAME.isBuiltIn()); - } - - @Test - public void avatarAttribute() { - assertEquals("avatar", UserAttribute.AVATAR.getName()); - assertTrue(UserAttribute.AVATAR.isBuiltIn()); - } - - @Test - public void firstNameAttribute() { - assertEquals("firstName", UserAttribute.FIRST_NAME.getName()); - assertTrue(UserAttribute.FIRST_NAME.isBuiltIn()); - } - - @Test - public void lastNameAttribute() { - assertEquals("lastName", UserAttribute.LAST_NAME.getName()); - assertTrue(UserAttribute.LAST_NAME.isBuiltIn()); - } - - @Test - public void anonymousAttribute() { - assertEquals("anonymous", UserAttribute.ANONYMOUS.getName()); - assertTrue(UserAttribute.ANONYMOUS.isBuiltIn()); - } - - @Test - public void customAttribute() { - assertEquals("things", UserAttribute.forName("things").getName()); - assertFalse(UserAttribute.forName("things").isBuiltIn()); - } -} From 172045e818ba3daaa2438cccd8da386aaeb48d7f Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 9 Mar 2020 19:54:34 -0700 Subject: [PATCH 350/673] rm debugging --- build.gradle | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/build.gradle b/build.gradle index c87f76e85..6ea463c4c 100644 --- a/build.gradle +++ b/build.gradle @@ -193,10 +193,7 @@ task javadocJar(type: Jar, dependsOn: javadoc) { } javadoc { - source configurations.doc.collect { - System.out.println(it) - zipTree(it) - } + source configurations.doc.collect { zipTree(it) } include '**/*.java' } From 3ed89f26e44ba334b57532ff675a6b32bd4fb567 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 9 Mar 2020 19:59:54 -0700 Subject: [PATCH 351/673] don't shade classes from common package --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 6ea463c4c..9785a3a2f 100644 --- a/build.gradle +++ b/build.gradle @@ -213,7 +213,7 @@ if (JavaVersion.current().isJava8Compatible()) { // 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 = [] + def names = [ "com.launchdarkly.sdk" ] // base package classes come from launchdarkly-java-sdk-common project.convention.getPlugin(JavaPluginConvention).sourceSets.main.output.each { baseDir -> if (baseDir.getPath().contains("classes" + File.separator + "java" + File.separator + "main")) { baseDir.eachFileRecurse { f -> From ff4969250555f883ba7d493dd3c4b3f93c903937 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 9 Mar 2020 20:10:33 -0700 Subject: [PATCH 352/673] also include common classes in thin jar --- build.gradle | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/build.gradle b/build.gradle index 9785a3a2f..f37d3c1b8 100644 --- a/build.gradle +++ b/build.gradle @@ -31,7 +31,10 @@ repositories { } configurations { - doc { + commonClasses { + transitive false + } + commonDoc { transitive false } } @@ -103,7 +106,8 @@ dependencies { runtime libraries.internal, libraries.external testImplementation libraries.test, libraries.internal, libraries.external - doc "com.launchdarkly:launchdarkly-java-sdk-common:$sdkCommonVersion:sources" + commonClasses "com.launchdarkly:launchdarkly-java-sdk-common:$sdkCommonVersion" + commonDoc "com.launchdarkly:launchdarkly-java-sdk-common:$sdkCommonVersion:sources" // 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. @@ -124,6 +128,8 @@ jar { // but is opt-in since users will have to specify it. classifier = 'thin' + from configurations.commonClasses.collect { zipTree(it) } + // 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 { @@ -193,7 +199,7 @@ task javadocJar(type: Jar, dependsOn: javadoc) { } javadoc { - source configurations.doc.collect { zipTree(it) } + source configurations.commonDoc.collect { zipTree(it) } include '**/*.java' } From 7274ae0e93cebdb4d473e541a86b06e25741d59c Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 11 Mar 2020 14:18:29 -0700 Subject: [PATCH 353/673] indents --- build.gradle | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/build.gradle b/build.gradle index f37d3c1b8..c4dd15a5c 100644 --- a/build.gradle +++ b/build.gradle @@ -56,14 +56,14 @@ ext { sdkBaseName = "launchdarkly-java-server-sdk" sdkCommonVersion = "1.0.0-SNAPSHOT" - commonsCodecVersion = "1.10" - gsonVersion = "2.7" - guavaVersion = "28.2-jre" - jedisVersion = "2.9.0" - okhttpEventsourceVersion = "2.0.1" - slf4jVersion = "1.7.21" - snakeyamlVersion = "1.19" - + commonsCodecVersion = "1.10" + gsonVersion = "2.7" + guavaVersion = "28.2-jre" + jedisVersion = "2.9.0" + okhttpEventsourceVersion = "2.0.1" + slf4jVersion = "1.7.21" + snakeyamlVersion = "1.19" + // 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" ] @@ -119,7 +119,7 @@ task wrapper(type: Wrapper) { } checkstyle { - configFile file("${project.rootDir}/checkstyle.xml") + configFile file("${project.rootDir}/checkstyle.xml") } jar { @@ -205,15 +205,15 @@ javadoc { // 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 + 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') - } + options.addStringOption('Xwerror', '-quiet') + } } // Returns the names of all Java packages defined in this library - not including From 8c9ae7a92cfe637aeed18a4b11cd8b6f09115bab Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 11 Mar 2020 14:21:39 -0700 Subject: [PATCH 354/673] better package comment --- .../java/com/launchdarkly/sdk/server/package-info.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/package-info.java b/src/main/java/com/launchdarkly/sdk/server/package-info.java index 50149adce..501216981 100644 --- a/src/main/java/com/launchdarkly/sdk/server/package-info.java +++ b/src/main/java/com/launchdarkly/sdk/server/package-info.java @@ -1,8 +1,10 @@ /** - * The main package for the LaunchDarkly Java SDK. + * Main package for the LaunchDarkly Server-Side Java SDK, containing the client and configuration classes. *

    - * You will most often use {@link com.launchdarkly.sdk.server.LDClient} (the SDK client), - * {@link com.launchdarkly.sdk.server.LDConfig} (configuration options for the client), and - * {@link com.launchdarkly.sdk.LDUser} (user properties for feature flag evaluation). + * You will most often use {@link com.launchdarkly.sdk.server.LDClient} (the SDK client) and + * {@link com.launchdarkly.sdk.server.LDConfig} (configuration options for the client). + *

    + * Other commonly used types such as {@link com.launchdarkly.sdk.LDUser} are in the {@code com.launchdarkly.sdk} + * package, since those are not server-side-specific and are shared with the LaunchDarkly Android SDK. */ package com.launchdarkly.sdk.server; From 6c9434e0dd08e85b65a05f3ebeb323f5a18b41d0 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 12 Mar 2020 11:17:29 -0700 Subject: [PATCH 355/673] add mechanism for data store status reporting --- .../server/DataStoreStatusProviderImpl.java | 38 +++ .../com/launchdarkly/sdk/server/LDClient.java | 8 + .../sdk/server/LDClientInterface.java | 14 ++ .../sdk/server/integrations/CacheMonitor.java | 148 ----------- .../PersistentDataStoreBuilder.java | 45 ++-- .../PersistentDataStoreStatusManager.java | 150 +++++++++++ .../PersistentDataStoreWrapper.java | 233 ++++++++++++------ .../integrations/RedisDataStoreImpl.java | 10 + .../interfaces/DataStoreStatusProvider.java | 210 ++++++++++++++++ .../interfaces/PersistentDataStore.java | 14 ++ .../DataStoreWrapperWithFakeStatus.java | 95 +++++++ .../PersistentDataStoreWrapperTest.java | 152 ++++++++++-- 12 files changed, 846 insertions(+), 271 deletions(-) create mode 100644 src/main/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImpl.java delete mode 100644 src/main/java/com/launchdarkly/sdk/server/integrations/CacheMonitor.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreStatusManager.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProvider.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/integrations/DataStoreWrapperWithFakeStatus.java diff --git a/src/main/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImpl.java b/src/main/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImpl.java new file mode 100644 index 000000000..eb0b41cb0 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImpl.java @@ -0,0 +1,38 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; + +// Simple delegator to ensure that LDClient.getDataStoreStatusProvider() never returns null and that +// the application isn't given direct access to the store. +final class DataStoreStatusProviderImpl implements DataStoreStatusProvider { + private final DataStoreStatusProvider delegateTo; + + DataStoreStatusProviderImpl(DataStore store) { + delegateTo = store instanceof DataStoreStatusProvider ? (DataStoreStatusProvider)store : null; + } + + @Override + public Status getStoreStatus() { + return delegateTo == null ? null : delegateTo.getStoreStatus(); + } + + @Override + public void addStatusListener(StatusListener listener) { + if (delegateTo != null) { + delegateTo.addStatusListener(listener); + } + } + + @Override + public void removeStatusListener(StatusListener listener) { + if (delegateTo != null) { + delegateTo.removeStatusListener(listener); + } + } + + @Override + public CacheStats getCacheStats() { + return delegateTo == null ? null : delegateTo.getCacheStats(); + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClient.java b/src/main/java/com/launchdarkly/sdk/server/LDClient.java index d0e41bd29..9a940ecc5 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClient.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClient.java @@ -9,6 +9,7 @@ import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; @@ -59,6 +60,7 @@ public final class LDClient implements LDClientInterface { final EventProcessor eventProcessor; final DataSource dataSource; final DataStore dataStore; + private final DataStoreStatusProvider dataStoreStatusProvider; /** * Creates a new client instance that connects to LaunchDarkly with the default configuration. In most @@ -105,6 +107,7 @@ public LDClient(String sdkKey, LDConfig config) { DataStoreFactory factory = config.dataStoreFactory == null ? Components.inMemoryDataStore() : config.dataStoreFactory; this.dataStore = factory.createDataStore(context); + this.dataStoreStatusProvider = new DataStoreStatusProviderImpl(this.dataStore); this.evaluator = new Evaluator(new Evaluator.Getters() { public DataModel.FeatureFlag getFlag(String key) { @@ -397,6 +400,11 @@ public void unregisterFlagChangeListener(FlagChangeListener listener) { } } + @Override + public DataStoreStatusProvider getDataStoreStatusProvider() { + return dataStoreStatusProvider; + } + @Override public void close() throws IOException { logger.info("Closing LaunchDarkly Client"); diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClientInterface.java b/src/main/java/com/launchdarkly/sdk/server/LDClientInterface.java index 416781bdc..20220e432 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClientInterface.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClientInterface.java @@ -3,6 +3,7 @@ import com.launchdarkly.sdk.EvaluationDetail; import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; import java.io.Closeable; @@ -263,6 +264,19 @@ public interface LDClientInterface extends Closeable { */ void unregisterFlagChangeListener(FlagChangeListener listener); + /** + * Returns an interface for tracking the status of a persistent data store. + *

    + * The {@link DataStoreStatusProvider} has methods for checking whether the data store is (as far as the + * SDK knows) currently operational, tracking changes in this status, and getting cache statistics. These + * are only relevant for a persistent data store; if you are using an in-memory data store, then this + * method will return a stub object that provides no information. + * + * @return a {@link DataStoreStatusProvider} + * @since 5.0.0 + */ + DataStoreStatusProvider getDataStoreStatusProvider(); + /** * For more info: https://github.com/launchdarkly/js-client#secure-mode * @param user the user to be hashed along with the SDK key diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/CacheMonitor.java b/src/main/java/com/launchdarkly/sdk/server/integrations/CacheMonitor.java deleted file mode 100644 index 506dd0145..000000000 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/CacheMonitor.java +++ /dev/null @@ -1,148 +0,0 @@ -package com.launchdarkly.sdk.server.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 PersistentDataStoreBuilder#cacheMonitor(CacheMonitor) - * @since 4.12.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 - */ - 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. - * - * @since 4.12.0 - */ - 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 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) { - 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/sdk/server/integrations/PersistentDataStoreBuilder.java b/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreBuilder.java index 9a662f132..d80e825e6 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreBuilder.java @@ -4,6 +4,7 @@ import com.launchdarkly.sdk.server.interfaces.ClientContext; import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; import com.launchdarkly.sdk.server.interfaces.PersistentDataStore; import com.launchdarkly.sdk.server.interfaces.PersistentDataStoreFactory; @@ -44,10 +45,10 @@ public abstract class PersistentDataStoreBuilder implements DataStoreFactory { */ public static final Duration DEFAULT_CACHE_TTL = Duration.ofSeconds(15); - protected final PersistentDataStoreFactory persistentDataStoreFactory; - protected Duration cacheTime = DEFAULT_CACHE_TTL; - protected StaleValuesPolicy staleValuesPolicy = StaleValuesPolicy.EVICT; - protected CacheMonitor cacheMonitor = null; + protected final PersistentDataStoreFactory persistentDataStoreFactory; // see Components for why this is not private + private Duration cacheTime = DEFAULT_CACHE_TTL; + private StaleValuesPolicy staleValuesPolicy = StaleValuesPolicy.EVICT; + private boolean recordCacheStats = false; /** * Possible values for {@link #staleValuesPolicy(StaleValuesPolicy)}. @@ -178,40 +179,30 @@ public PersistentDataStoreBuilder staleValuesPolicy(StaleValuesPolicy staleValue } /** - * Provides a conduit for an application to monitor the effectiveness of the in-memory cache. + * Enables monitoring 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 modify the - * {@link CacheMonitor} instance to make the caching statistics available through that 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();
    -   * 
    + * If set to true, this makes caching statistics available through the {@link DataStoreStatusProvider} + * that you can obtain from the client instance. This may slightly decrease performance, due to the + * need to record statistics for each cache operation. + *

    + * By default, it is false: statistics will not be recorded and the {@link DataStoreStatusProvider#getCacheStats()} + * method will return null. * - * @param cacheMonitor an instance of {@link CacheMonitor} + * @param recordCacheStats true to record caching statiistics * @return the builder + * @since 5.0.0 */ - public PersistentDataStoreBuilder cacheMonitor(CacheMonitor cacheMonitor) { - this.cacheMonitor = cacheMonitor; + public PersistentDataStoreBuilder recordCacheStats(boolean recordCacheStats) { + this.recordCacheStats = recordCacheStats; return this; } - + /** * Called by the SDK to create the data store instance. */ @Override public DataStore createDataStore(ClientContext context) { PersistentDataStore core = persistentDataStoreFactory.createPersistentDataStore(context); - return new PersistentDataStoreWrapper(core, cacheTime, staleValuesPolicy, cacheMonitor); + return new PersistentDataStoreWrapper(core, cacheTime, staleValuesPolicy, recordCacheStats); } } diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreStatusManager.java b/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreStatusManager.java new file mode 100644 index 000000000..948771a95 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreStatusManager.java @@ -0,0 +1,150 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.google.common.util.concurrent.ThreadFactoryBuilder; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider.Status; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider.StatusListener; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; + +/** + * Used internally to encapsulate the data store status broadcasting mechanism for PersistentDataStoreWrapper. + *

    + * This is currently only used by PersistentDataStoreWrapper, but encapsulating it in its own class helps with + * clarity and also lets us reuse this logic in tests. + */ +final class PersistentDataStoreStatusManager { + private static final Logger logger = LoggerFactory.getLogger(PersistentDataStoreStatusManager.class); + private static final int POLL_INTERVAL_MS = 500; + + private final List listeners = new ArrayList<>(); + private final ScheduledExecutorService scheduler; + private final Callable statusPollFn; + private final boolean refreshOnRecovery; + private volatile boolean lastAvailable; + private volatile ScheduledFuture pollerFuture; + + PersistentDataStoreStatusManager(boolean refreshOnRecovery, boolean availableNow, Callable statusPollFn) { + this.refreshOnRecovery = refreshOnRecovery; + this.lastAvailable = availableNow; + this.statusPollFn = statusPollFn; + + ThreadFactory threadFactory = new ThreadFactoryBuilder() + .setNameFormat("LaunchDarkly-DataStoreStatusManager-%d") + .build(); + scheduler = Executors.newScheduledThreadPool(2, threadFactory); + } + + synchronized void addStatusListener(StatusListener listener) { + listeners.add(listener); + } + + synchronized void removeStatusListener(StatusListener listener) { + listeners.remove(listener); + } + + void updateAvailability(boolean available) { + StatusListener[] copyOfListeners = null; + synchronized (this) { + if (lastAvailable == available) { + return; + } + lastAvailable = available; + copyOfListeners = listeners.toArray(new StatusListener[listeners.size()]); + } + + StatusImpl status = new StatusImpl(available, available && refreshOnRecovery); + + if (available) { + logger.warn("Persistent store is available again"); + } + + // Notify all the subscribers (on a worker thread, so we can't be blocked by a slow listener). + if (copyOfListeners.length > 0) { + scheduler.schedule(new StatusNotificationTask(status, copyOfListeners), 0, TimeUnit.MILLISECONDS); + } + + // If the store has just become unavailable, start a poller to detect when it comes back. If it has + // become available, stop any polling we are currently doing. + if (available) { + if (pollerFuture != null) { // don't need to synchronize access here because the state transition was already synchronized above + pollerFuture.cancel(false); + pollerFuture = null; + } + } else { + logger.warn("Detected persistent store unavailability; updates will be cached until it recovers"); + + // Start polling until the store starts working again + Runnable pollerTask = new Runnable() { + public void run() { + try { + if (statusPollFn.call()) { + updateAvailability(true); + } + } catch (Exception e) { + logger.error("Unexpected error from data store status function: {0}", e); + } + } + }; + pollerFuture = scheduler.scheduleAtFixedRate(pollerTask, POLL_INTERVAL_MS, POLL_INTERVAL_MS, TimeUnit.MILLISECONDS); + } + } + + synchronized boolean isAvailable() { + return lastAvailable; + } + + void close() { + scheduler.shutdown(); + } + + static final class StatusImpl implements Status { + private final boolean available; + private final boolean needsRefresh; + + StatusImpl(boolean available, boolean needsRefresh) { + this.available = available; + this.needsRefresh = needsRefresh; + } + + @Override + public boolean isAvailable() { + return available; + } + + @Override + public boolean isRefreshNeeded() { + return needsRefresh; + } + } + + private static final class StatusNotificationTask implements Runnable { + private final Status status; + private final StatusListener[] listeners; + + StatusNotificationTask(Status status, StatusListener[] listeners) { + this.status = status; + this.listeners = listeners; + } + + public void run() { + for (StatusListener listener: listeners) { + try { + listener.dataStoreStatusChanged(status); + } catch (Exception e) { + logger.error("Unexpected error from StatusListener: {0}", e); + } + } + } + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapper.java b/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapper.java index 0f66f7ea1..b07b7802d 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapper.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapper.java @@ -3,25 +3,30 @@ 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.collect.ImmutableList; import com.google.common.util.concurrent.ListeningExecutorService; import com.google.common.util.concurrent.MoreExecutors; import com.google.common.util.concurrent.ThreadFactoryBuilder; +import com.google.common.util.concurrent.UncheckedExecutionException; import com.launchdarkly.sdk.server.interfaces.DataStore; -import com.launchdarkly.sdk.server.interfaces.PersistentDataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.SerializedItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.PersistentDataStore; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.io.IOException; import java.time.Duration; import java.util.AbstractMap; +import java.util.HashSet; import java.util.Map; -import java.util.concurrent.Callable; +import java.util.Set; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -40,14 +45,17 @@ *

    * This class is only constructed by {@link PersistentDataStoreBuilder}. */ -class PersistentDataStoreWrapper implements DataStore { +final class PersistentDataStoreWrapper implements DataStore, DataStoreStatusProvider { + private static final Logger logger = LoggerFactory.getLogger(PersistentDataStoreWrapper.class); private static final String CACHE_REFRESH_THREAD_POOL_NAME_FORMAT = "CachingStoreWrapper-refresher-pool-%d"; private final PersistentDataStore core; private final LoadingCache> itemCache; private final LoadingCache> allCache; private final LoadingCache initCache; + private final PersistentDataStoreStatusManager statusManager; private final boolean cacheIndefinitely; + private final Set cachedDataKinds = new HashSet<>(); // this map is used in pollForAvailability() private final AtomicBoolean inited = new AtomicBoolean(false); private final ListeningExecutorService executorService; @@ -55,7 +63,7 @@ class PersistentDataStoreWrapper implements DataStore { final PersistentDataStore core, Duration cacheTtl, PersistentDataStoreBuilder.StaleValuesPolicy staleValuesPolicy, - CacheMonitor cacheMonitor + boolean recordCacheStats ) { this.core = core; @@ -98,20 +106,17 @@ public Boolean load(String key) throws Exception { executorService = null; } - itemCache = newCacheBuilder(cacheTtl, staleValuesPolicy, cacheMonitor).build(itemLoader); - allCache = newCacheBuilder(cacheTtl, staleValuesPolicy, cacheMonitor).build(allLoader); - initCache = newCacheBuilder(cacheTtl, staleValuesPolicy, cacheMonitor).build(initLoader); - - if (cacheMonitor != null) { - cacheMonitor.setSource(new CacheStatsSource()); - } + itemCache = newCacheBuilder(cacheTtl, staleValuesPolicy, recordCacheStats).build(itemLoader); + allCache = newCacheBuilder(cacheTtl, staleValuesPolicy, recordCacheStats).build(allLoader); + initCache = newCacheBuilder(cacheTtl, staleValuesPolicy, recordCacheStats).build(initLoader); } + statusManager = new PersistentDataStoreStatusManager(!cacheIndefinitely, true, this::pollAvailabilityAfterOutage); } private static CacheBuilder newCacheBuilder( Duration cacheTtl, PersistentDataStoreBuilder.StaleValuesPolicy staleValuesPolicy, - CacheMonitor cacheMonitor + boolean recordCacheStats ) { CacheBuilder builder = CacheBuilder.newBuilder(); boolean isInfiniteTtl = cacheTtl.isNegative(); @@ -126,7 +131,7 @@ private static CacheBuilder newCacheBuilder( builder = builder.refreshAfterWrite(cacheTtl); } } - if (cacheMonitor != null) { + if (recordCacheStats) { builder = builder.recordStats(); } return builder; @@ -137,6 +142,7 @@ public void close() throws IOException { if (executorService != null) { executorService.shutdownNow(); } + statusManager.close(); core.close(); } @@ -163,21 +169,19 @@ public boolean isInitialized() { @Override public void init(FullDataSet allData) { + synchronized (cachedDataKinds) { + cachedDataKinds.clear(); + for (Map.Entry> e: allData.getData()) { + cachedDataKinds.add(e.getKey()); + } + } ImmutableList.Builder>> allBuilder = ImmutableList.builder(); for (Map.Entry> e0: allData.getData()) { DataKind kind = e0.getKey(); - ImmutableList.Builder> itemsBuilder = ImmutableList.builder(); - for (Map.Entry e1: e0.getValue().getItems()) { - itemsBuilder.add(new AbstractMap.SimpleEntry<>(e1.getKey(), serialize(kind, e1.getValue()))); - } - allBuilder.add(new AbstractMap.SimpleEntry<>(kind, new KeyedItems<>(itemsBuilder.build()))); - } - RuntimeException failure = null; - try { - core.init(new FullDataSet<>(allBuilder.build())); - } catch (RuntimeException e) { - failure = e; + KeyedItems items = serializeAll(kind, e0.getValue()); + allBuilder.add(new AbstractMap.SimpleEntry<>(kind, items)); } + RuntimeException failure = initCore(new FullDataSet<>(allBuilder.build())); if (itemCache != null && allCache != null) { itemCache.invalidateAll(); allCache.invalidateAll(); @@ -205,42 +209,67 @@ public void init(FullDataSet allData) { } } + private RuntimeException initCore(FullDataSet allData) { + try { + core.init(allData); + processError(null); + return null; + } catch (RuntimeException e) { + processError(e); + return e; + } + } + @Override public ItemDescriptor get(DataKind kind, String key) { - if (itemCache != null) { - try { - return itemCache.get(CacheKey.forItem(kind, key)).orNull(); - } catch (ExecutionException e) { - throw new RuntimeException(e); - } + try { + ItemDescriptor ret = itemCache != null ? itemCache.get(CacheKey.forItem(kind, key)).orNull() : + getAndDeserializeItem(kind, key); + processError(null); + return ret; + } catch (Exception e) { + processError(e); + throw getAsRuntimeException(e); } - return getAndDeserializeItem(kind, key); } @Override public KeyedItems getAll(DataKind kind) { - if (allCache != null) { - try { - return allCache.get(kind); - } catch (ExecutionException e) { - throw new RuntimeException(e); - } + try { + KeyedItems ret; + ret = allCache != null ? allCache.get(kind) : getAllAndDeserialize(kind); + processError(null); + return ret; + } catch (Exception e) { + processError(e); + throw getAsRuntimeException(e); } - return getAllAndDeserialize(kind); } + private static RuntimeException getAsRuntimeException(Exception e) { + Throwable t = (e instanceof ExecutionException || e instanceof UncheckedExecutionException) + ? e.getCause() // this is a wrapped exception thrown by a cache + : e; + return t instanceof RuntimeException ? (RuntimeException)t : new RuntimeException(t); + } + @Override public boolean upsert(DataKind kind, String key, ItemDescriptor item) { + synchronized (cachedDataKinds) { + cachedDataKinds.add(kind); + } SerializedItemDescriptor serializedItem = serialize(kind, item); boolean updated = false; RuntimeException failure = null; try { updated = core.upsert(kind, key, serializedItem); + processError(null); } 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. + processError(e); if (!cacheIndefinitely) { throw e; @@ -257,14 +286,8 @@ public boolean upsert(DataKind kind, String key, ItemDescriptor item) { itemCache.refresh(cacheKey); } } else { - try { - Optional oldItem = itemCache.get(cacheKey); - if (oldItem.isPresent() && oldItem.get().getVersion() < item.getVersion()) { - itemCache.put(cacheKey, Optional.of(item)); - } - } catch (ExecutionException e) { - // An exception here means that the underlying database is down *and* there was no - // cached item; in that case we just go ahead and update the cache. + Optional oldItem = itemCache.getIfPresent(cacheKey); + if (oldItem == null || !oldItem.isPresent() || oldItem.get().getVersion() < item.getVersion()) { itemCache.put(cacheKey, Optional.of(item)); } } @@ -275,15 +298,8 @@ public boolean upsert(DataKind kind, String key, ItemDescriptor item) { // update the item within the existing "all items" entry (since we want things to still work // even if the underlying store is unavailable). if (cacheIndefinitely) { - try { - KeyedItems cachedAll = allCache.get(kind); - allCache.put(kind, updateSingleItem(cachedAll, key, item)); - } catch (ExecutionException 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. - } + KeyedItems cachedAll = allCache.getIfPresent(kind); + allCache.put(kind, updateSingleItem(cachedAll, key, item)); } else { allCache.invalidate(kind); } @@ -293,17 +309,36 @@ public boolean upsert(DataKind kind, String key, ItemDescriptor item) { } return updated; } - - /** - * Return the underlying Guava cache stats object. - * - * @return the cache statistics object - */ + + @Override + public Status getStoreStatus() { + return new PersistentDataStoreStatusManager.StatusImpl(statusManager.isAvailable(), false); + } + + @Override + public void addStatusListener(StatusListener listener) { + statusManager.addStatusListener(listener); + } + + @Override + public void removeStatusListener(StatusListener listener) { + statusManager.removeStatusListener(listener); + } + + @Override public CacheStats getCacheStats() { - if (itemCache != null) { - return itemCache.stats(); + if (itemCache == null || allCache == null) { + return null; } - return null; + com.google.common.cache.CacheStats itemStats = itemCache.stats(); + com.google.common.cache.CacheStats allStats = allCache.stats(); + return new 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()); } /** @@ -337,6 +372,14 @@ private SerializedItemDescriptor serialize(DataKind kind, ItemDescriptor itemDes return new SerializedItemDescriptor(itemDesc.getVersion(), isDeleted, kind.serialize(itemDesc)); } + private KeyedItems serializeAll(DataKind kind, KeyedItems items) { + ImmutableList.Builder> itemsBuilder = ImmutableList.builder(); + for (Map.Entry e: items.getItems()) { + itemsBuilder.add(new AbstractMap.SimpleEntry<>(e.getKey(), serialize(kind, e.getValue()))); + } + return new KeyedItems<>(itemsBuilder.build()); + } + private ItemDescriptor deserialize(DataKind kind, SerializedItemDescriptor serializedItemDesc) { if (serializedItemDesc.isDeleted() || serializedItemDesc.getSerializedItem() == null) { return ItemDescriptor.deletedItem(serializedItemDesc.getVersion()); @@ -354,30 +397,58 @@ private KeyedItems updateSingleItem(KeyedItems i // This is somewhat inefficient but it's preferable to use immutable data structures in the cache. return new KeyedItems<>( ImmutableList.copyOf(concat( - filter(items.getItems(), e -> !e.getKey().equals(key)), + items == null ? ImmutableList.of() : filter(items.getItems(), e -> !e.getKey().equals(key)), ImmutableList.>of(new AbstractMap.SimpleEntry<>(key, item)) ) )); } - private final class CacheStatsSource implements Callable { - public CacheMonitor.CacheStats call() { - if (itemCache == null || allCache == null) { - return null; + private void processError(Throwable error) { + if (error == null) { + // If we're waiting to recover after a failure, we'll let the polling routine take care + // of signaling success. Even if we could signal success a little earlier based on the + // success of whatever operation we just did, we'd rather avoid the overhead of acquiring + // w.statusLock every time we do anything. So we'll just do nothing here. + return; + } + statusManager.updateAvailability(false); + } + + private boolean pollAvailabilityAfterOutage() { + if (!core.isStoreAvailable()) { + return false; + } + + if (cacheIndefinitely && allCache != null) { + // If we're in infinite cache mode, then we can assume the cache has a full set of current + // flag data (since presumably the data source has still been running) and we can just + // write the contents of the cache to the underlying data store. + DataKind[] allKinds; + synchronized (cachedDataKinds) { + allKinds = cachedDataKinds.toArray(new DataKind[cachedDataKinds.size()]); + } + ImmutableList.Builder>> builder = ImmutableList.builder(); + for (DataKind kind: allKinds) { + KeyedItems items = allCache.getIfPresent(kind); + if (items != null) { + builder.add(new AbstractMap.SimpleEntry<>(kind, serializeAll(kind, items))); + } + } + RuntimeException e = initCore(new FullDataSet<>(builder.build())); + if (e == null) { + logger.warn("Successfully updated persistent store from cached data"); + } else { + // We failed to write the cached data to the underlying store. In this case, + // initCore() has already put us back into the failed state. The only further + // thing we can do is to log a note about what just happened. + logger.error("Tried to write cached data to persistent store after a store outage, but failed: {0}", e); } - 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()); } + + return true; } - - private static class CacheKey { + + private static final class CacheKey { final DataKind kind; final String key; diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/RedisDataStoreImpl.java b/src/main/java/com/launchdarkly/sdk/server/integrations/RedisDataStoreImpl.java index 7d1133cfe..83ba35000 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/RedisDataStoreImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/RedisDataStoreImpl.java @@ -156,6 +156,16 @@ public boolean isInitialized() { } } + @Override + public boolean isStoreAvailable() { + try { + isInitialized(); // don't care about the return value, just that it doesn't throw an exception + return true; + } catch (Exception e) { // don't care about exception class, since any exception means the Redis request couldn't be made + return false; + } + } + @Override public void close() throws IOException { logger.info("Closing LaunchDarkly RedisFeatureStore"); diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProvider.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProvider.java new file mode 100644 index 000000000..aeaf35a47 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProvider.java @@ -0,0 +1,210 @@ +package com.launchdarkly.sdk.server.interfaces; + +import com.launchdarkly.sdk.server.integrations.PersistentDataStoreBuilder; + +import java.util.Objects; + +/** + * An interface for querying the status of a persistent data store. + *

    + * An implementation of this interface is returned by {@link com.launchdarkly.sdk.server.LDClientInterface#getDataStoreStatusProvider}. + * If the data store is a persistent data store, then these methods are implemented by the SDK; if it is a custom + * class that implements this interface, then these methods delegate to the corresponding methods of the class; + * if it is the default in-memory data store, then these methods do nothing and return null values. + * + * @since 5.0.0 + */ +public interface DataStoreStatusProvider { + /** + * Returns the current status of the store. + * @return the latest status + */ + public Status getStoreStatus(); + + /** + * Subscribes for notifications of status changes. + *

    + * Applications may wish to know if there is an outage in a persistent data store, since that could mean that + * flag evaluations are unable to get the flag data from the store (unless it is currently cached) and therefore + * might return default values. + *

    + * If the SDK receives an exception while trying to query or update the data store, then it notifies listeners + * that the store appears to be offline ({@link Status#isAvailable()} is false) and begins polling the store + * at intervals until a query succeeds. Once it succeeds, it notifies listeners again with {@link Status#isAvailable()} + * set to true. + *

    + * This method has no effect if the data store implementation does not support status tracking, such as if you + * are using the default in-memory store rather than a persistent store. + * + * @param listener the listener to add + */ + public void addStatusListener(StatusListener listener); + + /** + * Unsubscribes from notifications of status changes. + *

    + * This method has no effect if the data store implementation does not support status tracking, such as if you + * are using the default in-memory store rather than a persistent store. + * + * @param listener the listener to remove; if no such listener was added, this does nothing + */ + public void removeStatusListener(StatusListener listener); + + /** + * Queries the current cache statistics, if this is a persistent store with caching enabled. + *

    + * This method returns null if the data store implementation does not support cache statistics because it is + * not a persistent store, or because you did not enable cache monitoring with + * {@link PersistentDataStoreBuilder#recordCacheStats(boolean)}. + * + * @return a {@link CacheStats} instance; null if not applicable + */ + public CacheStats getCacheStats(); + + /** + * Information about a status change. + */ + public static interface Status { + /** + * Returns true if the SDK believes the data store is now available. + *

    + * This property is normally true. If the SDK receives an exception while trying to query or update the data + * store, then it sets this property to false (notifying listeners, if any) and polls the store at intervals + * until a query succeeds. Once it succeeds, it sets the property back to true (again notifying listeners). + * + * @return true if store is available + */ + public boolean isAvailable(); + + /** + * Returns true if the store may be out of date due to a previous outage, so the SDK should attempt to refresh + * all feature flag data and rewrite it to the store. + *

    + * This property is not meaningful to application code. + * + * @return true if data should be rewritten + */ + public boolean isRefreshNeeded(); + } + + /** + * Interface for receiving status change notifications. + */ + public static interface StatusListener { + /** + * Called when the store status has changed. + * @param newStatus the new status + */ + public void dataStoreStatusChanged(Status newStatus); + } + + /** + * 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. + * + * @see DataStoreStatusProvider#getCacheStats() + * @see PersistentDataStoreBuilder#recordCacheStats(boolean) + * @since 4.12.0 + */ + 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 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) { + 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/sdk/server/interfaces/PersistentDataStore.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/PersistentDataStore.java index 03c0794e3..410baa062 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/PersistentDataStore.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/PersistentDataStore.java @@ -121,4 +121,18 @@ public interface PersistentDataStore extends Closeable { * @return true if the store has been initialized */ boolean isInitialized(); + + /** + * Tests whether the data store seems to be functioning normally. + *

    + * This should not be a detailed test of different kinds of operations, but just the smallest possible + * operation to determine whether (for instance) we can reach the database. + *

    + * Whenever one of the store's other methods throws an exception, the SDK will assume that it may have + * become unavailable (e.g. the database connection was lost). The SDK will then call + * {@link #isStoreAvailable()} at intervals until it returns true. + * + * @return true if the underlying data store is reachable + */ + public boolean isStoreAvailable(); } diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/DataStoreWrapperWithFakeStatus.java b/src/test/java/com/launchdarkly/sdk/server/integrations/DataStoreWrapperWithFakeStatus.java new file mode 100644 index 000000000..7669d536a --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/DataStoreWrapperWithFakeStatus.java @@ -0,0 +1,95 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; + +import java.io.IOException; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.Callable; +import java.util.concurrent.LinkedBlockingDeque; + +@SuppressWarnings("javadoc") +public class DataStoreWrapperWithFakeStatus implements DataStore, DataStoreStatusProvider { + private final DataStore store; + private final PersistentDataStoreStatusManager statusManager; + private final BlockingQueue> initQueue = new LinkedBlockingDeque<>(); + private volatile boolean available; + + public DataStoreWrapperWithFakeStatus(DataStore store, boolean refreshOnRecovery) { + this.store = store; + this.statusManager = new PersistentDataStoreStatusManager(refreshOnRecovery, true, new Callable() { + public Boolean call() throws Exception { + return available; + } + }); + this.available = true; + } + + public void updateAvailability(boolean available) { + this.available = available; + statusManager.updateAvailability(available); + } + + public FullDataSet awaitInit() { + try { + return initQueue.take(); + } catch (InterruptedException e) { // shouldn't happen in our tests + throw new RuntimeException(e); + } + } + + @Override + public void close() throws IOException { + store.close(); + statusManager.close(); + } + + @Override + public Status getStoreStatus() { + return new PersistentDataStoreStatusManager.StatusImpl(available, false); + } + + @Override + public void addStatusListener(StatusListener listener) { + statusManager.addStatusListener(listener); + } + + @Override + public void removeStatusListener(StatusListener listener) { + statusManager.removeStatusListener(listener); + } + + @Override + public CacheStats getCacheStats() { + return null; + } + + @Override + public void init(FullDataSet allData) { + store.init(allData); + } + + @Override + public ItemDescriptor get(DataKind kind, String key) { + return store.get(kind, key); + } + + @Override + public KeyedItems getAll(DataKind kind) { + return store.getAll(kind); + } + + @Override + public boolean upsert(DataKind kind, String key, ItemDescriptor item) { + return store.upsert(kind, key, item); + } + + @Override + public boolean isInitialized() { + return store.isInitialized(); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapperTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapperTest.java index a5461fa0f..987c36778 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapperTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapperTest.java @@ -4,16 +4,15 @@ import com.google.common.collect.ImmutableMap; import com.launchdarkly.sdk.server.DataStoreTestTypes.DataBuilder; import com.launchdarkly.sdk.server.DataStoreTestTypes.TestItem; -import com.launchdarkly.sdk.server.integrations.CacheMonitor; -import com.launchdarkly.sdk.server.integrations.PersistentDataStoreBuilder; -import com.launchdarkly.sdk.server.integrations.PersistentDataStoreWrapper; -import com.launchdarkly.sdk.server.interfaces.PersistentDataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.SerializedItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.PersistentDataStore; +import org.junit.After; import org.junit.Assert; import org.junit.Test; import org.junit.runner.RunWith; @@ -25,6 +24,8 @@ import java.util.HashMap; import java.util.LinkedHashMap; import java.util.Map; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; import static com.launchdarkly.sdk.server.DataStoreTestTypes.TEST_ITEMS; import static com.launchdarkly.sdk.server.DataStoreTestTypes.toDataMap; @@ -99,7 +100,12 @@ public PersistentDataStoreWrapperTest(TestMode testMode) { this.core = new MockCore(); this.core.persistOnlyAsString = testMode.persistOnlyAsString; this.wrapper = new PersistentDataStoreWrapper(core, testMode.getCacheTtl(), - PersistentDataStoreBuilder.StaleValuesPolicy.EVICT, null); + PersistentDataStoreBuilder.StaleValuesPolicy.EVICT, false); + } + + @After + public void tearDown() throws IOException { + this.wrapper.close(); } @Test @@ -445,7 +451,7 @@ public void initializedCanCacheFalseResult() throws Exception { // We need to create a different object for this test so we can set a short cache TTL try (PersistentDataStoreWrapper wrapper1 = new PersistentDataStoreWrapper(core, - Duration.ofMillis(500), PersistentDataStoreBuilder.StaleValuesPolicy.EVICT, null)) { + Duration.ofMillis(500), PersistentDataStoreBuilder.StaleValuesPolicy.EVICT, false)) { assertThat(wrapper1.isInitialized(), is(false)); assertThat(core.initedQueryCount, equalTo(1)); @@ -467,17 +473,15 @@ public void initializedCanCacheFalseResult() throws Exception { public void canGetCacheStats() throws Exception { assumeThat(testMode.isCachedWithFiniteTtl(), is(true)); - CacheMonitor cacheMonitor = new CacheMonitor(); - try (PersistentDataStoreWrapper w = new PersistentDataStoreWrapper(core, - Duration.ofSeconds(30), PersistentDataStoreBuilder.StaleValuesPolicy.EVICT, cacheMonitor)) { - CacheMonitor.CacheStats stats = cacheMonitor.getCacheStats(); + Duration.ofSeconds(30), PersistentDataStoreBuilder.StaleValuesPolicy.EVICT, true)) { + DataStoreStatusProvider.CacheStats stats = w.getCacheStats(); - assertThat(stats, equalTo(new CacheMonitor.CacheStats(0, 0, 0, 0, 0, 0))); + assertThat(stats, equalTo(new DataStoreStatusProvider.CacheStats(0, 0, 0, 0, 0, 0))); // Cause a cache miss w.get(TEST_ITEMS, "key1"); - stats = cacheMonitor.getCacheStats(); + stats = w.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 @@ -487,7 +491,7 @@ public void canGetCacheStats() throws Exception { core.forceSet(TEST_ITEMS, new TestItem("key2", 1)); w.get(TEST_ITEMS, "key2"); // this one is a cache miss, but causes the item to be loaded and cached w.get(TEST_ITEMS, "key2"); // now it's a cache hit - stats = cacheMonitor.getCacheStats(); + stats = w.getCacheStats(); assertThat(stats.getHitCount(), equalTo(1L)); assertThat(stats.getMissCount(), equalTo(2L)); assertThat(stats.getLoadSuccessCount(), equalTo(2L)); @@ -499,9 +503,9 @@ public void canGetCacheStats() throws Exception { w.get(TEST_ITEMS, "key3"); // cache miss -> tries to load the item -> gets an exception fail("expected exception"); } catch (RuntimeException e) { - assertThat(e.getCause(), is((Throwable)core.fakeError)); + assertThat(e, is((Throwable)core.fakeError)); } - stats = cacheMonitor.getCacheStats(); + stats = w.getCacheStats(); assertThat(stats.getHitCount(), equalTo(1L)); assertThat(stats.getMissCount(), equalTo(3L)); assertThat(stats.getLoadSuccessCount(), equalTo(2L)); @@ -509,11 +513,121 @@ public void canGetCacheStats() throws Exception { } } + @Test + public void statusIsOkInitially() throws Exception { + DataStoreStatusProvider.Status status = wrapper.getStoreStatus(); + assertThat(status.isAvailable(), is(true)); + assertThat(status.isRefreshNeeded(), is(false)); + } + + @Test + public void statusIsUnavailableAfterError() throws Exception { + causeStoreError(core, wrapper); + + DataStoreStatusProvider.Status status = wrapper.getStoreStatus(); + assertThat(status.isAvailable(), is(false)); + assertThat(status.isRefreshNeeded(), is(false)); + } + + @Test + public void statusListenerIsNotifiedOnFailureAndRecovery() throws Exception { + final BlockingQueue statuses = new LinkedBlockingQueue<>(); + wrapper.addStatusListener(statuses::add); + + causeStoreError(core, wrapper); + + DataStoreStatusProvider.Status status1 = statuses.take(); + assertThat(status1.isAvailable(), is(false)); + assertThat(status1.isRefreshNeeded(), is(false)); + + // Trigger another error, just to show that it will *not* publish a redundant status update since it + // is already in a failed state + causeStoreError(core, wrapper); + + // Now simulate the data store becoming OK again; the poller detects this and publishes a new status + makeStoreAvailable(core); + DataStoreStatusProvider.Status status2 = statuses.take(); + assertThat(status2.isAvailable(), is(true)); + assertThat(status2.isRefreshNeeded(), is(!testMode.isCachedIndefinitely())); + } + + @Test + public void cacheIsWrittenToStoreAfterRecoveryIfTtlIsInfinite() throws Exception { + assumeThat(testMode.isCachedIndefinitely(), is(true)); + + final BlockingQueue statuses = new LinkedBlockingQueue<>(); + wrapper.addStatusListener(statuses::add); + + TestItem item1v1 = new TestItem("key1", 1); + TestItem item1v2 = item1v1.withVersion(2); + TestItem item2 = new TestItem("key2", 1); + + wrapper.init(new DataBuilder().add(TEST_ITEMS, item1v1).build()); + + // In infinite cache mode, we do *not* expect exceptions thrown by the store to be propagated; it will + // swallow the error, but also go into polling/recovery mode. Note that even though the store rejects + // this update, it'll still be cached. + causeStoreError(core, wrapper); + try { + wrapper.upsert(TEST_ITEMS, item1v1.key, item1v2.toItemDescriptor()); + fail("expected exception"); + } catch (RuntimeException e) { + assertThat(e.getMessage(), equalTo(FAKE_ERROR.getMessage())); + } + assertThat(wrapper.get(TEST_ITEMS, item1v1.key), equalTo(item1v2.toItemDescriptor())); + + DataStoreStatusProvider.Status status1 = statuses.take(); + assertThat(status1.isAvailable(), is(false)); + assertThat(status1.isRefreshNeeded(), is(false)); + + // While the store is still down, try to update it again - the update goes into the cache + try { + wrapper.upsert(TEST_ITEMS, item2.key, item2.toItemDescriptor()); + fail("expected exception"); + } catch (RuntimeException e) { + assertThat(e.getMessage(), equalTo(FAKE_ERROR.getMessage())); + } + assertThat(wrapper.get(TEST_ITEMS, item2.key), equalTo(item2.toItemDescriptor())); + + // Verify that this update did not go into the underlying data yet + assertThat(core.data.get(TEST_ITEMS).get(item2.key), nullValue()); + + // Now simulate the store coming back up + makeStoreAvailable(core); + + // Wait for the poller to notice this and publish a new status + DataStoreStatusProvider.Status status2 = statuses.take(); + assertThat(status2.isAvailable(), is(true)); + assertThat(status2.isRefreshNeeded(), is(false)); + + // Once that has happened, the cache should have been written to the store + assertThat(core.data.get(TEST_ITEMS).get(item1v1.key), equalTo(item1v2.toSerializedItemDescriptor())); + assertThat(core.data.get(TEST_ITEMS).get(item2.key), equalTo(item2.toSerializedItemDescriptor())); + } + + private void causeStoreError(MockCore core, PersistentDataStoreWrapper w) { + core.unavailable = true; + core.fakeError = new RuntimeException(FAKE_ERROR.getMessage()); + try { + wrapper.upsert(TEST_ITEMS, "irrelevant-key", ItemDescriptor.deletedItem(1)); + fail("expected exception"); + } catch (RuntimeException e) { + assertThat(e.getMessage(), equalTo(FAKE_ERROR.getMessage())); + } + } + + private void makeStoreAvailable(MockCore core) { + core.fakeError = null; + core.unavailable = false; + } + + static class MockCore implements PersistentDataStore { Map> data = new HashMap<>(); boolean inited; int initedQueryCount; boolean persistOnlyAsString; + boolean unavailable; RuntimeException fakeError; @Override @@ -580,6 +694,11 @@ public boolean isInitialized() { return inited; } + @Override + public boolean isStoreAvailable() { + return !unavailable; + } + public void forceSet(DataKind kind, TestItem item) { forceSet(kind, item.key, item.toSerializedItemDescriptor()); } @@ -611,6 +730,9 @@ private void maybeThrow() { if (fakeError != null) { throw fakeError; } + if (unavailable) { + throw new RuntimeException("unavailable"); + } } } } From 514d1759ee32e6cb8bf5d2d8317a1ea262216777 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 16 Mar 2020 16:48:38 -0700 Subject: [PATCH 356/673] use CopyOnWriteArrayList --- .../sdk/server/FlagChangeEventPublisher.java | 37 ++++--------------- 1 file changed, 7 insertions(+), 30 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/FlagChangeEventPublisher.java b/src/main/java/com/launchdarkly/sdk/server/FlagChangeEventPublisher.java index 3381a0508..217174b32 100644 --- a/src/main/java/com/launchdarkly/sdk/server/FlagChangeEventPublisher.java +++ b/src/main/java/com/launchdarkly/sdk/server/FlagChangeEventPublisher.java @@ -6,57 +6,34 @@ import java.io.Closeable; import java.io.IOException; -import java.util.ArrayList; -import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ThreadFactory; -import java.util.concurrent.locks.ReentrantReadWriteLock; final class FlagChangeEventPublisher implements Closeable { - private final List listeners = new ArrayList<>(); - private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); + private final CopyOnWriteArrayList listeners = new CopyOnWriteArrayList<>(); private volatile ExecutorService executor = null; public void register(FlagChangeListener listener) { - lock.writeLock().lock(); - try { - listeners.add(listener); + listeners.add(listener); + synchronized (this) { if (executor == null) { executor = createExecutorService(); } - } finally { - lock.writeLock().unlock(); } } public void unregister(FlagChangeListener listener) { - lock.writeLock().lock(); - try { - listeners.remove(listener); - } finally { - lock.writeLock().unlock(); - } + listeners.remove(listener); } public boolean hasListeners() { - lock.readLock().lock(); - try { - return !listeners.isEmpty(); - } finally { - lock.readLock().unlock(); - } + return !listeners.isEmpty(); } public void publishEvent(FlagChangeEvent event) { - FlagChangeListener[] ll; - lock.readLock().lock(); - try { - ll = listeners.toArray(new FlagChangeListener[listeners.size()]); - } finally { - lock.readLock().unlock(); - } - for (FlagChangeListener l: ll) { + for (FlagChangeListener l: listeners) { executor.execute(() -> { l.onFlagChange(event); }); From d0e94334b5ad978378684b1b790063179535accc Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 20 Mar 2020 13:27:37 -0700 Subject: [PATCH 357/673] 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 358/673] 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 359/673] 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 360/673] 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 361/673] 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 362/673] 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 2f1cfee2e09ed83514a9ef47a6a8745f6e2d4763 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 6 Apr 2020 20:00:58 -0700 Subject: [PATCH 363/673] tests no longer use Redis --- .circleci/config.yml | 13 ------------- .ldrelease/config.yml | 2 -- CONTRIBUTING.md | 2 -- 3 files changed, 17 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 554291282..449edfca5 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -54,7 +54,6 @@ jobs: type: string docker: - image: <> - - image: redis steps: - checkout - run: cp gradle.properties.example gradle.properties @@ -85,18 +84,6 @@ jobs: $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: | diff --git a/.ldrelease/config.yml b/.ldrelease/config.yml index 09d702867..bdd13d5cc 100644 --- a/.ldrelease/config.yml +++ b/.ldrelease/config.yml @@ -10,8 +10,6 @@ publications: template: name: gradle - env: - LD_SKIP_DATABASE_TESTS: 1 documentation: githubPages: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3e5c76bf0..656464d56 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,5 +40,3 @@ To build the SDK and run all unit tests: ``` ./gradlew test ``` - -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. From 88c73230dc78a39122124ef10075bf26eebe119c Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 6 Apr 2020 20:17:48 -0700 Subject: [PATCH 364/673] rm unused --- build.gradle | 6 ------ gradle.properties | 2 +- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/build.gradle b/build.gradle index 9ab42cf8f..3fae20d6a 100644 --- a/build.gradle +++ b/build.gradle @@ -47,12 +47,6 @@ ext { sdkBasePackage = "com.launchdarkly.sdk" sdkBaseName = "launchdarkly-java-server-sdk" - commonsCodecVersion = "1.10" - gsonVersion = "2.7" - guavaVersion = "28.2-jre" - okHttpEventSourceVersion = "2.0.1" - snakeYamlVersion = "1.19" - // 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" ] diff --git a/gradle.properties b/gradle.properties index df3a1cc55..0b2bfecd5 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -version=4.12.2 +version=5.0.0-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= From c8a9f479c08968f384dd0d8fb773fbe78c4c4857 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 7 Apr 2020 09:39:32 -0700 Subject: [PATCH 365/673] don't set to snapshot version yet - packaging-test doesn't work with that --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 0b2bfecd5..df3a1cc55 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -version=5.0.0-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= From 3453e8ed1d8d4bfac2833a4e3e537b8b614a09da Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 7 Apr 2020 11:12:55 -0700 Subject: [PATCH 366/673] misc build fixes --- build.gradle | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/build.gradle b/build.gradle index b6f579533..ceafec175 100644 --- a/build.gradle +++ b/build.gradle @@ -55,15 +55,6 @@ allprojects { ext { sdkBasePackage = "com.launchdarkly.sdk" sdkBaseName = "launchdarkly-java-server-sdk" - - sdkCommonVersion = "1.0.0-SNAPSHOT" - commonsCodecVersion = "1.10" - gsonVersion = "2.7" - guavaVersion = "28.2-jre" - jedisVersion = "2.9.0" - okhttpEventsourceVersion = "2.0.1" - slf4jVersion = "1.7.21" - snakeyamlVersion = "1.19" // 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. @@ -76,7 +67,7 @@ ext.versions = [ "commonsCodec": "1.10", "gson": "2.7", "guava": "28.2-jre", - "launchdarklyJavaSdkCommon": "1.0.0-SNAPHOT", + "launchdarklyJavaSdkCommon": "1.0.0-SNAPSHOT", "okhttpEventsource": "2.0.1", "slf4j": "1.7.21", "snakeyaml": "1.19", @@ -116,8 +107,8 @@ dependencies { api libraries.external testImplementation libraries.test, libraries.internal, libraries.external - commonClasses "com.launchdarkly:launchdarkly-java-sdk-common:$sdkCommonVersion" - commonDoc "com.launchdarkly:launchdarkly-java-sdk-common:$sdkCommonVersion:sources" + commonClasses "com.launchdarkly:launchdarkly-java-sdk-common:${versions.launchdarklyJavaSdkCommon}" + commonDoc "com.launchdarkly:launchdarkly-java-sdk-common:${versions.launchdarklyJavaSdkCommon}:sources" // 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. From a5e3bd20d623a116305402bd6693fd5ec2d3927f Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 7 Apr 2020 14:40:13 -0700 Subject: [PATCH 367/673] improve data store test logic --- .../sdk/server/DataStoreTestTypes.java | 5 +- .../com/launchdarkly/sdk/server/TestUtil.java | 11 ++ .../integrations/MockPersistentDataStore.java | 156 ++++++++++++++++++ .../PersistentDataStoreGenericTest.java | 76 +++++++++ .../PersistentDataStoreTestBase.java | 9 +- .../PersistentDataStoreWrapperTest.java | 124 +------------- 6 files changed, 256 insertions(+), 125 deletions(-) create mode 100644 src/test/java/com/launchdarkly/sdk/server/integrations/MockPersistentDataStore.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreGenericTest.java diff --git a/src/test/java/com/launchdarkly/sdk/server/DataStoreTestTypes.java b/src/test/java/com/launchdarkly/sdk/server/DataStoreTestTypes.java index dd68112aa..692e108ae 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataStoreTestTypes.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataStoreTestTypes.java @@ -16,6 +16,7 @@ import java.util.Map; import static com.google.common.collect.Iterables.transform; +import static com.launchdarkly.sdk.server.TestUtil.TEST_GSON_INSTANCE; @SuppressWarnings("javadoc") public class DataStoreTestTypes { @@ -117,14 +118,14 @@ private static String serializeTestItem(ItemDescriptor item) { if (item.getItem() == null) { return "DELETED:" + item.getVersion(); } - return JsonHelpers.gsonInstance().toJson(item.getItem()); + return TEST_GSON_INSTANCE.toJson(item.getItem()); } private static ItemDescriptor deserializeTestItem(String s) { if (s.startsWith("DELETED:")) { return ItemDescriptor.deletedItem(Integer.parseInt(s.substring(8))); } - TestItem ti = JsonHelpers.gsonInstance().fromJson(s, TestItem.class); + TestItem ti = TEST_GSON_INSTANCE.fromJson(s, TestItem.class); return new ItemDescriptor(ti.version, ti); } diff --git a/src/test/java/com/launchdarkly/sdk/server/TestUtil.java b/src/test/java/com/launchdarkly/sdk/server/TestUtil.java index e1856edf6..3d0f537d4 100644 --- a/src/test/java/com/launchdarkly/sdk/server/TestUtil.java +++ b/src/test/java/com/launchdarkly/sdk/server/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.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.LDValueType; @@ -39,6 +40,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 ClientContext clientContext(final String sdkKey, final LDConfig config) { return new ClientContextImpl(sdkKey, config, null); } diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/MockPersistentDataStore.java b/src/test/java/com/launchdarkly/sdk/server/integrations/MockPersistentDataStore.java new file mode 100644 index 000000000..7046231ad --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/MockPersistentDataStore.java @@ -0,0 +1,156 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.google.common.collect.ImmutableList; +import com.launchdarkly.sdk.server.DataStoreTestTypes.TestItem; +import com.launchdarkly.sdk.server.interfaces.PersistentDataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.SerializedItemDescriptor; + +import java.io.IOException; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +@SuppressWarnings("javadoc") +public final class MockPersistentDataStore implements PersistentDataStore { + public static final class MockDatabaseInstance { + Map>> dataByPrefix = new HashMap<>(); + Map initedByPrefix = new HashMap<>(); + } + + Map> data; + AtomicBoolean inited; + int initedQueryCount; + boolean persistOnlyAsString; + RuntimeException fakeError; + Runnable updateHook; + + public MockPersistentDataStore() { + this.data = new HashMap<>(); + this.inited = new AtomicBoolean(); + } + + public MockPersistentDataStore(MockDatabaseInstance sharedData, String prefix) { + synchronized (sharedData) { + if (sharedData.dataByPrefix.containsKey(prefix)) { + this.data = sharedData.dataByPrefix.get(prefix); + this.inited = sharedData.initedByPrefix.get(prefix); + } else { + this.data = new HashMap<>(); + this.inited = new AtomicBoolean(); + sharedData.dataByPrefix.put(prefix, this.data); + sharedData.initedByPrefix.put(prefix, this.inited); + } + } + } + + @Override + public void close() throws IOException { + } + + @Override + public SerializedItemDescriptor get(DataKind kind, String key) { + maybeThrow(); + if (data.containsKey(kind)) { + SerializedItemDescriptor item = data.get(kind).get(key); + if (item != null) { + if (persistOnlyAsString) { + // This simulates the kind of store implementation that can't track metadata separately + return new SerializedItemDescriptor(0, false, item.getSerializedItem()); + } else { + return item; + } + } + } + return null; + } + + @Override + public KeyedItems getAll(DataKind kind) { + maybeThrow(); + return data.containsKey(kind) ? new KeyedItems<>(ImmutableList.copyOf(data.get(kind).entrySet())) : new KeyedItems<>(null); + } + + @Override + public void init(FullDataSet allData) { + maybeThrow(); + data.clear(); + for (Map.Entry> entry: allData.getData()) { + DataKind kind = entry.getKey(); + HashMap items = new LinkedHashMap<>(); + for (Map.Entry e: entry.getValue().getItems()) { + items.put(e.getKey(), storableItem(kind, e.getValue())); + } + data.put(kind, items); + } + inited.set(true); + } + + @Override + public boolean upsert(DataKind kind, String key, SerializedItemDescriptor item) { + maybeThrow(); + if (updateHook != null) { + updateHook.run(); + } + if (!data.containsKey(kind)) { + data.put(kind, new HashMap<>()); + } + Map items = data.get(kind); + SerializedItemDescriptor oldItem = items.get(key); + if (oldItem != null) { + // If persistOnlyAsString is true, simulate the kind of implementation where we can't see the + // version as a separate attribute in the database and must deserialize the item to get it. + int oldVersion = persistOnlyAsString ? + kind.deserialize(oldItem.getSerializedItem()).getVersion() : + oldItem.getVersion(); + if (oldVersion >= item.getVersion()) { + return false; + } + } + items.put(key, storableItem(kind, item)); + return true; + } + + @Override + public boolean isInitialized() { + maybeThrow(); + initedQueryCount++; + return inited.get(); + } + + public void forceSet(DataKind kind, TestItem item) { + forceSet(kind, item.key, item.toSerializedItemDescriptor()); + } + + public void forceSet(DataKind kind, String key, SerializedItemDescriptor item) { + if (!data.containsKey(kind)) { + data.put(kind, new HashMap<>()); + } + Map items = data.get(kind); + items.put(key, storableItem(kind, item)); + } + + public void forceRemove(DataKind kind, String key) { + if (data.containsKey(kind)) { + data.get(kind).remove(key); + } + } + + private SerializedItemDescriptor storableItem(DataKind kind, SerializedItemDescriptor item) { + if (item.isDeleted() && !persistOnlyAsString) { + // This simulates the kind of store implementation that *can* track metadata separately, so we don't + // have to persist the placeholder string for deleted items + return new SerializedItemDescriptor(item.getVersion(), true, null); + } + return item; + } + + private void maybeThrow() { + if (fakeError != null) { + throw fakeError; + } + } +} \ No newline at end of file diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreGenericTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreGenericTest.java new file mode 100644 index 000000000..af4040c3a --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreGenericTest.java @@ -0,0 +1,76 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.google.common.collect.ImmutableList; + +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; + +/** + * This verifies that PersistentDataStoreTestBase behaves as expected as long as the PersistentDataStore + * implementation behaves as expected. Since there aren't any actual database integrations built into the + * SDK project, and PersistentDataStoreTestBase will be used by external projects like java-server-sdk-redis, + * we want to make sure the test logic is correct regardless of database implementation details. + * + * PersistentDataStore implementations may be able to persist the version and deleted state as metadata + * separate from the serialized item string; or they may not, in which case a little extra parsing is + * necessary. MockPersistentDataStore is able to simulate both of these scenarios, and we test both here. + */ +@SuppressWarnings("javadoc") +@RunWith(Parameterized.class) +public class PersistentDataStoreGenericTest extends PersistentDataStoreTestBase { + private final MockPersistentDataStore.MockDatabaseInstance sharedData = new MockPersistentDataStore.MockDatabaseInstance(); + private final TestMode testMode; + + static class TestMode { + final boolean persistOnlyAsString; + + TestMode(boolean persistOnlyAsString) { + this.persistOnlyAsString = persistOnlyAsString; + } + + @Override + public String toString() { + return "TestMode(" + (persistOnlyAsString ? "persistOnlyAsString" : "persistWithMetadata") + ")"; + } + } + + @Parameters(name="{0}") + public static Iterable data() { + return ImmutableList.of( + new TestMode(false), + new TestMode(true) + ); + } + + public PersistentDataStoreGenericTest(TestMode testMode) { + this.testMode = testMode; + } + + @Override + protected MockPersistentDataStore makeStore() { + return makeStoreWithPrefix(""); + } + + @Override + protected MockPersistentDataStore makeStoreWithPrefix(String prefix) { + MockPersistentDataStore store = new MockPersistentDataStore(sharedData, prefix); + store.persistOnlyAsString = testMode.persistOnlyAsString; + return store; + } + + @Override + protected void clearAllData() { + synchronized (sharedData) { + for (String prefix: sharedData.dataByPrefix.keySet()) { + sharedData.dataByPrefix.get(prefix).clear(); + } + } + } + + @Override + protected boolean setUpdateHook(MockPersistentDataStore storeUnderTest, Runnable hook) { + storeUnderTest.updateHook = hook; + return true; + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreTestBase.java b/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreTestBase.java index b6134ffb0..df805b330 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreTestBase.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreTestBase.java @@ -33,11 +33,11 @@ public abstract class PersistentDataStoreTestBase { protected T store; - protected TestItem item1 = new TestItem("first", "key1", 10); + protected TestItem item1 = new TestItem("key1", "first", 10); - protected TestItem item2 = new TestItem("second", "key2", 10); + protected TestItem item2 = new TestItem("key2", "second", 10); - protected TestItem otherItem1 = new TestItem("other-first", "key1", 11); + protected TestItem otherItem1 = new TestItem("key1", "other-first", 11); /** * Test subclasses must override this method to create an instance of the feature store class @@ -77,7 +77,8 @@ private void assertEqualsDeletedItem(SerializedItemDescriptor expected, Serializ // As above, the PersistentDataStore may not have separate access to the version and deleted state; // PersistentDataStoreWrapper compensates for this when it deserializes the item. if (serializedItemDesc.getSerializedItem() == null) { - assertEquals(expected, serializedItemDesc); + assertTrue(serializedItemDesc.isDeleted()); + assertEquals(expected.getVersion(), serializedItemDesc.getVersion()); } else { ItemDescriptor itemDesc = TEST_ITEMS.deserialize(serializedItemDesc.getSerializedItem()); assertEquals(ItemDescriptor.deletedItem(expected.getVersion()), itemDesc); diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapperTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapperTest.java index a5461fa0f..525b97cf8 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapperTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapperTest.java @@ -4,14 +4,8 @@ import com.google.common.collect.ImmutableMap; import com.launchdarkly.sdk.server.DataStoreTestTypes.DataBuilder; import com.launchdarkly.sdk.server.DataStoreTestTypes.TestItem; -import com.launchdarkly.sdk.server.integrations.CacheMonitor; -import com.launchdarkly.sdk.server.integrations.PersistentDataStoreBuilder; -import com.launchdarkly.sdk.server.integrations.PersistentDataStoreWrapper; -import com.launchdarkly.sdk.server.interfaces.PersistentDataStore; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.SerializedItemDescriptor; import org.junit.Assert; @@ -20,10 +14,7 @@ import org.junit.runners.Parameterized; import org.junit.runners.Parameterized.Parameters; -import java.io.IOException; import java.time.Duration; -import java.util.HashMap; -import java.util.LinkedHashMap; import java.util.Map; import static com.launchdarkly.sdk.server.DataStoreTestTypes.TEST_ITEMS; @@ -44,7 +35,7 @@ public class PersistentDataStoreWrapperTest { private final RuntimeException FAKE_ERROR = new RuntimeException("fake error"); private final TestMode testMode; - private final MockCore core; + private final MockPersistentDataStore core; private final PersistentDataStoreWrapper wrapper; static class TestMode { @@ -96,7 +87,7 @@ public static Iterable data() { public PersistentDataStoreWrapperTest(TestMode testMode) { this.testMode = testMode; - this.core = new MockCore(); + this.core = new MockPersistentDataStore(); this.core.persistOnlyAsString = testMode.persistOnlyAsString; this.wrapper = new PersistentDataStoreWrapper(core, testMode.getCacheTtl(), PersistentDataStoreBuilder.StaleValuesPolicy.EVICT, null); @@ -417,11 +408,11 @@ public void initializedCallsInternalMethodOnlyIfNotAlreadyInited() { assertThat(wrapper.isInitialized(), is(false)); assertThat(core.initedQueryCount, equalTo(1)); - core.inited = true; + core.inited.set(true); assertThat(wrapper.isInitialized(), is(true)); assertThat(core.initedQueryCount, equalTo(2)); - core.inited = false; + core.inited.set(false); assertThat(wrapper.isInitialized(), is(true)); assertThat(core.initedQueryCount, equalTo(2)); } @@ -449,7 +440,7 @@ public void initializedCanCacheFalseResult() throws Exception { assertThat(wrapper1.isInitialized(), is(false)); assertThat(core.initedQueryCount, equalTo(1)); - core.inited = true; + core.inited.set(true); assertThat(core.initedQueryCount, equalTo(1)); Thread.sleep(600); @@ -508,109 +499,4 @@ public void canGetCacheStats() throws Exception { assertThat(stats.getLoadExceptionCount(), equalTo(1L)); } } - - static class MockCore implements PersistentDataStore { - Map> data = new HashMap<>(); - boolean inited; - int initedQueryCount; - boolean persistOnlyAsString; - RuntimeException fakeError; - - @Override - public void close() throws IOException { - } - - @Override - public SerializedItemDescriptor get(DataKind kind, String key) { - maybeThrow(); - if (data.containsKey(kind)) { - SerializedItemDescriptor item = data.get(kind).get(key); - if (item != null) { - if (persistOnlyAsString) { - // This simulates the kind of store implementation that can't track metadata separately - return new SerializedItemDescriptor(0, false, item.getSerializedItem()); - } else { - return item; - } - } - } - return null; - } - - @Override - public KeyedItems getAll(DataKind kind) { - maybeThrow(); - return data.containsKey(kind) ? new KeyedItems<>(ImmutableList.copyOf(data.get(kind).entrySet())) : new KeyedItems<>(null); - } - - @Override - public void init(FullDataSet allData) { - maybeThrow(); - data.clear(); - for (Map.Entry> entry: allData.getData()) { - DataKind kind = entry.getKey(); - HashMap items = new LinkedHashMap<>(); - for (Map.Entry e: entry.getValue().getItems()) { - items.put(e.getKey(), storableItem(kind, e.getValue())); - } - data.put(kind, items); - } - inited = true; - } - - @Override - public boolean upsert(DataKind kind, String key, SerializedItemDescriptor item) { - maybeThrow(); - if (!data.containsKey(kind)) { - data.put(kind, new HashMap<>()); - } - Map items = data.get(kind); - SerializedItemDescriptor oldItem = items.get(key); - if (oldItem != null && oldItem.getVersion() >= item.getVersion()) { - return false; - } - items.put(key, storableItem(kind, item)); - return true; - } - - @Override - public boolean isInitialized() { - maybeThrow(); - initedQueryCount++; - return inited; - } - - public void forceSet(DataKind kind, TestItem item) { - forceSet(kind, item.key, item.toSerializedItemDescriptor()); - } - - public void forceSet(DataKind kind, String key, SerializedItemDescriptor item) { - if (!data.containsKey(kind)) { - data.put(kind, new HashMap<>()); - } - Map items = data.get(kind); - items.put(key, storableItem(kind, item)); - } - - public void forceRemove(DataKind kind, String key) { - if (data.containsKey(kind)) { - data.get(kind).remove(key); - } - } - - private SerializedItemDescriptor storableItem(DataKind kind, SerializedItemDescriptor item) { - if (item.isDeleted() && !persistOnlyAsString) { - // This simulates the kind of store implementation that *can* track metadata separately, so we don't - // have to persist the placeholder string for deleted items - return new SerializedItemDescriptor(item.getVersion(), true, null); - } - return item; - } - - private void maybeThrow() { - if (fakeError != null) { - throw fakeError; - } - } - } } From e82444a5c96272bfe1793c2a710fa433b9ff02b4 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 8 Apr 2020 14:06:04 -0700 Subject: [PATCH 368/673] add stream logic for data store outages + improve test code --- build.gradle | 2 +- .../server/DataStoreStatusProviderImpl.java | 6 +- .../sdk/server/DataStoreUpdatesImpl.java | 10 +- .../sdk/server/FeatureRequestor.java | 30 ++ .../sdk/server/PollingProcessor.java | 2 +- .../sdk/server/StreamProcessor.java | 451 +++++++++++++----- .../PersistentDataStoreStatusManager.java | 22 +- .../PersistentDataStoreWrapper.java | 7 +- .../interfaces/DataStoreStatusProvider.java | 30 +- .../server/interfaces/DataStoreUpdates.java | 14 +- .../sdk/server/DataStoreUpdatesImplTest.java | 2 +- .../sdk/server/DefaultEventProcessorTest.java | 2 +- .../sdk/server/EventOutputTest.java | 24 +- .../server/EventUserSerializationTest.java | 4 +- .../sdk/server/LDClientEvaluationTest.java | 19 +- .../sdk/server/LDClientEventTest.java | 16 +- .../LDClientExternalUpdatesOnlyTest.java | 10 +- .../sdk/server/LDClientOfflineTest.java | 4 +- .../launchdarkly/sdk/server/LDClientTest.java | 14 +- .../sdk/server/PollingProcessorTest.java | 6 +- .../sdk/server/StreamProcessorTest.java | 164 ++++--- .../sdk/server/TestComponents.java | 280 +++++++++++ .../com/launchdarkly/sdk/server/TestUtil.java | 168 ------- .../DataStoreWrapperWithFakeStatus.java | 95 ---- .../integrations/FileDataSourceTest.java | 8 +- 25 files changed, 844 insertions(+), 546 deletions(-) create mode 100644 src/test/java/com/launchdarkly/sdk/server/TestComponents.java delete mode 100644 src/test/java/com/launchdarkly/sdk/server/integrations/DataStoreWrapperWithFakeStatus.java diff --git a/build.gradle b/build.gradle index ceafec175..778712669 100644 --- a/build.gradle +++ b/build.gradle @@ -68,7 +68,7 @@ ext.versions = [ "gson": "2.7", "guava": "28.2-jre", "launchdarklyJavaSdkCommon": "1.0.0-SNAPSHOT", - "okhttpEventsource": "2.0.1", + "okhttpEventsource": "2.1.0-SNAPSHOT", "slf4j": "1.7.21", "snakeyaml": "1.19", "jedis": "2.9.0" diff --git a/src/main/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImpl.java b/src/main/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImpl.java index eb0b41cb0..db63225ee 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImpl.java @@ -18,10 +18,8 @@ public Status getStoreStatus() { } @Override - public void addStatusListener(StatusListener listener) { - if (delegateTo != null) { - delegateTo.addStatusListener(listener); - } + public boolean addStatusListener(StatusListener listener) { + return delegateTo != null && delegateTo.addStatusListener(listener); } @Override diff --git a/src/main/java/com/launchdarkly/sdk/server/DataStoreUpdatesImpl.java b/src/main/java/com/launchdarkly/sdk/server/DataStoreUpdatesImpl.java index 74fcd3ef0..615ade8d5 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DataStoreUpdatesImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/DataStoreUpdatesImpl.java @@ -4,6 +4,7 @@ import com.google.common.collect.ImmutableSet; import com.launchdarkly.sdk.server.DataModelDependencies.KindAndKey; import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; @@ -32,10 +33,12 @@ final class DataStoreUpdatesImpl implements DataStoreUpdates { private final DataStore store; private final FlagChangeEventPublisher flagChangeEventPublisher; private final DataModelDependencies.DependencyTracker dependencyTracker = new DataModelDependencies.DependencyTracker(); - + private final DataStoreStatusProvider dataStoreStatusProvider; + DataStoreUpdatesImpl(DataStore store, FlagChangeEventPublisher flagChangeEventPublisher) { this.store = store; this.flagChangeEventPublisher = flagChangeEventPublisher; + this.dataStoreStatusProvider = new DataStoreStatusProviderImpl(store); } @Override @@ -78,6 +81,11 @@ public void upsert(DataKind kind, String key, ItemDescriptor item) { } } + @Override + public DataStoreStatusProvider getStatusProvider() { + return dataStoreStatusProvider; + } + private boolean hasFlagChangeEventListeners() { return flagChangeEventPublisher != null && flagChangeEventPublisher.hasListeners(); } diff --git a/src/main/java/com/launchdarkly/sdk/server/FeatureRequestor.java b/src/main/java/com/launchdarkly/sdk/server/FeatureRequestor.java index cf53f3360..e1e5b3003 100644 --- a/src/main/java/com/launchdarkly/sdk/server/FeatureRequestor.java +++ b/src/main/java/com/launchdarkly/sdk/server/FeatureRequestor.java @@ -1,9 +1,21 @@ package com.launchdarkly.sdk.server; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.launchdarkly.sdk.server.DataModel.VersionedData; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; + import java.io.Closeable; import java.io.IOException; +import java.util.AbstractMap; import java.util.Map; +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; + interface FeatureRequestor extends Closeable { DataModel.FeatureFlag getFlag(String featureKey) throws IOException, HttpErrorException; @@ -19,5 +31,23 @@ static class AllData { this.flags = flags; this.segments = segments; } + + FullDataSet toFullDataSet() { + return new FullDataSet(ImmutableMap.of( + FEATURES, toKeyedItems(FEATURES, flags), + SEGMENTS, toKeyedItems(SEGMENTS, segments) + ).entrySet()); + } + + static KeyedItems toKeyedItems(DataKind kind, Map itemsMap) { + ImmutableList.Builder> builder = ImmutableList.builder(); + if (itemsMap != null) { + for (Map.Entry e: itemsMap.entrySet()) { + ItemDescriptor item = new ItemDescriptor(e.getValue().getVersion(), e.getValue()); + builder.add(new AbstractMap.SimpleEntry<>(e.getKey(), item)); + } + } + return new KeyedItems<>(builder.build()); + } } } diff --git a/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java b/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java index 18f52fc7d..b3bed6adf 100644 --- a/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java @@ -63,7 +63,7 @@ public Future start() { scheduler.scheduleAtFixedRate(() -> { try { FeatureRequestor.AllData allData = requestor.getAllData(); - dataStoreUpdates.init(DefaultFeatureRequestor.toFullDataSet(allData)); + dataStoreUpdates.init(allData.toFullDataSet()); if (!initialized.getAndSet(true)) { logger.info("Initialized LaunchDarkly client."); initFuture.set(null); diff --git a/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java index 1221e5643..ead070fd5 100644 --- a/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java @@ -2,7 +2,6 @@ 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.eventsource.ConnectionErrorHandler; import com.launchdarkly.eventsource.ConnectionErrorHandler.Action; @@ -10,10 +9,14 @@ import com.launchdarkly.eventsource.EventSource; import com.launchdarkly.eventsource.MessageEvent; import com.launchdarkly.eventsource.UnsuccessfulResponseException; +import com.launchdarkly.sdk.server.DataModel.VersionedData; import com.launchdarkly.sdk.server.interfaces.DataSource; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; +import com.launchdarkly.sdk.server.interfaces.SerializationException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -21,6 +24,8 @@ import java.io.IOException; import java.net.URI; import java.time.Duration; +import java.util.AbstractMap; +import java.util.Map; import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicBoolean; @@ -34,6 +39,28 @@ 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. + * 2a. If the data store supports status notifications (which all persistent stores normally do), then we can + * assume it has entered a failed state and will notify us once it is working again. If and when it recovers, then + * it will tell us whether we need to restart the stream (to ensure that we haven't missed any updates), or + * whether it has already persisted all of the stream updates we received during the outage. + * 2b. If the data store doesn't support status notifications (which is normally only true of the in-memory store) + * then we don't know the significance of the error, but we must assume that updates have been lost, so we'll + * restart the stream. + * 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 close the closeWhenReady channel 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 DataSource { private static final String PUT = "put"; private static final String PATCH = "patch"; @@ -51,15 +78,36 @@ final class StreamProcessor implements DataSource { @VisibleForTesting final FeatureRequestor requestor; private final DiagnosticAccumulator diagnosticAccumulator; private final EventSourceCreator eventSourceCreator; + private final DataStoreStatusProvider.StatusListener statusListener; 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 + static final class EventSourceParams { + final EventHandler handler; + final URI streamUri; + final Duration initialReconnectDelay; + final ConnectionErrorHandler errorHandler; + final Headers headers; + final HttpConfiguration httpConfig; + + EventSourceParams(EventHandler handler, URI streamUri, Duration initialReconnectDelay, + ConnectionErrorHandler errorHandler, Headers headers, HttpConfiguration httpConfig) { + this.handler = handler; + this.streamUri = streamUri; + this.initialReconnectDelay = initialReconnectDelay; + this.errorHandler = errorHandler; + this.headers = headers; + this.httpConfig = httpConfig; + } + } + + @FunctionalInterface static interface EventSourceCreator { - EventSource createEventSource(EventHandler handler, URI streamUri, Duration initialReconnectDelay, - ConnectionErrorHandler errorHandler, Headers headers, HttpConfiguration httpConfig); + EventSource createEventSource(EventSourceParams params); } StreamProcessor( @@ -76,15 +124,36 @@ EventSource createEventSource(EventHandler handler, URI streamUri, Duration init this.httpConfig = httpConfig; this.requestor = requestor; this.diagnosticAccumulator = diagnosticAccumulator; - this.eventSourceCreator = eventSourceCreator != null ? eventSourceCreator : new DefaultEventSourceCreator(); + this.eventSourceCreator = eventSourceCreator != null ? eventSourceCreator : StreamProcessor::defaultEventSourceCreator; this.streamUri = streamUri; this.initialReconnectDelay = initialReconnectDelay; this.headers = getHeadersBuilderFor(sdkKey, httpConfig) .add("Accept", "text/event-stream") .build(); + + DataStoreStatusProvider.StatusListener statusListener = this::onStoreStatusChanged; + if (dataStoreUpdates.getStatusProvider().addStatusListener(statusListener)) { + this.statusListener = statusListener; + } else { + this.statusListener = null; + } } + private void onStoreStatusChanged(DataStoreStatusProvider.Status newStatus) { + if (newStatus.isAvailable()) { + if (newStatus.isRefreshNeeded()) { + // The store has just transitioned from unavailable to available, and we can't guarantee that + // all of the latest data got cached, so let's restart the stream to refresh all the data. + EventSource stream = es; + if (stream != null) { + logger.warn("Restarting stream to refresh data after data store outage"); + stream.restart(); + } + } + } + } + private ConnectionErrorHandler createDefaultConnectionErrorHandler() { return (Throwable t) -> { recordStreamInit(true); @@ -113,112 +182,14 @@ public Future start() { return result; }; - EventHandler handler = new EventHandler() { - - @Override - public void onOpen() throws Exception { - } - - @Override - 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); - dataStoreUpdates.init(DefaultFeatureRequestor.toFullDataSet(putData.data)); - if (!initialized.getAndSet(true)) { - initFuture.set(null); - logger.info("Initialized LaunchDarkly client."); - } - break; - } - case PATCH: { - PatchData data = gson.fromJson(event.getData(), PatchData.class); - if (getKeyFromStreamApiPath(FEATURES, data.path) != null) { - DataModel.FeatureFlag flag = JsonHelpers.gsonInstance().fromJson(data.data, DataModel.FeatureFlag.class); - dataStoreUpdates.upsert(FEATURES, flag.getKey(), new ItemDescriptor(flag.getVersion(), flag)); - } else if (getKeyFromStreamApiPath(SEGMENTS, data.path) != null) { - DataModel.Segment segment = JsonHelpers.gsonInstance().fromJson(data.data, DataModel.Segment.class); - dataStoreUpdates.upsert(SEGMENTS, segment.getKey(), new ItemDescriptor(segment.getVersion(), segment)); - } - break; - } - case DELETE: { - DeleteData data = gson.fromJson(event.getData(), DeleteData.class); - ItemDescriptor placeholder = new ItemDescriptor(data.version, null); - String featureKey = getKeyFromStreamApiPath(FEATURES, data.path); - if (featureKey != null) { - dataStoreUpdates.upsert(FEATURES, featureKey, placeholder); - } else { - String segmentKey = getKeyFromStreamApiPath(SEGMENTS, data.path); - if (segmentKey != null) { - dataStoreUpdates.upsert(SEGMENTS, segmentKey, placeholder); - } - } - break; - } - case INDIRECT_PUT: - try { - FeatureRequestor.AllData allData = requestor.getAllData(); - dataStoreUpdates.init(DefaultFeatureRequestor.toFullDataSet(allData)); - 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 = getKeyFromStreamApiPath(FEATURES, path); - if (featureKey != null) { - DataModel.FeatureFlag feature = requestor.getFlag(featureKey); - dataStoreUpdates.upsert(FEATURES, featureKey, new ItemDescriptor(feature.getVersion(), feature)); - } else { - String segmentKey = getKeyFromStreamApiPath(SEGMENTS, path); - if (segmentKey != null) { - DataModel.Segment segment = requestor.getSegment(segmentKey); - dataStoreUpdates.upsert(SEGMENTS, segmentKey, new ItemDescriptor(segment.getVersion(), segment)); - } - } - } 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; - } - } - - @Override - public void onComment(String comment) { - logger.debug("Received a heartbeat"); - } - - @Override - public void onError(Throwable throwable) { - logger.warn("Encountered EventSource error: {}", throwable.toString()); - logger.debug(throwable.toString(), throwable); - } - }; - - es = eventSourceCreator.createEventSource(handler, + EventHandler handler = new StreamEventHandler(initFuture); + + es = eventSourceCreator.createEventSource(new EventSourceParams(handler, URI.create(streamUri.toASCIIString() + "/all"), initialReconnectDelay, wrappedConnectionErrorHandler, headers, - httpConfig); + httpConfig)); esStarted = System.currentTimeMillis(); es.start(); return initFuture; @@ -233,6 +204,9 @@ private void recordStreamInit(boolean failed) { @Override public void close() throws IOException { logger.info("Closing LaunchDarkly StreamProcessor"); + if (statusListener != null) { + dataStoreUpdates.getStatusProvider().removeStatusListener(statusListener); + } if (es != null) { es.close(); } @@ -244,9 +218,246 @@ public boolean isInitialized() { return initialized.get(); } - private static String getKeyFromStreamApiPath(DataKind kind, String path) { - String prefix = (kind == SEGMENTS) ? "/segments/" : "/flags/"; - return path.startsWith(prefix) ? path.substring(prefix.length()) : null; + private class StreamEventHandler implements EventHandler { + private final SettableFuture initFuture; + + StreamEventHandler(SettableFuture initFuture) { + this.initFuture = initFuture; + } + + @Override + public void onOpen() throws Exception { + } + + @Override + public void onClosed() throws Exception { + } + + @Override + public void onMessage(String name, MessageEvent event) throws Exception { + try { + switch (name) { + case PUT: + handlePut(event.getData()); + break; + + case PATCH: + handlePatch(event.getData()); + break; + + case DELETE: + handleDelete(event.getData()); + break; + + case INDIRECT_PUT: + handleIndirectPut(); + break; + + case INDIRECT_PATCH: + handleIndirectPatch(event.getData()); + break; + + default: + logger.warn("Unexpected event found in stream: " + name); + break; + } + lastStoreUpdateFailed = false; + } catch (StreamInputException e) { + // See item 1 in error handling comments at top of class + logger.error("Malformed JSON data or other service error for streaming \"{0}\" event; will restart stream", + name, e.getCause().toString()); + logger.debug(e.getCause().toString(), e.getCause()); + es.restart(); + } catch (StreamStoreException e) { + // See item 2 in error handling comments at top of class + if (!lastStoreUpdateFailed) { + logger.error("Unexpected data store failure when storing updates from stream: {0}", + e.getCause().toString()); + logger.debug(e.getCause().toString(), e.getCause()); + } + if (statusListener == null) { + if (!lastStoreUpdateFailed) { + logger.warn("Restarting stream to ensure that we have the latest data"); + } + es.restart(); + } + lastStoreUpdateFailed = true; + } + } + + private void handlePut(String eventData) throws StreamInputException, StreamStoreException { + recordStreamInit(false); + esStarted = 0; + PutData putData = parseStreamJson(PutData.class, eventData); + FullDataSet allData; + try { + allData = putData.data.toFullDataSet(); + } catch (Exception e) { + throw new StreamInputException(e); + } + try { + dataStoreUpdates.init(allData); + } catch (Exception e) { + throw new StreamStoreException(e); + } + if (!initialized.getAndSet(true)) { + initFuture.set(null); + logger.info("Initialized LaunchDarkly client."); + } + } + + private void handlePatch(String eventData) throws StreamInputException, StreamStoreException { + PatchData data = parseStreamJson(PatchData.class, eventData); + Map.Entry kindAndKey = getKindAndKeyFromStreamApiPath(data.path); + DataKind kind = kindAndKey.getKey(); + String key = kindAndKey.getValue(); + ItemDescriptor item; + try { + item = deserializeFromParsedJson(kind, data.data); + } catch (Exception e) { + throw new StreamInputException(e); + } + try { + dataStoreUpdates.upsert(kind, key, item); + } catch (Exception e) { + throw new StreamStoreException(e); + } + } + + private void handleDelete(String eventData) throws StreamInputException, StreamStoreException { + DeleteData data = parseStreamJson(DeleteData.class, eventData); + Map.Entry kindAndKey = getKindAndKeyFromStreamApiPath(data.path); + DataKind kind = kindAndKey.getKey(); + String key = kindAndKey.getValue(); + ItemDescriptor placeholder = new ItemDescriptor(data.version, null); + try { + dataStoreUpdates.upsert(kind, key, placeholder); + } catch (Exception e) { + throw new StreamStoreException(e); + } + } + + private void handleIndirectPut() throws StreamInputException, StreamStoreException { + FeatureRequestor.AllData putData; + try { + putData = requestor.getAllData(); + } catch (Exception e) { + throw new StreamInputException(e); + } + FullDataSet allData; + try { + allData = putData.toFullDataSet(); + } catch (Exception e) { + throw new StreamInputException(e); + } + try { + dataStoreUpdates.init(allData); + } catch (Exception e) { + throw new StreamStoreException(e); + } + if (!initialized.getAndSet(true)) { + initFuture.set(null); + logger.info("Initialized LaunchDarkly client."); + } + } + + private void handleIndirectPatch(String path) throws StreamInputException, StreamStoreException { + Map.Entry kindAndKey = getKindAndKeyFromStreamApiPath(path); + DataKind kind = kindAndKey.getKey(); + String key = kindAndKey.getValue(); + VersionedData item; + try { + item = kind == SEGMENTS ? requestor.getSegment(key) : requestor.getFlag(key); + } catch (Exception e) { + throw new StreamInputException(e); + } + try { + dataStoreUpdates.upsert(kind, key, new ItemDescriptor(item.getVersion(), item)); + } catch (Exception e) { + throw new StreamStoreException(e); + } + } + + @Override + public void onComment(String comment) { + logger.debug("Received a heartbeat"); + } + + @Override + public void onError(Throwable throwable) { + logger.warn("Encountered EventSource error: {}", throwable.toString()); + logger.debug(throwable.toString(), throwable); + } + } + + private static EventSource defaultEventSourceCreator(EventSourceParams params) { + EventSource.Builder builder = new EventSource.Builder(params.handler, params.streamUri) + .clientBuilderActions(new EventSource.Builder.ClientConfigurer() { + public void configure(OkHttpClient.Builder builder) { + configureHttpClientBuilder(params.httpConfig, builder); + } + }) + .connectionErrorHandler(params.errorHandler) + .headers(params.headers) + .reconnectTime(params.initialReconnectDelay) + .readTimeout(DEAD_CONNECTION_INTERVAL); + // Note that this is not the same read timeout that can be set in LDConfig. We default to a smaller one + // there because we don't expect long delays within any *non*-streaming response that the LD client gets. + // A read timeout on the stream will result in the connection being cycled, so we set this to be slightly + // more than the expected interval between heartbeat signals. + + return builder.build(); + } + + private static Map.Entry getKindAndKeyFromStreamApiPath(String path) throws StreamInputException { + for (DataKind kind: DataModel.ALL_DATA_KINDS) { + String prefix = (kind == SEGMENTS) ? "/segments/" : "/flags/"; + if (path.startsWith(prefix)) { + return new AbstractMap.SimpleEntry<>(kind, path.substring(prefix.length())); + } + } + throw new StreamInputException(new IllegalArgumentException("unrecognized item path: " + path)); + } + + private static T parseStreamJson(Class c, String json) throws StreamInputException { + try { + return JsonHelpers.gsonInstance().fromJson(json, c); + } catch (Exception e) { + throw new StreamInputException(e); + } + } + + private static ItemDescriptor deserializeFromParsedJson(DataKind kind, JsonElement parsedJson) throws SerializationException { + VersionedData item; + try { + if (kind == FEATURES) { + item = JsonHelpers.gsonInstance().fromJson(parsedJson, DataModel.FeatureFlag.class); + } else if (kind == SEGMENTS) { + item = JsonHelpers.gsonInstance().fromJson(parsedJson, DataModel.Segment.class); + } else { // this case should never happen + return kind.deserialize(JsonHelpers.gsonInstance().toJson(parsedJson)); + } + } catch (Exception e) { + throw new SerializationException(e); + } + return new ItemDescriptor(item.getVersion(), item); + } + + // Using these two exception wrapper types helps to simplify the error-handling logic. 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(Throwable cause) { + super(cause); + } + } + + @SuppressWarnings("serial") + private static final class StreamStoreException extends Exception { + public StreamStoreException(Throwable cause) { + super(cause); + } } private static final class PutData { @@ -271,26 +482,4 @@ private static final class DeleteData { @SuppressWarnings("unused") // used by Gson public DeleteData() { } } - - private class DefaultEventSourceCreator implements EventSourceCreator { - public EventSource createEventSource(EventHandler handler, URI streamUri, Duration initialReconnectDelay, - 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(httpConfig, builder); - } - }) - .connectionErrorHandler(errorHandler) - .headers(headers) - .reconnectTime(initialReconnectDelay) - .readTimeout(DEAD_CONNECTION_INTERVAL); - // Note that this is not the same read timeout that can be set in LDConfig. We default to a smaller one - // there because we don't expect long delays within any *non*-streaming response that the LD client gets. - // A read timeout on the stream will result in the connection being cycled, so we set this to be slightly - // more than the expected interval between heartbeat signals. - - return builder.build(); - } - } } diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreStatusManager.java b/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreStatusManager.java index 948771a95..4a8f101a1 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreStatusManager.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreStatusManager.java @@ -63,7 +63,7 @@ void updateAvailability(boolean available) { copyOfListeners = listeners.toArray(new StatusListener[listeners.size()]); } - StatusImpl status = new StatusImpl(available, available && refreshOnRecovery); + Status status = new Status(available, available && refreshOnRecovery); if (available) { logger.warn("Persistent store is available again"); @@ -108,26 +108,6 @@ void close() { scheduler.shutdown(); } - static final class StatusImpl implements Status { - private final boolean available; - private final boolean needsRefresh; - - StatusImpl(boolean available, boolean needsRefresh) { - this.available = available; - this.needsRefresh = needsRefresh; - } - - @Override - public boolean isAvailable() { - return available; - } - - @Override - public boolean isRefreshNeeded() { - return needsRefresh; - } - } - private static final class StatusNotificationTask implements Runnable { private final Status status; private final StatusListener[] listeners; diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapper.java b/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapper.java index b07b7802d..3281ba997 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapper.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapper.java @@ -312,12 +312,13 @@ public boolean upsert(DataKind kind, String key, ItemDescriptor item) { @Override public Status getStoreStatus() { - return new PersistentDataStoreStatusManager.StatusImpl(statusManager.isAvailable(), false); + return new Status(statusManager.isAvailable(), false); } @Override - public void addStatusListener(StatusListener listener) { + public boolean addStatusListener(StatusListener listener) { statusManager.addStatusListener(listener); + return true; } @Override @@ -441,7 +442,7 @@ private boolean pollAvailabilityAfterOutage() { // We failed to write the cached data to the underlying store. In this case, // initCore() has already put us back into the failed state. The only further // thing we can do is to log a note about what just happened. - logger.error("Tried to write cached data to persistent store after a store outage, but failed: {0}", e); + logger.error("Tried to write cached data to persistent store after a store outage, but failed: {}", e); } } diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProvider.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProvider.java index aeaf35a47..25b43b08b 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProvider.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProvider.java @@ -17,7 +17,8 @@ public interface DataStoreStatusProvider { /** * Returns the current status of the store. - * @return the latest status + * + * @return the latest status, or null if not available */ public Status getStoreStatus(); @@ -37,8 +38,10 @@ public interface DataStoreStatusProvider { * are using the default in-memory store rather than a persistent store. * * @param listener the listener to add + * @return true if the listener was added, or was already registered; false if the data store does not support + * status tracking */ - public void addStatusListener(StatusListener listener); + public boolean addStatusListener(StatusListener listener); /** * Unsubscribes from notifications of status changes. @@ -64,7 +67,20 @@ public interface DataStoreStatusProvider { /** * Information about a status change. */ - public static interface Status { + public static final class Status { + private final boolean available; + private final boolean refreshNeeded; + + /** + * Creates an instance. + * @param available see {@link #isAvailable()} + * @param refreshNeeded see {@link #isRefreshNeeded()} + */ + public Status(boolean available, boolean refreshNeeded) { + this.available = available; + this.refreshNeeded = refreshNeeded; + } + /** * Returns true if the SDK believes the data store is now available. *

    @@ -74,7 +90,9 @@ public static interface Status { * * @return true if store is available */ - public boolean isAvailable(); + public boolean isAvailable() { + return available; + } /** * Returns true if the store may be out of date due to a previous outage, so the SDK should attempt to refresh @@ -84,7 +102,9 @@ public static interface Status { * * @return true if data should be rewritten */ - public boolean isRefreshNeeded(); + public boolean isRefreshNeeded() { + return refreshNeeded; + } } /** diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreUpdates.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreUpdates.java index 2b8d19757..409369833 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreUpdates.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreUpdates.java @@ -39,5 +39,17 @@ public interface DataStoreUpdates { * @param key the unique key for the item within that collection * @param item the item to insert or update */ - void upsert(DataKind kind, String key, ItemDescriptor item); + void upsert(DataKind kind, String key, ItemDescriptor item); + + /** + * Returns an object that provides status tracking for the data store, if applicable. + *

    + * For data stores that do not support status tracking (the in-memory store, or a custom implementation + * that is not based on the SDK's usual persistent data store mechanism), it returns a stub + * implementation that returns null from {@link DataStoreStatusProvider#getStoreStatus()} and + * false from {@link DataStoreStatusProvider#addStatusListener(com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider.StatusListener)}. + * + * @return a {@link DataStoreStatusProvider} + */ + DataStoreStatusProvider getStatusProvider(); } diff --git a/src/test/java/com/launchdarkly/sdk/server/DataStoreUpdatesImplTest.java b/src/test/java/com/launchdarkly/sdk/server/DataStoreUpdatesImplTest.java index 16015a967..4f041624c 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataStoreUpdatesImplTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataStoreUpdatesImplTest.java @@ -28,7 +28,7 @@ import static com.launchdarkly.sdk.server.ModelBuilders.prerequisite; import static com.launchdarkly.sdk.server.ModelBuilders.ruleBuilder; import static com.launchdarkly.sdk.server.ModelBuilders.segmentBuilder; -import static com.launchdarkly.sdk.server.TestUtil.inMemoryDataStore; +import static com.launchdarkly.sdk.server.TestComponents.inMemoryDataStore; import static org.easymock.EasyMock.replay; import static org.junit.Assert.assertEquals; import static org.junit.Assert.fail; diff --git a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java index 2df3302ff..e410a44fe 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java @@ -23,9 +23,9 @@ import static com.launchdarkly.sdk.server.Components.sendEvents; import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.TestComponents.clientContext; import static com.launchdarkly.sdk.server.TestHttpUtil.httpsServerWithSelfSignedCert; import static com.launchdarkly.sdk.server.TestHttpUtil.makeStartedServer; -import static com.launchdarkly.sdk.server.TestUtil.clientContext; import static com.launchdarkly.sdk.server.TestUtil.hasJsonProperty; import static com.launchdarkly.sdk.server.TestUtil.isJsonArray; import static com.launchdarkly.sdk.server.TestUtil.simpleEvaluation; diff --git a/src/test/java/com/launchdarkly/sdk/server/EventOutputTest.java b/src/test/java/com/launchdarkly/sdk/server/EventOutputTest.java index 0db986e0f..187b7429a 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EventOutputTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EventOutputTest.java @@ -18,6 +18,8 @@ import java.util.Set; import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.TestComponents.defaultEventsConfig; +import static com.launchdarkly.sdk.server.TestComponents.makeEventsConfig; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.containsInAnyOrder; @@ -59,21 +61,21 @@ public class EventOutputTest { @Test public void allUserAttributesAreSerialized() throws Exception { testInlineUserSerialization(userBuilderWithAllAttributes.build(), userJsonWithAllAttributes, - TestUtil.defaultEventsConfig()); + defaultEventsConfig()); } @Test public void unsetUserAttributesAreNotSerialized() throws Exception { LDUser user = new LDUser("userkey"); LDValue userJson = parseValue("{\"key\":\"userkey\"}"); - testInlineUserSerialization(user, userJson, TestUtil.defaultEventsConfig()); + testInlineUserSerialization(user, userJson, 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(TestUtil.defaultEventsConfig()); + EventOutputFormatter f = new EventOutputFormatter(defaultEventsConfig()); Event.FeatureRequest featureEvent = EventFactory.DEFAULT.newFeatureRequestEvent( flagBuilder("flag").build(), @@ -103,7 +105,7 @@ public void userKeyIsSetInsteadOfUserWhenNotInlined() throws Exception { @Test public void allAttributesPrivateMakesAttributesPrivate() throws Exception { LDUser user = userBuilderWithAllAttributes.build(); - EventsConfiguration config = TestUtil.makeEventsConfig(true, false, null); + EventsConfiguration config = makeEventsConfig(true, false, null); testPrivateAttributes(config, user, attributesThatCanBePrivate); } @@ -111,7 +113,7 @@ public void allAttributesPrivateMakesAttributesPrivate() throws Exception { public void globalPrivateAttributeNamesMakeAttributesPrivate() throws Exception { LDUser user = userBuilderWithAllAttributes.build(); for (String attrName: attributesThatCanBePrivate) { - EventsConfiguration config = TestUtil.makeEventsConfig(false, false, ImmutableSet.of(UserAttribute.forName(attrName))); + EventsConfiguration config = makeEventsConfig(false, false, ImmutableSet.of(UserAttribute.forName(attrName))); testPrivateAttributes(config, user, attrName); } } @@ -119,7 +121,7 @@ public void globalPrivateAttributeNamesMakeAttributesPrivate() throws Exception @Test public void perUserPrivateAttributesMakeAttributePrivate() throws Exception { LDUser baseUser = userBuilderWithAllAttributes.build(); - EventsConfiguration config = TestUtil.defaultEventsConfig(); + EventsConfiguration config = defaultEventsConfig(); testPrivateAttributes(config, new LDUser.Builder(baseUser).privateAvatar("x").build(), "avatar"); testPrivateAttributes(config, new LDUser.Builder(baseUser).privateCountry("US").build(), "country"); @@ -169,7 +171,7 @@ public void featureEventIsSerialized() throws Exception { EventFactory factoryWithReason = eventFactoryWithTimestamp(100000, true); DataModel.FeatureFlag flag = flagBuilder("flag").version(11).build(); LDUser user = new LDUser.Builder("userkey").name("me").build(); - EventOutputFormatter f = new EventOutputFormatter(TestUtil.defaultEventsConfig()); + EventOutputFormatter f = new EventOutputFormatter(defaultEventsConfig()); FeatureRequest feWithVariation = factory.newFeatureRequestEvent(flag, user, new Evaluator.EvalResult(LDValue.of("flagvalue"), 1, EvaluationReason.off()), @@ -246,7 +248,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(TestUtil.defaultEventsConfig()); + EventOutputFormatter f = new EventOutputFormatter(defaultEventsConfig()); Event.Identify ie = factory.newIdentifyEvent(user); LDValue ieJson = parseValue("{" + @@ -262,7 +264,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(TestUtil.defaultEventsConfig()); + EventOutputFormatter f = new EventOutputFormatter(defaultEventsConfig()); Event.Custom ceWithoutData = factory.newCustomEvent("customkey", user, LDValue.ofNull(), null); LDValue ceJson1 = parseValue("{" + @@ -328,7 +330,7 @@ public void summaryEventIsSerialized() throws Exception { summary.noteTimestamp(1000); summary.noteTimestamp(1002); - EventOutputFormatter f = new EventOutputFormatter(TestUtil.defaultEventsConfig()); + EventOutputFormatter f = new EventOutputFormatter(defaultEventsConfig()); StringWriter w = new StringWriter(); int count = f.writeOutputEvents(new Event[0], summary, w); assertEquals(1, count); @@ -387,7 +389,7 @@ private LDValue getSingleOutputEvent(EventOutputFormatter f, Event event) throws } private void testInlineUserSerialization(LDUser user, LDValue expectedJsonValue, EventsConfiguration baseConfig) throws IOException { - EventsConfiguration config = TestUtil.makeEventsConfig(baseConfig.allAttributesPrivate, true, baseConfig.privateAttributes); + EventsConfiguration config = makeEventsConfig(baseConfig.allAttributesPrivate, true, baseConfig.privateAttributes); EventOutputFormatter f = new EventOutputFormatter(config); Event.FeatureRequest featureEvent = EventFactory.DEFAULT.newFeatureRequestEvent( diff --git a/src/test/java/com/launchdarkly/sdk/server/EventUserSerializationTest.java b/src/test/java/com/launchdarkly/sdk/server/EventUserSerializationTest.java index 6d3f49c36..747aaf035 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EventUserSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EventUserSerializationTest.java @@ -17,8 +17,8 @@ import static com.launchdarkly.sdk.server.JsonHelpers.gsonInstance; import static com.launchdarkly.sdk.server.JsonHelpers.gsonInstanceForEventsSerialization; -import static com.launchdarkly.sdk.server.TestUtil.defaultEventsConfig; -import static com.launchdarkly.sdk.server.TestUtil.makeEventsConfig; +import static com.launchdarkly.sdk.server.TestComponents.defaultEventsConfig; +import static com.launchdarkly.sdk.server.TestComponents.makeEventsConfig; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java index c867bd3d0..09cb86924 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java @@ -7,14 +7,6 @@ import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; -import com.launchdarkly.sdk.server.Components; -import com.launchdarkly.sdk.server.DataModel; -import com.launchdarkly.sdk.server.FeatureFlagsState; -import com.launchdarkly.sdk.server.FlagsStateOption; -import com.launchdarkly.sdk.server.InMemoryDataStore; -import com.launchdarkly.sdk.server.LDClient; -import com.launchdarkly.sdk.server.LDClientInterface; -import com.launchdarkly.sdk.server.LDConfig; import com.launchdarkly.sdk.server.interfaces.DataStore; import org.junit.Test; @@ -28,10 +20,11 @@ import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; import static com.launchdarkly.sdk.server.ModelBuilders.flagWithValue; import static com.launchdarkly.sdk.server.ModelBuilders.segmentBuilder; -import static com.launchdarkly.sdk.server.TestUtil.dataStoreThatThrowsException; -import static com.launchdarkly.sdk.server.TestUtil.failedDataSource; -import static com.launchdarkly.sdk.server.TestUtil.specificDataSource; -import static com.launchdarkly.sdk.server.TestUtil.specificDataStore; +import static com.launchdarkly.sdk.server.TestComponents.dataStoreThatThrowsException; +import static com.launchdarkly.sdk.server.TestComponents.failedDataSource; +import static com.launchdarkly.sdk.server.TestComponents.initedDataStore; +import static com.launchdarkly.sdk.server.TestComponents.specificDataSource; +import static com.launchdarkly.sdk.server.TestComponents.specificDataStore; import static com.launchdarkly.sdk.server.TestUtil.upsertFlag; import static com.launchdarkly.sdk.server.TestUtil.upsertSegment; import static org.junit.Assert.assertEquals; @@ -45,7 +38,7 @@ public class LDClientEvaluationTest { private static final LDUser userWithNullKey = new LDUser.Builder((String)null).build(); private static final Gson gson = new Gson(); - private DataStore dataStore = TestUtil.initedDataStore(); + private DataStore dataStore = initedDataStore(); private LDConfig config = new LDConfig.Builder() .dataStore(specificDataStore(dataStore)) diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientEventTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientEventTest.java index 15c30e142..c20eb2bb3 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientEventTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientEventTest.java @@ -1,14 +1,9 @@ package com.launchdarkly.sdk.server; import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.EvaluationReason.ErrorKind; import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; -import com.launchdarkly.sdk.EvaluationReason.ErrorKind; -import com.launchdarkly.sdk.server.Components; -import com.launchdarkly.sdk.server.DataModel; -import com.launchdarkly.sdk.server.LDClient; -import com.launchdarkly.sdk.server.LDClientInterface; -import com.launchdarkly.sdk.server.LDConfig; import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.Event; @@ -21,8 +16,9 @@ import static com.launchdarkly.sdk.server.ModelBuilders.flagWithValue; import static com.launchdarkly.sdk.server.ModelBuilders.prerequisite; import static com.launchdarkly.sdk.server.ModelBuilders.ruleBuilder; -import static com.launchdarkly.sdk.server.TestUtil.specificDataStore; -import static com.launchdarkly.sdk.server.TestUtil.specificEventProcessor; +import static com.launchdarkly.sdk.server.TestComponents.initedDataStore; +import static com.launchdarkly.sdk.server.TestComponents.specificDataStore; +import static com.launchdarkly.sdk.server.TestComponents.specificEventProcessor; import static com.launchdarkly.sdk.server.TestUtil.upsertFlag; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -34,8 +30,8 @@ public class LDClientEventTest { private static final LDUser user = new LDUser("userkey"); private static final LDUser userWithNullKey = new LDUser.Builder((String)null).build(); - private DataStore dataStore = TestUtil.initedDataStore(); - private TestUtil.TestEventProcessor eventSink = new TestUtil.TestEventProcessor(); + private DataStore dataStore = initedDataStore(); + private TestComponents.TestEventProcessor eventSink = new TestComponents.TestEventProcessor(); private LDConfig config = new LDConfig.Builder() .dataStore(specificDataStore(dataStore)) .events(specificEventProcessor(eventSink)) diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientExternalUpdatesOnlyTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientExternalUpdatesOnlyTest.java index 527b10926..089b3ec12 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientExternalUpdatesOnlyTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientExternalUpdatesOnlyTest.java @@ -2,11 +2,6 @@ import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; -import com.launchdarkly.sdk.server.Components; -import com.launchdarkly.sdk.server.DataModel; -import com.launchdarkly.sdk.server.DefaultEventProcessor; -import com.launchdarkly.sdk.server.LDClient; -import com.launchdarkly.sdk.server.LDConfig; import com.launchdarkly.sdk.server.interfaces.DataStore; import org.junit.Test; @@ -14,7 +9,8 @@ import java.io.IOException; import static com.launchdarkly.sdk.server.ModelBuilders.flagWithValue; -import static com.launchdarkly.sdk.server.TestUtil.specificDataStore; +import static com.launchdarkly.sdk.server.TestComponents.initedDataStore; +import static com.launchdarkly.sdk.server.TestComponents.specificDataStore; import static com.launchdarkly.sdk.server.TestUtil.upsertFlag; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; @@ -53,7 +49,7 @@ public void externalUpdatesOnlyClientIsInitialized() throws Exception { @Test public void externalUpdatesOnlyClientGetsFlagFromDataStore() throws IOException { - DataStore testDataStore = TestUtil.initedDataStore(); + DataStore testDataStore = initedDataStore(); LDConfig config = new LDConfig.Builder() .dataSource(Components.externalUpdatesOnly()) .dataStore(specificDataStore(testDataStore)) diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientOfflineTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientOfflineTest.java index 097b2b43c..3565554d7 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientOfflineTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientOfflineTest.java @@ -15,8 +15,8 @@ import java.io.IOException; import static com.launchdarkly.sdk.server.ModelBuilders.flagWithValue; -import static com.launchdarkly.sdk.server.TestUtil.initedDataStore; -import static com.launchdarkly.sdk.server.TestUtil.specificDataStore; +import static com.launchdarkly.sdk.server.TestComponents.initedDataStore; +import static com.launchdarkly.sdk.server.TestComponents.specificDataStore; import static com.launchdarkly.sdk.server.TestUtil.upsertFlag; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java index 614fe4f9c..abb20a4aa 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java @@ -4,7 +4,7 @@ import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.server.DataModel.FeatureFlag; import com.launchdarkly.sdk.server.DataStoreTestTypes.DataBuilder; -import com.launchdarkly.sdk.server.TestUtil.DataSourceFactoryThatExposesUpdater; +import com.launchdarkly.sdk.server.TestComponents.DataSourceFactoryThatExposesUpdater; import com.launchdarkly.sdk.server.TestUtil.FlagChangeEventSink; import com.launchdarkly.sdk.server.TestUtil.FlagValueChangeEventSink; import com.launchdarkly.sdk.server.interfaces.ClientContext; @@ -33,9 +33,11 @@ import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; import static com.launchdarkly.sdk.server.ModelBuilders.flagWithValue; -import static com.launchdarkly.sdk.server.TestUtil.failedDataSource; -import static com.launchdarkly.sdk.server.TestUtil.initedDataStore; -import static com.launchdarkly.sdk.server.TestUtil.specificDataStore; +import static com.launchdarkly.sdk.server.TestComponents.failedDataSource; +import static com.launchdarkly.sdk.server.TestComponents.initedDataStore; +import static com.launchdarkly.sdk.server.TestComponents.specificDataSource; +import static com.launchdarkly.sdk.server.TestComponents.specificDataStore; +import static com.launchdarkly.sdk.server.TestComponents.specificEventProcessor; import static com.launchdarkly.sdk.server.TestUtil.upsertFlag; import static org.easymock.EasyMock.anyObject; import static org.easymock.EasyMock.capture; @@ -475,8 +477,8 @@ private void expectEventsSent(int count) { } private LDClientInterface createMockClient(LDConfig.Builder config) { - config.dataSource(TestUtil.specificDataSource(dataSource)); - config.events(TestUtil.specificEventProcessor(eventProcessor)); + config.dataSource(specificDataSource(dataSource)); + config.events(specificEventProcessor(eventProcessor)); return new LDClient(SDK_KEY, config.build()); } } \ No newline at end of file diff --git a/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java index 41773747d..b43a2e2c5 100644 --- a/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java @@ -22,8 +22,8 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; -import static com.launchdarkly.sdk.server.TestUtil.clientContext; -import static com.launchdarkly.sdk.server.TestUtil.dataStoreUpdates; +import static com.launchdarkly.sdk.server.TestComponents.clientContext; +import static com.launchdarkly.sdk.server.TestComponents.dataStoreUpdates; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.junit.Assert.assertFalse; @@ -59,7 +59,7 @@ public void builderCanSpecifyConfiguration() throws Exception { @Test public void testConnectionOk() throws Exception { MockFeatureRequestor requestor = new MockFeatureRequestor(); - requestor.allData = new FeatureRequestor.AllData(new HashMap(), new HashMap()); + requestor.allData = new FeatureRequestor.AllData(new HashMap<>(), new HashMap<>()); DataStore store = new InMemoryDataStore(); try (PollingProcessor pollingProcessor = new PollingProcessor(requestor, dataStoreUpdates(store), LENGTHY_INTERVAL)) { diff --git a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java index 240527439..d0ce22812 100644 --- a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java @@ -1,12 +1,15 @@ package com.launchdarkly.sdk.server; +import com.google.common.util.concurrent.SettableFuture; import com.launchdarkly.eventsource.ConnectionErrorHandler; import com.launchdarkly.eventsource.EventHandler; import com.launchdarkly.eventsource.EventSource; import com.launchdarkly.eventsource.MessageEvent; import com.launchdarkly.eventsource.UnsuccessfulResponseException; +import com.launchdarkly.sdk.server.TestComponents.MockEventSourceCreator; import com.launchdarkly.sdk.server.integrations.StreamingDataSourceBuilder; import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import org.easymock.EasyMockSupport; @@ -27,15 +30,17 @@ import static com.launchdarkly.sdk.server.DataModel.FEATURES; import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; +import static com.launchdarkly.sdk.server.JsonHelpers.gsonInstance; import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; import static com.launchdarkly.sdk.server.ModelBuilders.segmentBuilder; +import static com.launchdarkly.sdk.server.TestComponents.clientContext; +import static com.launchdarkly.sdk.server.TestComponents.dataStoreUpdates; import static com.launchdarkly.sdk.server.TestHttpUtil.eventStreamResponse; import static com.launchdarkly.sdk.server.TestHttpUtil.makeStartedServer; -import static com.launchdarkly.sdk.server.TestUtil.clientContext; -import static com.launchdarkly.sdk.server.TestUtil.dataStoreUpdates; import static com.launchdarkly.sdk.server.TestUtil.upsertFlag; import static com.launchdarkly.sdk.server.TestUtil.upsertSegment; 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; @@ -47,7 +52,6 @@ import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; -import okhttp3.Headers; import okhttp3.HttpUrl; import okhttp3.mockwebserver.MockWebServer; @@ -69,22 +73,21 @@ public class StreamProcessorTest extends EasyMockSupport { private InMemoryDataStore dataStore; private FeatureRequestor mockRequestor; private EventSource mockEventSource; - private EventHandler eventHandler; - private URI actualStreamUri; - private ConnectionErrorHandler errorHandler; - private Headers headers; + private MockEventSourceCreator mockEventSourceCreator; @Before public void setup() { dataStore = new InMemoryDataStore(); mockRequestor = createStrictMock(FeatureRequestor.class); - mockEventSource = createStrictMock(EventSource.class); + mockEventSource = createMock(EventSource.class); + mockEventSourceCreator = new MockEventSourceCreator(mockEventSource); } @Test public void builderHasDefaultConfiguration() throws Exception { DataSourceFactory f = Components.streamingDataSource(); - try (StreamProcessor sp = (StreamProcessor)f.createDataSource(clientContext(SDK_KEY, LDConfig.DEFAULT), null)) { + try (StreamProcessor sp = (StreamProcessor)f.createDataSource(clientContext(SDK_KEY, LDConfig.DEFAULT), + dataStoreUpdates(dataStore))) { assertThat(sp.initialReconnectDelay, equalTo(StreamingDataSourceBuilder.DEFAULT_INITIAL_RECONNECT_DELAY)); assertThat(sp.streamUri, equalTo(LDConfig.DEFAULT_STREAM_URI)); assertThat(((DefaultFeatureRequestor)sp.requestor).baseUri, equalTo(LDConfig.DEFAULT_BASE_URI)); @@ -99,7 +102,8 @@ public void builderCanSpecifyConfiguration() throws Exception { .baseURI(streamUri) .initialReconnectDelay(Duration.ofMillis(5555)) .pollingBaseURI(pollUri); - try (StreamProcessor sp = (StreamProcessor)f.createDataSource(clientContext(SDK_KEY, LDConfig.DEFAULT), null)) { + try (StreamProcessor sp = (StreamProcessor)f.createDataSource(clientContext(SDK_KEY, LDConfig.DEFAULT), + dataStoreUpdates(dataStore))) { assertThat(sp.initialReconnectDelay, equalTo(Duration.ofMillis(5555))); assertThat(sp.streamUri, equalTo(streamUri)); assertThat(((DefaultFeatureRequestor)sp.requestor).baseUri, equalTo(pollUri)); @@ -109,25 +113,29 @@ public void builderCanSpecifyConfiguration() throws Exception { @Test public void streamUriHasCorrectEndpoint() { createStreamProcessor(STREAM_URI).start(); - assertEquals(URI.create(STREAM_URI.toString() + "/all"), actualStreamUri); + assertEquals(URI.create(STREAM_URI.toString() + "/all"), + mockEventSourceCreator.getNextReceivedParams().streamUri); } @Test public void headersHaveAuthorization() { createStreamProcessor(STREAM_URI).start(); - assertEquals(SDK_KEY, headers.get("Authorization")); + assertEquals(SDK_KEY, + mockEventSourceCreator.getNextReceivedParams().headers.get("Authorization")); } @Test public void headersHaveUserAgent() { createStreamProcessor(STREAM_URI).start(); - assertEquals("JavaClient/" + LDClient.CLIENT_VERSION, headers.get("User-Agent")); + assertEquals("JavaClient/" + LDClient.CLIENT_VERSION, + mockEventSourceCreator.getNextReceivedParams().headers.get("User-Agent")); } @Test public void headersHaveAccept() { createStreamProcessor(STREAM_URI).start(); - assertEquals("text/event-stream", headers.get("Accept")); + assertEquals("text/event-stream", + mockEventSourceCreator.getNextReceivedParams().headers.get("Accept")); } @Test @@ -137,16 +145,19 @@ public void headersHaveWrapperWhenSet() { .wrapperVersion("0.1.0") .build(); createStreamProcessor(config, STREAM_URI).start(); - assertEquals("Scala/0.1.0", headers.get("X-LaunchDarkly-Wrapper")); + assertEquals("Scala/0.1.0", + mockEventSourceCreator.getNextReceivedParams().headers.get("X-LaunchDarkly-Wrapper")); } @Test public void putCausesFeatureToBeStored() throws Exception { createStreamProcessor(STREAM_URI).start(); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + MessageEvent event = new MessageEvent("{\"data\":{\"flags\":{\"" + FEATURE1_KEY + "\":" + featureJson(FEATURE1_KEY, FEATURE1_VERSION) + "}," + "\"segments\":{}}}"); - eventHandler.onMessage("put", event); + handler.onMessage("put", event); assertFeatureInStore(FEATURE); } @@ -154,9 +165,11 @@ public void putCausesFeatureToBeStored() throws Exception { @Test public void putCausesSegmentToBeStored() throws Exception { createStreamProcessor(STREAM_URI).start(); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + MessageEvent event = new MessageEvent("{\"data\":{\"flags\":{},\"segments\":{\"" + SEGMENT1_KEY + "\":" + segmentJson(SEGMENT1_KEY, SEGMENT1_VERSION) + "}}}"); - eventHandler.onMessage("put", event); + handler.onMessage("put", event); assertSegmentInStore(SEGMENT); } @@ -170,7 +183,8 @@ public void storeNotInitializedByDefault() throws Exception { @Test public void putCausesStoreToBeInitialized() throws Exception { createStreamProcessor(STREAM_URI).start(); - eventHandler.onMessage("put", emptyPutEvent()); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("put", emptyPutEvent()); assertTrue(dataStore.isInitialized()); } @@ -185,7 +199,8 @@ public void processorNotInitializedByDefault() throws Exception { public void putCausesProcessorToBeInitialized() throws Exception { StreamProcessor sp = createStreamProcessor(STREAM_URI); sp.start(); - eventHandler.onMessage("put", emptyPutEvent()); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("put", emptyPutEvent()); assertTrue(sp.isInitialized()); } @@ -200,19 +215,21 @@ public void futureIsNotSetByDefault() throws Exception { public void putCausesFutureToBeSet() throws Exception { StreamProcessor sp = createStreamProcessor(STREAM_URI); Future future = sp.start(); - eventHandler.onMessage("put", emptyPutEvent()); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("put", emptyPutEvent()); assertTrue(future.isDone()); } @Test public void patchUpdatesFeature() throws Exception { createStreamProcessor(STREAM_URI).start(); - eventHandler.onMessage("put", emptyPutEvent()); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("put", emptyPutEvent()); String path = "/flags/" + FEATURE1_KEY; MessageEvent event = new MessageEvent("{\"path\":\"" + path + "\",\"data\":" + featureJson(FEATURE1_KEY, FEATURE1_VERSION) + "}"); - eventHandler.onMessage("patch", event); + handler.onMessage("patch", event); assertFeatureInStore(FEATURE); } @@ -220,12 +237,13 @@ public void patchUpdatesFeature() throws Exception { @Test public void patchUpdatesSegment() throws Exception { createStreamProcessor(STREAM_URI).start(); - eventHandler.onMessage("put", emptyPutEvent()); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("put", emptyPutEvent()); String path = "/segments/" + SEGMENT1_KEY; MessageEvent event = new MessageEvent("{\"path\":\"" + path + "\",\"data\":" + segmentJson(SEGMENT1_KEY, SEGMENT1_VERSION) + "}"); - eventHandler.onMessage("patch", event); + handler.onMessage("patch", event); assertSegmentInStore(SEGMENT); } @@ -233,13 +251,14 @@ public void patchUpdatesSegment() throws Exception { @Test public void deleteDeletesFeature() throws Exception { createStreamProcessor(STREAM_URI).start(); - eventHandler.onMessage("put", emptyPutEvent()); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("put", emptyPutEvent()); upsertFlag(dataStore, FEATURE); String path = "/flags/" + FEATURE1_KEY; MessageEvent event = new MessageEvent("{\"path\":\"" + path + "\",\"version\":" + (FEATURE1_VERSION + 1) + "}"); - eventHandler.onMessage("delete", event); + handler.onMessage("delete", event); assertEquals(ItemDescriptor.deletedItem(FEATURE1_VERSION + 1), dataStore.get(FEATURES, FEATURE1_KEY)); } @@ -247,13 +266,14 @@ public void deleteDeletesFeature() throws Exception { @Test public void deleteDeletesSegment() throws Exception { createStreamProcessor(STREAM_URI).start(); - eventHandler.onMessage("put", emptyPutEvent()); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("put", emptyPutEvent()); upsertSegment(dataStore, SEGMENT); String path = "/segments/" + SEGMENT1_KEY; MessageEvent event = new MessageEvent("{\"path\":\"" + path + "\",\"version\":" + (SEGMENT1_VERSION + 1) + "}"); - eventHandler.onMessage("delete", event); + handler.onMessage("delete", event); assertEquals(ItemDescriptor.deletedItem(SEGMENT1_VERSION + 1), dataStore.get(SEGMENTS, SEGMENT1_KEY)); } @@ -264,7 +284,8 @@ public void indirectPutRequestsAndStoresFeature() throws Exception { setupRequestorToReturnAllDataWithFlag(FEATURE); replayAll(); - eventHandler.onMessage("indirect/put", new MessageEvent("")); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("indirect/put", new MessageEvent("")); assertFeatureInStore(FEATURE); } @@ -275,7 +296,8 @@ public void indirectPutInitializesStore() throws Exception { setupRequestorToReturnAllDataWithFlag(FEATURE); replayAll(); - eventHandler.onMessage("indirect/put", new MessageEvent("")); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("indirect/put", new MessageEvent("")); assertTrue(dataStore.isInitialized()); } @@ -287,7 +309,8 @@ public void indirectPutInitializesProcessor() throws Exception { setupRequestorToReturnAllDataWithFlag(FEATURE); replayAll(); - eventHandler.onMessage("indirect/put", new MessageEvent("")); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("indirect/put", new MessageEvent("")); assertTrue(dataStore.isInitialized()); } @@ -299,7 +322,8 @@ public void indirectPutSetsFuture() throws Exception { setupRequestorToReturnAllDataWithFlag(FEATURE); replayAll(); - eventHandler.onMessage("indirect/put", new MessageEvent("")); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("indirect/put", new MessageEvent("")); assertTrue(future.isDone()); } @@ -310,8 +334,9 @@ public void indirectPatchRequestsAndUpdatesFeature() throws Exception { expect(mockRequestor.getFlag(FEATURE1_KEY)).andReturn(FEATURE); replayAll(); - eventHandler.onMessage("put", emptyPutEvent()); - eventHandler.onMessage("indirect/patch", new MessageEvent("/flags/" + FEATURE1_KEY)); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("put", emptyPutEvent()); + handler.onMessage("indirect/patch", new MessageEvent("/flags/" + FEATURE1_KEY)); assertFeatureInStore(FEATURE); } @@ -322,8 +347,9 @@ public void indirectPatchRequestsAndUpdatesSegment() throws Exception { expect(mockRequestor.getSegment(SEGMENT1_KEY)).andReturn(SEGMENT); replayAll(); - eventHandler.onMessage("put", emptyPutEvent()); - eventHandler.onMessage("indirect/patch", new MessageEvent("/segments/" + SEGMENT1_KEY)); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("put", emptyPutEvent()); + handler.onMessage("indirect/patch", new MessageEvent("/segments/" + SEGMENT1_KEY)); assertSegmentInStore(SEGMENT); } @@ -331,12 +357,14 @@ public void indirectPatchRequestsAndUpdatesSegment() throws Exception { @Test public void unknownEventTypeDoesNotThrowException() throws Exception { createStreamProcessor(STREAM_URI).start(); - eventHandler.onMessage("what", new MessageEvent("")); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("what", new MessageEvent("")); } @Test public void streamWillReconnectAfterGeneralIOException() throws Exception { createStreamProcessor(STREAM_URI).start(); + ConnectionErrorHandler errorHandler = mockEventSourceCreator.getNextReceivedParams().errorHandler; ConnectionErrorHandler.Action action = errorHandler.onConnectionError(new IOException()); assertEquals(ConnectionErrorHandler.Action.PROCEED, action); } @@ -346,7 +374,8 @@ public void streamInitDiagnosticRecordedOnOpen() throws Exception { DiagnosticAccumulator acc = new DiagnosticAccumulator(new DiagnosticId(SDK_KEY)); long startTime = System.currentTimeMillis(); createStreamProcessor(LDConfig.DEFAULT, STREAM_URI, acc).start(); - eventHandler.onMessage("put", emptyPutEvent()); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("put", emptyPutEvent()); long timeAfterOpen = System.currentTimeMillis(); DiagnosticEvent.Statistics event = acc.createEventAndReset(0, 0); assertEquals(1, event.streamInits.size()); @@ -362,6 +391,7 @@ public void streamInitDiagnosticRecordedOnErrorDuringInit() throws Exception { DiagnosticAccumulator acc = new DiagnosticAccumulator(new DiagnosticId(SDK_KEY)); long startTime = System.currentTimeMillis(); createStreamProcessor(LDConfig.DEFAULT, STREAM_URI, acc).start(); + ConnectionErrorHandler errorHandler = mockEventSourceCreator.getNextReceivedParams().errorHandler; errorHandler.onConnectionError(new IOException()); long timeAfterOpen = System.currentTimeMillis(); DiagnosticEvent.Statistics event = acc.createEventAndReset(0, 0); @@ -377,10 +407,11 @@ public void streamInitDiagnosticRecordedOnErrorDuringInit() throws Exception { public void streamInitDiagnosticNotRecordedOnErrorAfterInit() throws Exception { DiagnosticAccumulator acc = new DiagnosticAccumulator(new DiagnosticId(SDK_KEY)); createStreamProcessor(LDConfig.DEFAULT, STREAM_URI, acc).start(); - eventHandler.onMessage("put", emptyPutEvent()); + StreamProcessor.EventSourceParams params = mockEventSourceCreator.getNextReceivedParams(); + params.handler.onMessage("put", emptyPutEvent()); // Drop first stream init from stream open acc.createEventAndReset(0, 0); - errorHandler.onConnectionError(new IOException()); + params.errorHandler.onConnectionError(new IOException()); DiagnosticEvent.Statistics event = acc.createEventAndReset(0, 0); assertEquals(0, event.streamInits.size()); } @@ -415,6 +446,38 @@ public void http500ErrorIsRecoverable() throws Exception { testRecoverableHttpError(500); } + @Test + public void restartsStreamIfStoreNeedsRefresh() throws Exception { + TestComponents.DataStoreWithStatusUpdates storeWithStatus = new TestComponents.DataStoreWithStatusUpdates(dataStore); + + mockEventSource.start(); + expectLastCall().times(1); + mockEventSource.close(); + expectLastCall().times(1); + mockRequestor.close(); + expectLastCall().times(1); + + SettableFuture restarted = SettableFuture.create(); + mockEventSource.restart(); + expectLastCall().andAnswer(() -> { + restarted.set(null); + return null; + }); + + replayAll(); + + try (StreamProcessor sp = new StreamProcessor(SDK_KEY, LDConfig.DEFAULT.httpConfig, mockRequestor, + dataStoreUpdates(storeWithStatus), mockEventSourceCreator, null, + STREAM_URI, StreamingDataSourceBuilder.DEFAULT_INITIAL_RECONNECT_DELAY)) { + sp.start(); + + storeWithStatus.broadcastStatusChange(new DataStoreStatusProvider.Status(false, false)); + storeWithStatus.broadcastStatusChange(new DataStoreStatusProvider.Status(true, true)); + + restarted.get(); + } + } + // 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. @@ -494,6 +557,7 @@ private void testUnrecoverableHttpError(int status) throws Exception { StreamProcessor sp = createStreamProcessor(STREAM_URI); Future initFuture = sp.start(); + ConnectionErrorHandler errorHandler = mockEventSourceCreator.getNextReceivedParams().errorHandler; ConnectionErrorHandler.Action action = errorHandler.onConnectionError(e); assertEquals(ConnectionErrorHandler.Action.SHUTDOWN, action); @@ -513,6 +577,7 @@ private void testRecoverableHttpError(int status) throws Exception { StreamProcessor sp = createStreamProcessor(STREAM_URI); Future initFuture = sp.start(); + ConnectionErrorHandler errorHandler = mockEventSourceCreator.getNextReceivedParams().errorHandler; ConnectionErrorHandler.Action action = errorHandler.onConnectionError(e); assertEquals(ConnectionErrorHandler.Action.PROCEED, action); @@ -536,7 +601,7 @@ private StreamProcessor createStreamProcessor(LDConfig config, URI streamUri) { private StreamProcessor createStreamProcessor(LDConfig config, URI streamUri, DiagnosticAccumulator diagnosticAccumulator) { return new StreamProcessor(SDK_KEY, config.httpConfig, mockRequestor, dataStoreUpdates(dataStore), - new StubEventSourceCreator(), diagnosticAccumulator, + mockEventSourceCreator, diagnosticAccumulator, streamUri, StreamingDataSourceBuilder.DEFAULT_INITIAL_RECONNECT_DELAY); } @@ -546,11 +611,11 @@ private StreamProcessor createStreamProcessorWithRealHttp(LDConfig config, URI s } private String featureJson(String key, int version) { - return "{\"key\":\"" + key + "\",\"version\":" + version + ",\"on\":true}"; + return gsonInstance().toJson(flagBuilder(key).version(version).build()); } private String segmentJson(String key, int version) { - return "{\"key\":\"" + key + "\",\"version\":" + version + ",\"includes\":[],\"excludes\":[],\"rules\":[]}"; + return gsonInstance().toJson(ModelBuilders.segmentBuilder(key).version(version).build()); } private MessageEvent emptyPutEvent() { @@ -559,7 +624,7 @@ private MessageEvent emptyPutEvent() { private void setupRequestorToReturnAllDataWithFlag(DataModel.FeatureFlag feature) throws Exception { FeatureRequestor.AllData data = new FeatureRequestor.AllData( - Collections.singletonMap(feature.getKey(), feature), Collections.emptyMap()); + Collections.singletonMap(feature.getKey(), feature), Collections.emptyMap()); expect(mockRequestor.getAllData()).andReturn(data); } @@ -570,15 +635,4 @@ private void assertFeatureInStore(DataModel.FeatureFlag feature) { private void assertSegmentInStore(DataModel.Segment segment) { assertEquals(segment.getVersion(), dataStore.get(SEGMENTS, segment.getKey()).getVersion()); } - - private class StubEventSourceCreator implements StreamProcessor.EventSourceCreator { - public EventSource createEventSource(EventHandler handler, URI streamUri, - Duration initialReconnectDelay, ConnectionErrorHandler errorHandler, Headers headers, HttpConfiguration httpConfig) { - StreamProcessorTest.this.eventHandler = handler; - StreamProcessorTest.this.actualStreamUri = streamUri; - StreamProcessorTest.this.errorHandler = errorHandler; - StreamProcessorTest.this.headers = headers; - return mockEventSource; - } - } } diff --git a/src/test/java/com/launchdarkly/sdk/server/TestComponents.java b/src/test/java/com/launchdarkly/sdk/server/TestComponents.java new file mode 100644 index 000000000..983b33a43 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/TestComponents.java @@ -0,0 +1,280 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.SettableFuture; +import com.launchdarkly.eventsource.EventSource; +import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.server.DataModel.FeatureFlag; +import com.launchdarkly.sdk.server.integrations.EventProcessorBuilder; +import com.launchdarkly.sdk.server.interfaces.ClientContext; +import com.launchdarkly.sdk.server.interfaces.DataSource; +import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; +import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; +import com.launchdarkly.sdk.server.interfaces.Event; +import com.launchdarkly.sdk.server.interfaces.EventProcessor; +import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.Future; +import java.util.concurrent.LinkedBlockingQueue; + +import static com.launchdarkly.sdk.server.DataModel.FEATURES; + +@SuppressWarnings("javadoc") +public class TestComponents { + public static ClientContext clientContext(final String sdkKey, final LDConfig config) { + return new ClientContextImpl(sdkKey, config, null); + } + + public static ClientContext clientContext(final String sdkKey, final LDConfig config, DiagnosticAccumulator diagnosticAccumulator) { + return new ClientContextImpl(sdkKey, config, diagnosticAccumulator); + } + + public static DataSourceFactory dataSourceWithData(FullDataSet data) { + return (context, dataStoreUpdates) -> new DataSourceWithData(data, dataStoreUpdates); + } + + public static DataStore dataStoreThatThrowsException(final RuntimeException e) { + return new DataStoreThatThrowsException(e); + } + + public static DataStoreUpdates dataStoreUpdates(final DataStore store) { + return new DataStoreUpdatesImpl(store, null); + } + + static EventsConfiguration defaultEventsConfig() { + return makeEventsConfig(false, false, null); + } + + public static DataSource failedDataSource() { + return new DataSourceThatNeverInitializes(); + } + + public static DataStore inMemoryDataStore() { + return new InMemoryDataStore(); // this is for tests in other packages which can't see this concrete class + } + + public static DataStore initedDataStore() { + DataStore store = new InMemoryDataStore(); + store.init(new FullDataSet(null)); + return store; + } + + static EventsConfiguration makeEventsConfig(boolean allAttributesPrivate, boolean inlineUsersInEvents, + Set privateAttributes) { + return new EventsConfiguration( + allAttributesPrivate, + 0, null, EventProcessorBuilder.DEFAULT_FLUSH_INTERVAL, + inlineUsersInEvents, + privateAttributes, + 0, 0, EventProcessorBuilder.DEFAULT_USER_KEYS_FLUSH_INTERVAL, + EventProcessorBuilder.DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL); + } + + public static DataSourceFactory specificDataSource(final DataSource up) { + return (context, dataStoreUpdates) -> up; + } + + public static DataStoreFactory specificDataStore(final DataStore store) { + return context -> store; + } + + public static EventProcessorFactory specificEventProcessor(final EventProcessor ep) { + return context -> ep; + } + + public static class TestEventProcessor implements EventProcessor { + List events = new ArrayList<>(); + + @Override + public void close() throws IOException {} + + @Override + public void sendEvent(Event e) { + events.add(e); + } + + @Override + public void flush() {} + } + + public static class DataSourceFactoryThatExposesUpdater implements DataSourceFactory { + private final FullDataSet initialData; + private DataStoreUpdates dataStoreUpdates; + + public DataSourceFactoryThatExposesUpdater(FullDataSet initialData) { + this.initialData = initialData; + } + + @Override + public DataSource createDataSource(ClientContext context, DataStoreUpdates dataStoreUpdates) { + this.dataStoreUpdates = dataStoreUpdates; + return dataSourceWithData(initialData).createDataSource(context, dataStoreUpdates); + } + + public void updateFlag(FeatureFlag flag) { + dataStoreUpdates.upsert(FEATURES, flag.getKey(), new ItemDescriptor(flag.getVersion(), flag)); + } + } + + private static class DataSourceThatNeverInitializes implements DataSource { + public Future start() { + return SettableFuture.create(); + } + + public boolean isInitialized() { + return false; + } + + public void close() throws IOException { + } + }; + + private static class DataSourceWithData implements DataSource { + private final FullDataSet data; + private final DataStoreUpdates dataStoreUpdates; + + DataSourceWithData(FullDataSet data, DataStoreUpdates dataStoreUpdates) { + this.data = data; + this.dataStoreUpdates = dataStoreUpdates; + } + + public Future start() { + dataStoreUpdates.init(data); + return Futures.immediateFuture(null); + } + + public boolean isInitialized() { + return true; + } + + public void close() throws IOException { + } + } + + private static class DataStoreThatThrowsException implements DataStore { + private final RuntimeException e; + + DataStoreThatThrowsException(RuntimeException e) { + this.e = e; + } + + public void close() throws IOException { } + + public ItemDescriptor get(DataKind kind, String key) { + throw e; + } + + public KeyedItems getAll(DataKind kind) { + throw e; + } + + public void init(FullDataSet allData) { } + + public boolean upsert(DataKind kind, String key, ItemDescriptor item) { + return true; + } + + public boolean isInitialized() { + return true; + } + } + + public static class DataStoreWithStatusUpdates implements DataStore, DataStoreStatusProvider { + private final DataStore wrappedStore; + private final List listeners = new ArrayList<>(); + volatile Status currentStatus = new Status(true, false); + + DataStoreWithStatusUpdates(DataStore wrappedStore) { + this.wrappedStore = wrappedStore; + } + + public void broadcastStatusChange(final Status newStatus) { + currentStatus = newStatus; + final StatusListener[] ls; + synchronized (this) { + ls = listeners.toArray(new StatusListener[listeners.size()]); + } + Thread t = new Thread(() -> { + for (StatusListener l: ls) { + l.dataStoreStatusChanged(newStatus); + } + }); + t.start(); + } + + public void close() throws IOException { + wrappedStore.close(); + } + + public ItemDescriptor get(DataKind kind, String key) { + return wrappedStore.get(kind, key); + } + + public KeyedItems getAll(DataKind kind) { + return wrappedStore.getAll(kind); + } + + public void init(FullDataSet allData) { + wrappedStore.init(allData); + } + + public boolean upsert(DataKind kind, String key, ItemDescriptor item) { + return wrappedStore.upsert(kind, key, item); + } + + public boolean isInitialized() { + return wrappedStore.isInitialized(); + } + + public Status getStoreStatus() { + return currentStatus; + } + + public boolean addStatusListener(StatusListener listener) { + synchronized (this) { + listeners.add(listener); + } + return true; + } + + public void removeStatusListener(StatusListener listener) { + synchronized (this) { + listeners.remove(listener); + } + } + + public CacheStats getCacheStats() { + return null; + } + } + + public static class MockEventSourceCreator implements StreamProcessor.EventSourceCreator { + private final EventSource eventSource; + private final BlockingQueue receivedParams = new LinkedBlockingQueue<>(); + + MockEventSourceCreator(EventSource eventSource) { + this.eventSource = eventSource; + } + + public EventSource createEventSource(StreamProcessor.EventSourceParams params) { + receivedParams.add(params); + return eventSource; + } + + public StreamProcessor.EventSourceParams getNextReceivedParams() { + return receivedParams.poll(); + } + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/TestUtil.java b/src/test/java/com/launchdarkly/sdk/server/TestUtil.java index 536cbf41c..ef34dd55a 100644 --- a/src/test/java/com/launchdarkly/sdk/server/TestUtil.java +++ b/src/test/java/com/launchdarkly/sdk/server/TestUtil.java @@ -1,28 +1,13 @@ package com.launchdarkly.sdk.server; import com.google.common.collect.ImmutableSet; -import com.google.common.util.concurrent.Futures; -import com.google.common.util.concurrent.SettableFuture; import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.LDValueType; -import com.launchdarkly.sdk.UserAttribute; import com.launchdarkly.sdk.server.DataModel.FeatureFlag; import com.launchdarkly.sdk.server.DataModel.Segment; -import com.launchdarkly.sdk.server.integrations.EventProcessorBuilder; -import com.launchdarkly.sdk.server.interfaces.ClientContext; -import com.launchdarkly.sdk.server.interfaces.DataSource; -import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; import com.launchdarkly.sdk.server.interfaces.DataStore; -import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; -import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; -import com.launchdarkly.sdk.server.interfaces.Event; -import com.launchdarkly.sdk.server.interfaces.EventProcessor; -import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; import com.launchdarkly.sdk.server.interfaces.FlagValueChangeEvent; @@ -32,14 +17,10 @@ import org.hamcrest.Matcher; import org.hamcrest.TypeSafeDiagnosingMatcher; -import java.io.IOException; -import java.util.ArrayList; import java.util.HashSet; -import java.util.List; import java.util.Set; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; -import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import static com.launchdarkly.sdk.server.DataModel.FEATURES; @@ -52,32 +33,6 @@ @SuppressWarnings("javadoc") public class TestUtil { - public static ClientContext clientContext(final String sdkKey, final LDConfig config) { - return new ClientContextImpl(sdkKey, config, null); - } - - public static ClientContext clientContext(final String sdkKey, final LDConfig config, DiagnosticAccumulator diagnosticAccumulator) { - return new ClientContextImpl(sdkKey, config, diagnosticAccumulator); - } - - public static DataStoreUpdates dataStoreUpdates(final DataStore store) { - return new DataStoreUpdatesImpl(store, null); - } - - public static DataStoreFactory specificDataStore(final DataStore store) { - return context -> store; - } - - public static DataStore inMemoryDataStore() { - return new InMemoryDataStore(); // this is for tests in other packages which can't see this concrete class - } - - public static DataStore initedDataStore() { - DataStore store = new InMemoryDataStore(); - store.init(new FullDataSet(null)); - return store; - } - public static void upsertFlag(DataStore store, FeatureFlag flag) { store.upsert(FEATURES, flag.getKey(), new ItemDescriptor(flag.getVersion(), flag)); } @@ -86,114 +41,6 @@ public static void upsertSegment(DataStore store, Segment segment) { store.upsert(SEGMENTS, segment.getKey(), new ItemDescriptor(segment.getVersion(), segment)); } - public static EventProcessorFactory specificEventProcessor(final EventProcessor ep) { - return context -> ep; - } - - public static DataSourceFactory specificDataSource(final DataSource up) { - return (context, dataStoreUpdates) -> up; - } - - public static DataSourceFactory dataSourceWithData(final FullDataSet data) { - return (ClientContext context, final DataStoreUpdates dataStoreUpdates) -> { - return new DataSource() { - public Future start() { - dataStoreUpdates.init(data); - return Futures.immediateFuture(null); - } - - public boolean isInitialized() { - return true; - } - - public void close() throws IOException { - } - }; - }; - } - - public static DataStore dataStoreThatThrowsException(final RuntimeException e) { - return new DataStore() { - @Override - public void close() throws IOException { } - - @Override - public ItemDescriptor get(DataKind kind, String key) { - throw e; - } - - @Override - public KeyedItems getAll(DataKind kind) { - throw e; - } - - @Override - public void init(FullDataSet allData) { } - - @Override - public boolean upsert(DataKind kind, String key, ItemDescriptor item) { - return true; - } - - @Override - public boolean isInitialized() { - return true; - } - }; - } - - public static DataSource failedDataSource() { - return new DataSource() { - @Override - public Future start() { - return SettableFuture.create(); - } - - @Override - public boolean isInitialized() { - return false; - } - - @Override - public void close() throws IOException { - } - }; - } - - public static class DataSourceFactoryThatExposesUpdater implements DataSourceFactory { - private final FullDataSet initialData; - private DataStoreUpdates dataStoreUpdates; - - public DataSourceFactoryThatExposesUpdater(FullDataSet initialData) { - this.initialData = initialData; - } - - @Override - public DataSource createDataSource(ClientContext context, DataStoreUpdates dataStoreUpdates) { - this.dataStoreUpdates = dataStoreUpdates; - return dataSourceWithData(initialData).createDataSource(context, dataStoreUpdates); - } - - public void updateFlag(FeatureFlag flag) { - dataStoreUpdates.upsert(FEATURES, flag.getKey(), new ItemDescriptor(flag.getVersion(), flag)); - } - } - - public static class TestEventProcessor implements EventProcessor { - List events = new ArrayList<>(); - - @Override - public void close() throws IOException {} - - @Override - public void sendEvent(Event e) { - events.add(e); - } - - @Override - public void flush() {} - } - public static class FlagChangeEventSink extends FlagChangeEventSinkBase implements FlagChangeListener { @Override public void onFlagChange(FlagChangeEvent event) { @@ -315,19 +162,4 @@ protected boolean matchesSafely(LDValue item, Description mismatchDescription) { } }; } - - static EventsConfiguration makeEventsConfig(boolean allAttributesPrivate, boolean inlineUsersInEvents, - Set privateAttributes) { - return new EventsConfiguration( - allAttributesPrivate, - 0, null, EventProcessorBuilder.DEFAULT_FLUSH_INTERVAL, - inlineUsersInEvents, - privateAttributes, - 0, 0, EventProcessorBuilder.DEFAULT_USER_KEYS_FLUSH_INTERVAL, - EventProcessorBuilder.DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL); - } - - static EventsConfiguration defaultEventsConfig() { - return makeEventsConfig(false, false, null); - } } diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/DataStoreWrapperWithFakeStatus.java b/src/test/java/com/launchdarkly/sdk/server/integrations/DataStoreWrapperWithFakeStatus.java deleted file mode 100644 index 7669d536a..000000000 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/DataStoreWrapperWithFakeStatus.java +++ /dev/null @@ -1,95 +0,0 @@ -package com.launchdarkly.sdk.server.integrations; - -import com.launchdarkly.sdk.server.interfaces.DataStore; -import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; - -import java.io.IOException; -import java.util.concurrent.BlockingQueue; -import java.util.concurrent.Callable; -import java.util.concurrent.LinkedBlockingDeque; - -@SuppressWarnings("javadoc") -public class DataStoreWrapperWithFakeStatus implements DataStore, DataStoreStatusProvider { - private final DataStore store; - private final PersistentDataStoreStatusManager statusManager; - private final BlockingQueue> initQueue = new LinkedBlockingDeque<>(); - private volatile boolean available; - - public DataStoreWrapperWithFakeStatus(DataStore store, boolean refreshOnRecovery) { - this.store = store; - this.statusManager = new PersistentDataStoreStatusManager(refreshOnRecovery, true, new Callable() { - public Boolean call() throws Exception { - return available; - } - }); - this.available = true; - } - - public void updateAvailability(boolean available) { - this.available = available; - statusManager.updateAvailability(available); - } - - public FullDataSet awaitInit() { - try { - return initQueue.take(); - } catch (InterruptedException e) { // shouldn't happen in our tests - throw new RuntimeException(e); - } - } - - @Override - public void close() throws IOException { - store.close(); - statusManager.close(); - } - - @Override - public Status getStoreStatus() { - return new PersistentDataStoreStatusManager.StatusImpl(available, false); - } - - @Override - public void addStatusListener(StatusListener listener) { - statusManager.addStatusListener(listener); - } - - @Override - public void removeStatusListener(StatusListener listener) { - statusManager.removeStatusListener(listener); - } - - @Override - public CacheStats getCacheStats() { - return null; - } - - @Override - public void init(FullDataSet allData) { - store.init(allData); - } - - @Override - public ItemDescriptor get(DataKind kind, String key) { - return store.get(kind, key); - } - - @Override - public KeyedItems getAll(DataKind kind) { - return store.getAll(kind); - } - - @Override - public boolean upsert(DataKind kind, String key, ItemDescriptor item) { - return store.upsert(kind, key, item); - } - - @Override - public boolean isInitialized() { - return store.isInitialized(); - } -} diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java index 3091fa197..0861c178c 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java @@ -1,7 +1,6 @@ package com.launchdarkly.sdk.server.integrations; import com.launchdarkly.sdk.server.LDConfig; -import com.launchdarkly.sdk.server.TestUtil; import com.launchdarkly.sdk.server.interfaces.DataSource; import com.launchdarkly.sdk.server.interfaces.DataStore; @@ -17,8 +16,9 @@ import static com.launchdarkly.sdk.server.DataModel.FEATURES; import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; import static com.launchdarkly.sdk.server.DataStoreTestTypes.toItemsMap; -import static com.launchdarkly.sdk.server.TestUtil.clientContext; -import static com.launchdarkly.sdk.server.TestUtil.dataStoreUpdates; +import static com.launchdarkly.sdk.server.TestComponents.clientContext; +import static com.launchdarkly.sdk.server.TestComponents.dataStoreUpdates; +import static com.launchdarkly.sdk.server.TestComponents.inMemoryDataStore; import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.ALL_FLAG_KEYS; import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.ALL_SEGMENT_KEYS; import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.getResourceContents; @@ -31,7 +31,7 @@ public class FileDataSourceTest { private static final Path badFilePath = Paths.get("no-such-file.json"); - private final DataStore store = TestUtil.inMemoryDataStore(); + private final DataStore store = inMemoryDataStore(); private final LDConfig config = new LDConfig.Builder().build(); private final FileDataSourceBuilder factory; From fea844d71af88634350560f43b41d6516541b898 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 8 Apr 2020 14:18:31 -0700 Subject: [PATCH 369/673] remove some unrelated changes for now --- .../sdk/server/StreamProcessor.java | 100 +++++------------- 1 file changed, 28 insertions(+), 72 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java index ead070fd5..3ddfb1a27 100644 --- a/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java @@ -16,7 +16,6 @@ import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; -import com.launchdarkly.sdk.server.interfaces.SerializationException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -262,16 +261,10 @@ public void onMessage(String name, MessageEvent event) throws Exception { break; } lastStoreUpdateFailed = false; - } catch (StreamInputException e) { - // See item 1 in error handling comments at top of class - logger.error("Malformed JSON data or other service error for streaming \"{0}\" event; will restart stream", - name, e.getCause().toString()); - logger.debug(e.getCause().toString(), e.getCause()); - es.restart(); } catch (StreamStoreException e) { // See item 2 in error handling comments at top of class if (!lastStoreUpdateFailed) { - logger.error("Unexpected data store failure when storing updates from stream: {0}", + logger.error("Unexpected data store failure when storing updates from stream: {}", e.getCause().toString()); logger.debug(e.getCause().toString(), e.getCause()); } @@ -282,19 +275,17 @@ public void onMessage(String name, MessageEvent event) throws Exception { es.restart(); } lastStoreUpdateFailed = true; + } catch (Exception e) { + logger.warn("Unexpected error from stream processor: {}", e.toString()); + logger.debug(e.toString(), e); } } - private void handlePut(String eventData) throws StreamInputException, StreamStoreException { + private void handlePut(String eventData) throws StreamStoreException { recordStreamInit(false); esStarted = 0; PutData putData = parseStreamJson(PutData.class, eventData); - FullDataSet allData; - try { - allData = putData.data.toFullDataSet(); - } catch (Exception e) { - throw new StreamInputException(e); - } + FullDataSet allData = putData.data.toFullDataSet(); try { dataStoreUpdates.init(allData); } catch (Exception e) { @@ -306,17 +297,12 @@ private void handlePut(String eventData) throws StreamInputException, StreamStor } } - private void handlePatch(String eventData) throws StreamInputException, StreamStoreException { + private void handlePatch(String eventData) throws StreamStoreException { PatchData data = parseStreamJson(PatchData.class, eventData); Map.Entry kindAndKey = getKindAndKeyFromStreamApiPath(data.path); DataKind kind = kindAndKey.getKey(); String key = kindAndKey.getValue(); - ItemDescriptor item; - try { - item = deserializeFromParsedJson(kind, data.data); - } catch (Exception e) { - throw new StreamInputException(e); - } + ItemDescriptor item = deserializeFromParsedJson(kind, data.data); try { dataStoreUpdates.upsert(kind, key, item); } catch (Exception e) { @@ -324,7 +310,7 @@ private void handlePatch(String eventData) throws StreamInputException, StreamSt } } - private void handleDelete(String eventData) throws StreamInputException, StreamStoreException { + private void handleDelete(String eventData) throws StreamStoreException { DeleteData data = parseStreamJson(DeleteData.class, eventData); Map.Entry kindAndKey = getKindAndKeyFromStreamApiPath(data.path); DataKind kind = kindAndKey.getKey(); @@ -337,19 +323,9 @@ private void handleDelete(String eventData) throws StreamInputException, StreamS } } - private void handleIndirectPut() throws StreamInputException, StreamStoreException { - FeatureRequestor.AllData putData; - try { - putData = requestor.getAllData(); - } catch (Exception e) { - throw new StreamInputException(e); - } - FullDataSet allData; - try { - allData = putData.toFullDataSet(); - } catch (Exception e) { - throw new StreamInputException(e); - } + private void handleIndirectPut() throws StreamStoreException, HttpErrorException, IOException { + FeatureRequestor.AllData putData = requestor.getAllData(); + FullDataSet allData = putData.toFullDataSet(); try { dataStoreUpdates.init(allData); } catch (Exception e) { @@ -361,16 +337,11 @@ private void handleIndirectPut() throws StreamInputException, StreamStoreExcepti } } - private void handleIndirectPatch(String path) throws StreamInputException, StreamStoreException { + private void handleIndirectPatch(String path) throws StreamStoreException, HttpErrorException, IOException { Map.Entry kindAndKey = getKindAndKeyFromStreamApiPath(path); DataKind kind = kindAndKey.getKey(); String key = kindAndKey.getValue(); - VersionedData item; - try { - item = kind == SEGMENTS ? requestor.getSegment(key) : requestor.getFlag(key); - } catch (Exception e) { - throw new StreamInputException(e); - } + VersionedData item = kind == SEGMENTS ? requestor.getSegment(key) : requestor.getFlag(key); try { dataStoreUpdates.upsert(kind, key, new ItemDescriptor(item.getVersion(), item)); } catch (Exception e) { @@ -409,50 +380,35 @@ public void configure(OkHttpClient.Builder builder) { return builder.build(); } - private static Map.Entry getKindAndKeyFromStreamApiPath(String path) throws StreamInputException { + private static Map.Entry getKindAndKeyFromStreamApiPath(String path) { for (DataKind kind: DataModel.ALL_DATA_KINDS) { String prefix = (kind == SEGMENTS) ? "/segments/" : "/flags/"; if (path.startsWith(prefix)) { return new AbstractMap.SimpleEntry<>(kind, path.substring(prefix.length())); } } - throw new StreamInputException(new IllegalArgumentException("unrecognized item path: " + path)); + throw new RuntimeException(new IllegalArgumentException("unrecognized item path: " + path)); } - private static T parseStreamJson(Class c, String json) throws StreamInputException { - try { - return JsonHelpers.gsonInstance().fromJson(json, c); - } catch (Exception e) { - throw new StreamInputException(e); - } + // This helper method is currently trivial but will be used for better error handling in the future, and helps to + // minimize usage of Gson-specific APIs throughout the code. + private static T parseStreamJson(Class c, String json) { + return JsonHelpers.gsonInstance().fromJson(json, c); } - private static ItemDescriptor deserializeFromParsedJson(DataKind kind, JsonElement parsedJson) throws SerializationException { + private static ItemDescriptor deserializeFromParsedJson(DataKind kind, JsonElement parsedJson) { VersionedData item; - try { - if (kind == FEATURES) { - item = JsonHelpers.gsonInstance().fromJson(parsedJson, DataModel.FeatureFlag.class); - } else if (kind == SEGMENTS) { - item = JsonHelpers.gsonInstance().fromJson(parsedJson, DataModel.Segment.class); - } else { // this case should never happen - return kind.deserialize(JsonHelpers.gsonInstance().toJson(parsedJson)); - } - } catch (Exception e) { - throw new SerializationException(e); + if (kind == FEATURES) { + item = JsonHelpers.gsonInstance().fromJson(parsedJson, DataModel.FeatureFlag.class); + } else if (kind == SEGMENTS) { + item = JsonHelpers.gsonInstance().fromJson(parsedJson, DataModel.Segment.class); + } else { // this case should never happen + return kind.deserialize(JsonHelpers.gsonInstance().toJson(parsedJson)); } return new ItemDescriptor(item.getVersion(), item); } - // Using these two exception wrapper types helps to simplify the error-handling logic. 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(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) { From 9aef9cb33fab583553ffa2481156684fff3b4464 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 10 Apr 2020 16:56:18 -0700 Subject: [PATCH 370/673] (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 b580014993a3f99b6ebb3d06f08cae7cd91164d6 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 13 Apr 2020 13:34:12 -0700 Subject: [PATCH 371/673] fix parameter to remove --- .../java/com/launchdarkly/sdk/server/DataModelDependencies.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/DataModelDependencies.java b/src/main/java/com/launchdarkly/sdk/server/DataModelDependencies.java index 6eba3c432..b3a653705 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DataModelDependencies.java +++ b/src/main/java/com/launchdarkly/sdk/server/DataModelDependencies.java @@ -209,7 +209,7 @@ public void updateDependenciesFrom(DataKind fromKind, String fromKey, ItemDescri } } if (updatedDependencies == null) { - dependenciesFrom.remove(fromKind, fromKey); + dependenciesFrom.remove(fromWhat); } else { dependenciesFrom.put(fromWhat, updatedDependencies); for (KindAndKey newDep: updatedDependencies) { From ff55d2de3a153086a101b511993e428a87bbc790 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 13 Apr 2020 14:45:19 -0700 Subject: [PATCH 372/673] fix test dependency that broke tests after updating to newer okhttp-eventsource --- build.gradle | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index 0c00810aa..09f603eec 100644 --- a/build.gradle +++ b/build.gradle @@ -83,8 +83,9 @@ libraries.external = [ // Add dependencies to "libraries.test" that are used only in unit tests. libraries.test = [ - "com.squareup.okhttp3:mockwebserver:4.3.1", - "com.squareup.okhttp3:okhttp-tls:4.3.1", + // Note that the okhttp3 test deps must be kept in sync with the okhttp version used in okhttp-eventsource + "com.squareup.okhttp3:mockwebserver:4.5.0", + "com.squareup.okhttp3:okhttp-tls:4.5.0", "org.hamcrest:hamcrest-all:1.3", "org.easymock:easymock:3.4", "junit:junit:4.12", From 2d17d43d8db8378a07935de29f3fb4ee530e70ab Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 13 Apr 2020 15:56:55 -0700 Subject: [PATCH 373/673] simplify flag value listener implementation, make listener immutable --- .../launchdarkly/sdk/server/Components.java | 47 ++----------------- .../server/FlagValueMonitoringListener.java | 42 +++++++++++++++++ .../com/launchdarkly/sdk/server/LDClient.java | 7 --- .../interfaces/FlagValueChangeEvent.java | 2 +- .../interfaces/FlagValueChangeListener.java | 6 +-- .../interfaces/ListenerRegistration.java | 30 ------------ .../launchdarkly/sdk/server/LDClientTest.java | 4 +- 7 files changed, 52 insertions(+), 86 deletions(-) create mode 100644 src/main/java/com/launchdarkly/sdk/server/FlagValueMonitoringListener.java delete mode 100644 src/main/java/com/launchdarkly/sdk/server/interfaces/ListenerRegistration.java diff --git a/src/main/java/com/launchdarkly/sdk/server/Components.java b/src/main/java/com/launchdarkly/sdk/server/Components.java index 262900613..ce8056909 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Components.java +++ b/src/main/java/com/launchdarkly/sdk/server/Components.java @@ -17,17 +17,13 @@ import com.launchdarkly.sdk.server.interfaces.Event; import com.launchdarkly.sdk.server.interfaces.EventProcessor; import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; -import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; -import com.launchdarkly.sdk.server.interfaces.ListenerRegistration; -import com.launchdarkly.sdk.server.interfaces.FlagValueChangeEvent; import com.launchdarkly.sdk.server.interfaces.FlagValueChangeListener; import com.launchdarkly.sdk.server.interfaces.PersistentDataStoreFactory; import java.io.IOException; import java.net.URI; import java.util.concurrent.Future; -import java.util.concurrent.atomic.AtomicReference; import static com.google.common.util.concurrent.Futures.immediateFuture; @@ -226,6 +222,7 @@ public static DataSourceFactory externalUpdatesOnly() { *

    * See {@link FlagValueChangeListener} for more information and examples. * + * @param client the same client instance that you will be registering this listener with * @param flagKey the flag key to be evaluated * @param user the user properties for evaluation * @param valueChangeListener an object that you provide which will be notified of changes @@ -235,8 +232,9 @@ public static DataSourceFactory externalUpdatesOnly() { * @see FlagValueChangeListener * @see FlagChangeListener */ - public static FlagChangeListener flagValueMonitoringListener(String flagKey, LDUser user, FlagValueChangeListener valueChangeListener) { - return new FlagValueMonitorImpl(flagKey, user, valueChangeListener); + public static FlagChangeListener flagValueMonitoringListener(LDClientInterface client, String flagKey, LDUser user, + FlagValueChangeListener valueChangeListener) { + return new FlagValueMonitoringListener(client, flagKey, user, valueChangeListener); } private static final class InMemoryDataStoreFactory implements DataStoreFactory, DiagnosticDescription { @@ -473,41 +471,4 @@ public LDValue describeConfiguration(LDConfig config) { return LDValue.of("custom"); } } - - private static final class FlagValueMonitorImpl implements FlagChangeListener, ListenerRegistration { - private volatile LDClientInterface client; - private AtomicReference currentValue = new AtomicReference<>(LDValue.ofNull()); - private final String flagKey; - private final LDUser user; - private final FlagValueChangeListener valueChangeListener; - - public FlagValueMonitorImpl(String flagKey, LDUser user, FlagValueChangeListener valueChangeListener) { - this.flagKey = flagKey; - this.user = user; - this.valueChangeListener = valueChangeListener; - } - - @Override - public void onRegister(LDClientInterface client) { - this.client = client; - currentValue.set(client.jsonValueVariation(flagKey, user, LDValue.ofNull())); - } - - @Override - public void onUnregister(LDClientInterface client) {} - - @Override - public void onFlagChange(FlagChangeEvent event) { - if (event.getKey().equals(flagKey)) { - LDClientInterface c = client; - if (c != null) { // shouldn't be possible to be null since we wouldn't get an event if we were never registered - LDValue newValue = c.jsonValueVariation(flagKey, user, LDValue.ofNull()); - LDValue previousValue = currentValue.getAndSet(newValue); - if (!newValue.equals(previousValue)) { - valueChangeListener.onFlagValueChange(new FlagValueChangeEvent(flagKey, previousValue, newValue)); - } - } - } - } - } } diff --git a/src/main/java/com/launchdarkly/sdk/server/FlagValueMonitoringListener.java b/src/main/java/com/launchdarkly/sdk/server/FlagValueMonitoringListener.java new file mode 100644 index 000000000..0d756bdae --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/FlagValueMonitoringListener.java @@ -0,0 +1,42 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; +import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; +import com.launchdarkly.sdk.server.interfaces.FlagValueChangeEvent; +import com.launchdarkly.sdk.server.interfaces.FlagValueChangeListener; + +import java.util.concurrent.atomic.AtomicReference; + +/** + * Implementation of the flag change listener wrapper provided by + * {@link Components#flagValueMonitoringListener(LDClientInterface, String, com.launchdarkly.sdk.LDUser, com.launchdarkly.sdk.server.interfaces.FlagValueChangeListener)}. + * This class is deliberately not public, it is an implementation detail. + */ +final class FlagValueMonitoringListener implements FlagChangeListener { + private final LDClientInterface client; + private final AtomicReference currentValue = new AtomicReference<>(LDValue.ofNull()); + private final String flagKey; + private final LDUser user; + private final FlagValueChangeListener valueChangeListener; + + public FlagValueMonitoringListener(LDClientInterface client, String flagKey, LDUser user, FlagValueChangeListener valueChangeListener) { + this.client = client; + this.flagKey = flagKey; + this.user = user; + this.valueChangeListener = valueChangeListener; + currentValue.set(client.jsonValueVariation(flagKey, user, LDValue.ofNull())); + } + + @Override + public void onFlagChange(FlagChangeEvent event) { + if (event.getKey().equals(flagKey)) { + LDValue newValue = client.jsonValueVariation(flagKey, user, LDValue.ofNull()); + LDValue previousValue = currentValue.getAndSet(newValue); + if (!newValue.equals(previousValue)) { + valueChangeListener.onFlagValueChange(new FlagValueChangeEvent(flagKey, previousValue, newValue)); + } + } + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClient.java b/src/main/java/com/launchdarkly/sdk/server/LDClient.java index d0e41bd29..395d2ea84 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClient.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClient.java @@ -16,7 +16,6 @@ import com.launchdarkly.sdk.server.interfaces.EventProcessor; import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; -import com.launchdarkly.sdk.server.interfaces.ListenerRegistration; import org.apache.commons.codec.binary.Hex; import org.slf4j.Logger; @@ -383,18 +382,12 @@ private Evaluator.EvalResult evaluateInternal(String featureKey, LDUser user, LD @Override public void registerFlagChangeListener(FlagChangeListener listener) { - if (listener instanceof ListenerRegistration) { - ((ListenerRegistration)listener).onRegister(this); - } flagChangeEventPublisher.register(listener); } @Override public void unregisterFlagChangeListener(FlagChangeListener listener) { flagChangeEventPublisher.unregister(listener); - if (listener instanceof ListenerRegistration) { - ((ListenerRegistration)listener).onUnregister(this); - } } @Override diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeEvent.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeEvent.java index 3b75600cc..81767de46 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeEvent.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeEvent.java @@ -11,7 +11,7 @@ * @see FlagValueChangeListener * @see com.launchdarkly.sdk.server.LDClientInterface#registerFlagChangeListener(FlagChangeListener) * @see com.launchdarkly.sdk.server.LDClientInterface#unregisterFlagChangeListener(FlagChangeListener) - * @see com.launchdarkly.sdk.server.Components#flagValueMonitoringListener(String, com.launchdarkly.sdk.LDUser, FlagValueChangeListener) + * @see com.launchdarkly.sdk.server.Components#flagValueMonitoringListener(com.launchdarkly.sdk.server.LDClientInterface, String, com.launchdarkly.sdk.LDUser, FlagValueChangeListener) */ public class FlagValueChangeEvent extends FlagChangeEvent { private final LDValue oldValue; diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeListener.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeListener.java index d2f058bf6..d5390828c 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeListener.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeListener.java @@ -4,7 +4,7 @@ * An event listener that is notified when a feature flag's value has changed for a specific user. *

    * Use this in conjunction with - * {@link com.launchdarkly.sdk.server.Components#flagValueMonitoringListener(String, com.launchdarkly.sdk.LDUser, FlagValueChangeListener)} + * {@link com.launchdarkly.sdk.server.Components#flagValueMonitoringListener(com.launchdarkly.sdk.server.LDClientInterface, String, com.launchdarkly.sdk.LDUser, FlagValueChangeListener)} * if you want the client to re-evaluate a flag for a specific set of user properties whenever * the flag's configuration has changed, and notify you only if the new value is different from the old * value. The listener will not be notified if the flag's configuration is changed in some way that does @@ -19,7 +19,7 @@ * } * }; * client.registerFlagChangeListener(Components.flagValueMonitoringListener( - * flagKey, userForFlagEvaluation, listenForNewValue)); + * client, flagKey, userForFlagEvaluation, listenForNewValue)); *

    * * In the above example, the value provided in {@code event.getNewValue()} is the result of calling @@ -30,7 +30,7 @@ * @see FlagChangeListener * @see com.launchdarkly.sdk.server.LDClientInterface#registerFlagChangeListener(FlagChangeListener) * @see com.launchdarkly.sdk.server.LDClientInterface#unregisterFlagChangeListener(FlagChangeListener) - * @see com.launchdarkly.sdk.server.Components#flagValueMonitoringListener(String, com.launchdarkly.sdk.LDUser, FlagValueChangeListener) + * @see com.launchdarkly.sdk.server.Components#flagValueMonitoringListener(com.launchdarkly.sdk.server.LDClientInterface, String, com.launchdarkly.sdk.LDUser, FlagValueChangeListener) */ public interface FlagValueChangeListener { /** diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/ListenerRegistration.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/ListenerRegistration.java deleted file mode 100644 index d70681970..000000000 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/ListenerRegistration.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.launchdarkly.sdk.server.interfaces; - -import com.launchdarkly.sdk.server.LDClientInterface; - -/** - * This interface can be implemented by any event listener that needs to know when it has been - * registered or unregistered with a client instance. - * - * @see LDClientInterface#registerFlagChangeListener(FlagChangeListener) - * @see LDClientInterface#unregisterFlagChangeListener(FlagChangeListener) - * @since 5.0.0 - */ -public interface ListenerRegistration { - /** - * The SDK calls this method when the listener is being registered with a client so it will receive events. - *

    - * The listener is not yet registered at this point so it cannot have received any events yet. - * - * @param client the client instance that is registering the listener - */ - void onRegister(LDClientInterface client); - - /** - * The SDK calls this method when the listener has been unregistered with a client so it will no longer - * receive events. - * - * @param client the client instance that has unregistered the listener - */ - void onUnregister(LDClientInterface client); -} diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java index 614fe4f9c..cb16ac472 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java @@ -445,8 +445,8 @@ public void clientSendsFlagValueChangeEvents() throws Exception { client = new LDClient(SDK_KEY, config); FlagValueChangeEventSink eventSink1 = new FlagValueChangeEventSink(); FlagValueChangeEventSink eventSink2 = new FlagValueChangeEventSink(); - client.registerFlagChangeListener(Components.flagValueMonitoringListener(flagKey, user, eventSink1)); - client.registerFlagChangeListener(Components.flagValueMonitoringListener(flagKey, otherUser, eventSink2)); + client.registerFlagChangeListener(Components.flagValueMonitoringListener(client, flagKey, user, eventSink1)); + client.registerFlagChangeListener(Components.flagValueMonitoringListener(client, flagKey, otherUser, eventSink2)); eventSink1.expectNoEvents(); eventSink2.expectNoEvents(); From dd177867172ee4e78d719bfa7cc3b86a9f94919a Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 13 Apr 2020 16:00:29 -0700 Subject: [PATCH 374/673] comment --- .../com/launchdarkly/sdk/server/DataStoreUpdatesImpl.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/com/launchdarkly/sdk/server/DataStoreUpdatesImpl.java b/src/main/java/com/launchdarkly/sdk/server/DataStoreUpdatesImpl.java index 74fcd3ef0..f293737e4 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DataStoreUpdatesImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/DataStoreUpdatesImpl.java @@ -134,6 +134,10 @@ private Set computeChangedItemsForFullDataSet(Map Date: Mon, 13 Apr 2020 16:45:21 -0700 Subject: [PATCH 375/673] don't stop polling store status if write from cache fails --- .../sdk/server/integrations/PersistentDataStoreWrapper.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapper.java b/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapper.java index b07b7802d..576c8acae 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapper.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapper.java @@ -438,10 +438,10 @@ private boolean pollAvailabilityAfterOutage() { if (e == null) { logger.warn("Successfully updated persistent store from cached data"); } else { - // We failed to write the cached data to the underlying store. In this case, - // initCore() has already put us back into the failed state. The only further - // thing we can do is to log a note about what just happened. + // We failed to write the cached data to the underlying store. In this case, we should not + // return to a recovered state, but just try this all again next time the poll task runs. logger.error("Tried to write cached data to persistent store after a store outage, but failed: {0}", e); + return false; } } From fb8044dc4f1475b847c2a92eaf4c918b56b50df3 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 13 Apr 2020 16:58:07 -0700 Subject: [PATCH 376/673] reduce worker threads for store status from 2 to 1 --- .../server/integrations/PersistentDataStoreStatusManager.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreStatusManager.java b/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreStatusManager.java index 948771a95..f2daebc2d 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreStatusManager.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreStatusManager.java @@ -42,7 +42,9 @@ final class PersistentDataStoreStatusManager { ThreadFactory threadFactory = new ThreadFactoryBuilder() .setNameFormat("LaunchDarkly-DataStoreStatusManager-%d") .build(); - scheduler = Executors.newScheduledThreadPool(2, threadFactory); + scheduler = Executors.newSingleThreadScheduledExecutor(threadFactory); + // Using newSingleThreadScheduledExecutor avoids ambiguity about execution order if we might have + // have a StatusNotificationTask happening soon after another one. } synchronized void addStatusListener(StatusListener listener) { From 8bfffe2867dc7e610a5dd219974b900eb5bd7b10 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 13 Apr 2020 17:02:31 -0700 Subject: [PATCH 377/673] synchronize again to avoid race condition on creating poller --- .../PersistentDataStoreStatusManager.java | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreStatusManager.java b/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreStatusManager.java index f2daebc2d..8dc40f442 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreStatusManager.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreStatusManager.java @@ -79,9 +79,11 @@ void updateAvailability(boolean available) { // If the store has just become unavailable, start a poller to detect when it comes back. If it has // become available, stop any polling we are currently doing. if (available) { - if (pollerFuture != null) { // don't need to synchronize access here because the state transition was already synchronized above - pollerFuture.cancel(false); - pollerFuture = null; + synchronized (this) { + if (pollerFuture != null) { + pollerFuture.cancel(false); + pollerFuture = null; + } } } else { logger.warn("Detected persistent store unavailability; updates will be cached until it recovers"); @@ -98,7 +100,11 @@ public void run() { } } }; - pollerFuture = scheduler.scheduleAtFixedRate(pollerTask, POLL_INTERVAL_MS, POLL_INTERVAL_MS, TimeUnit.MILLISECONDS); + synchronized (this) { + if (pollerFuture == null) { + pollerFuture = scheduler.scheduleAtFixedRate(pollerTask, POLL_INTERVAL_MS, POLL_INTERVAL_MS, TimeUnit.MILLISECONDS); + } + } } } From f77c8467a05e8eb6ec297cc5cf61e747a0feb7a1 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 13 Apr 2020 18:01:34 -0700 Subject: [PATCH 378/673] misc fixes --- .../sdk/server/StreamProcessor.java | 9 ++++-- .../sdk/server/StreamProcessorTest.java | 30 +++++++++++-------- .../sdk/server/TestComponents.java | 6 ++-- 3 files changed, 28 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java index fe1d3ba73..4dc537f1f 100644 --- a/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java @@ -334,8 +334,13 @@ private void handleDelete(String eventData) throws StreamInputException, StreamS } } - private void handleIndirectPut() throws StreamStoreException, HttpErrorException, IOException { - FeatureRequestor.AllData putData = requestor.getAllData(); + private void handleIndirectPut() throws StreamInputException, StreamStoreException { + FeatureRequestor.AllData putData; + try { + putData = requestor.getAllData(); + } catch (Exception e) { + throw new StreamInputException(e); + } FullDataSet allData = putData.toFullDataSet(); try { dataStoreUpdates.init(allData); diff --git a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java index 964a6591c..50d2ad165 100644 --- a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java @@ -475,22 +475,22 @@ public void http500ErrorIsRecoverable() throws Exception { @Test public void putEventWithInvalidJsonCausesStreamRestart() throws Exception { - verifyEventCausesStreamRestart("put", "{sorry"); + verifyEventCausesStreamRestartWithInMemoryStore("put", "{sorry"); } @Test public void putEventWithWellFormedJsonButInvalidDataCausesStreamRestart() throws Exception { - verifyEventCausesStreamRestart("put", "{\"data\":{\"flags\":3}}"); + verifyEventCausesStreamRestartWithInMemoryStore("put", "{\"data\":{\"flags\":3}}"); } @Test public void patchEventWithInvalidJsonCausesStreamRestart() throws Exception { - verifyEventCausesStreamRestart("patch", "{sorry"); + verifyEventCausesStreamRestartWithInMemoryStore("patch", "{sorry"); } @Test public void patchEventWithWellFormedJsonButInvalidDataCausesStreamRestart() throws Exception { - verifyEventCausesStreamRestart("patch", "{\"path\":\"/flags/flagkey\", \"data\":{\"rules\":3}}"); + verifyEventCausesStreamRestartWithInMemoryStore("patch", "{\"path\":\"/flags/flagkey\", \"data\":{\"rules\":3}}"); } @Test @@ -500,7 +500,7 @@ public void patchEventWithInvalidPathCausesNoStreamRestart() throws Exception { @Test public void deleteEventWithInvalidJsonCausesStreamRestart() throws Exception { - verifyEventCausesStreamRestart("delete", "{sorry"); + verifyEventCausesStreamRestartWithInMemoryStore("delete", "{sorry"); } @Test @@ -516,13 +516,13 @@ public void indirectPatchEventWithInvalidPathDoesNotCauseStreamRestart() throws @Test public void indirectPutWithFailedPollCausesStreamRestart() throws Exception { expect(mockRequestor.getAllData()).andThrow(new IOException("sorry")); - verifyEventCausesStreamRestart("indirect/put", ""); + verifyEventCausesStreamRestartWithInMemoryStore("indirect/put", ""); } @Test public void indirectPatchWithFailedPollCausesStreamRestart() throws Exception { expect(mockRequestor.getFlag("flagkey")).andThrow(new IOException("sorry")); - verifyEventCausesStreamRestart("indirect/patch", "/flags/flagkey"); + verifyEventCausesStreamRestartWithInMemoryStore("indirect/patch", "/flags/flagkey"); } @Test @@ -530,17 +530,21 @@ public void restartsStreamIfStoreNeedsRefresh() throws Exception { TestComponents.DataStoreWithStatusUpdates storeWithStatus = new TestComponents.DataStoreWithStatusUpdates(dataStore); SettableFuture restarted = SettableFuture.create(); + mockEventSource.start(); + expectLastCall(); mockEventSource.restart(); expectLastCall().andAnswer(() -> { restarted.set(null); return null; }); + mockEventSource.close(); + expectLastCall(); + mockRequestor.close(); + expectLastCall(); replayAll(); - try (StreamProcessor sp = new StreamProcessor(SDK_KEY, LDConfig.DEFAULT.httpConfig, mockRequestor, - dataStoreUpdates(storeWithStatus), mockEventSourceCreator, null, - STREAM_URI, StreamingDataSourceBuilder.DEFAULT_INITIAL_RECONNECT_DELAY)) { + try (StreamProcessor sp = createStreamProcessorWithStore(storeWithStatus)) { sp.start(); storeWithStatus.broadcastStatusChange(new DataStoreStatusProvider.Status(false, false)); @@ -630,7 +634,7 @@ private void verifyEventCausesNoStreamRestart(String eventName, String eventData verifyEventBehavior(eventName, eventData); } - private void verifyEventCausesStreamRestart(String eventName, String eventData) throws Exception { + private void verifyEventCausesStreamRestartWithInMemoryStore(String eventName, String eventData) throws Exception { expectStreamRestart(); verifyEventBehavior(eventName, eventData); } @@ -788,7 +792,7 @@ private StreamProcessor createStreamProcessor(LDConfig config, URI streamUri) { private StreamProcessor createStreamProcessor(LDConfig config, URI streamUri, DiagnosticAccumulator diagnosticAccumulator) { return new StreamProcessor(SDK_KEY, config.httpConfig, mockRequestor, dataStoreUpdates(dataStore), - new MockEventSourceCreator(mockEventSource), diagnosticAccumulator, + mockEventSourceCreator, diagnosticAccumulator, streamUri, DEFAULT_INITIAL_RECONNECT_DELAY); } @@ -799,7 +803,7 @@ private StreamProcessor createStreamProcessorWithRealHttp(LDConfig config, URI s private StreamProcessor createStreamProcessorWithStore(DataStore store) { return new StreamProcessor(SDK_KEY, LDConfig.DEFAULT.httpConfig, mockRequestor, dataStoreUpdates(store), - new MockEventSourceCreator(mockEventSource), null, STREAM_URI, DEFAULT_INITIAL_RECONNECT_DELAY); + mockEventSourceCreator, null, STREAM_URI, DEFAULT_INITIAL_RECONNECT_DELAY); } private String featureJson(String key, int version) { diff --git a/src/test/java/com/launchdarkly/sdk/server/TestComponents.java b/src/test/java/com/launchdarkly/sdk/server/TestComponents.java index 983b33a43..0300a2791 100644 --- a/src/test/java/com/launchdarkly/sdk/server/TestComponents.java +++ b/src/test/java/com/launchdarkly/sdk/server/TestComponents.java @@ -180,10 +180,12 @@ public KeyedItems getAll(DataKind kind) { throw e; } - public void init(FullDataSet allData) { } + public void init(FullDataSet allData) { + throw e; + } public boolean upsert(DataKind kind, String key, ItemDescriptor item) { - return true; + throw e; } public boolean isInitialized() { From 0fc7bb3219162e07a8ffce4ebd1dde015eaa7182 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 15 Apr 2020 15:09:50 -0700 Subject: [PATCH 379/673] add test for error handling during store outages --- .../PersistentDataStoreStatusManager.java | 2 +- .../integrations/MockPersistentDataStore.java | 3 + .../PersistentDataStoreWrapperTest.java | 68 +++++++++++++++++++ 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreStatusManager.java b/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreStatusManager.java index 8dc40f442..ded532f8d 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreStatusManager.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreStatusManager.java @@ -25,7 +25,7 @@ */ final class PersistentDataStoreStatusManager { private static final Logger logger = LoggerFactory.getLogger(PersistentDataStoreStatusManager.class); - private static final int POLL_INTERVAL_MS = 500; + static final int POLL_INTERVAL_MS = 500; // visible for testing private final List listeners = new ArrayList<>(); private final ScheduledExecutorService scheduler; diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/MockPersistentDataStore.java b/src/test/java/com/launchdarkly/sdk/server/integrations/MockPersistentDataStore.java index 410a9d8b2..f8697dbb7 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/MockPersistentDataStore.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/MockPersistentDataStore.java @@ -13,6 +13,7 @@ import java.util.LinkedHashMap; import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; @SuppressWarnings("javadoc") public final class MockPersistentDataStore implements PersistentDataStore { @@ -23,6 +24,7 @@ public static final class MockDatabaseInstance { final Map> data; final AtomicBoolean inited; + final AtomicInteger initedCount = new AtomicInteger(0); volatile int initedQueryCount; volatile boolean persistOnlyAsString; volatile boolean unavailable; @@ -77,6 +79,7 @@ public KeyedItems getAll(DataKind kind) { @Override public void init(FullDataSet allData) { + initedCount.incrementAndGet(); maybeThrow(); data.clear(); for (Map.Entry> entry: allData.getData()) { diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapperTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapperTest.java index 5ff0b1ece..1db918cbd 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapperTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapperTest.java @@ -28,6 +28,7 @@ import static com.launchdarkly.sdk.server.DataStoreTestTypes.toSerialized; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThan; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.nullValue; import static org.junit.Assert.fail; @@ -599,6 +600,73 @@ public void cacheIsWrittenToStoreAfterRecoveryIfTtlIsInfinite() throws Exception assertThat(core.data.get(TEST_ITEMS).get(item2.key), equalTo(item2.toSerializedItemDescriptor())); } + @Test + public void statusRemainsUnavailableIfStoreSaysItIsAvailableButInitFails() throws Exception { + assumeThat(testMode.isCachedIndefinitely(), is(true)); + + // Most of this test is identical to cacheIsWrittenToStoreAfterRecoveryIfTtlIsInfinite() except as noted below. + + final BlockingQueue statuses = new LinkedBlockingQueue<>(); + wrapper.addStatusListener(statuses::add); + + TestItem item1v1 = new TestItem("key1", 1); + TestItem item1v2 = item1v1.withVersion(2); + TestItem item2 = new TestItem("key2", 1); + + wrapper.init(new DataBuilder().add(TEST_ITEMS, item1v1).build()); + + causeStoreError(core, wrapper); + try { + wrapper.upsert(TEST_ITEMS, item1v1.key, item1v2.toItemDescriptor()); + fail("expected exception"); + } catch (RuntimeException e) { + assertThat(e.getMessage(), equalTo(FAKE_ERROR.getMessage())); + } + assertThat(wrapper.get(TEST_ITEMS, item1v1.key), equalTo(item1v2.toItemDescriptor())); + + DataStoreStatusProvider.Status status1 = statuses.take(); + assertThat(status1.isAvailable(), is(false)); + assertThat(status1.isRefreshNeeded(), is(false)); + + // While the store is still down, try to update it again - the update goes into the cache + try { + wrapper.upsert(TEST_ITEMS, item2.key, item2.toItemDescriptor()); + fail("expected exception"); + } catch (RuntimeException e) { + assertThat(e.getMessage(), equalTo(FAKE_ERROR.getMessage())); + } + assertThat(wrapper.get(TEST_ITEMS, item2.key), equalTo(item2.toItemDescriptor())); + + // Verify that this update did not go into the underlying data yet + assertThat(core.data.get(TEST_ITEMS).get(item2.key), nullValue()); + + // Here's what is unique to this test: we are telling the store to report its status as "available", + // but *not* clearing the fake exception, so when the poller tries to write the cached data with + // init() it should fail. + core.unavailable = false; + + // We can't prove that an unwanted status transition will never happen, but we can verify that it + // does not happen within two status poll intervals. + Thread.sleep(PersistentDataStoreStatusManager.POLL_INTERVAL_MS * 2); + + assertThat(statuses.isEmpty(), is(true)); + int initedCount = core.initedCount.get(); + assertThat(initedCount, greaterThan(1)); // that is, it *tried* to do at least one init + + // Now simulate the store coming back up and actually working + core.fakeError = null; + + // Wait for the poller to notice this and publish a new status + DataStoreStatusProvider.Status status2 = statuses.take(); + assertThat(status2.isAvailable(), is(true)); + assertThat(status2.isRefreshNeeded(), is(false)); + + // Once that has happened, the cache should have been written to the store + assertThat(core.data.get(TEST_ITEMS).get(item1v1.key), equalTo(item1v2.toSerializedItemDescriptor())); + assertThat(core.data.get(TEST_ITEMS).get(item2.key), equalTo(item2.toSerializedItemDescriptor())); + assertThat(core.initedCount.get(), greaterThan(initedCount)); + } + private void causeStoreError(MockPersistentDataStore core, PersistentDataStoreWrapper w) { core.unavailable = true; core.fakeError = new RuntimeException(FAKE_ERROR.getMessage()); From cad1e1fd6d72fabcd82f84c005c1e51d413b13cd Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 15 Apr 2020 16:08:12 -0700 Subject: [PATCH 380/673] 5.0: remove NewRelicReflector --- .../com/launchdarkly/sdk/server/LDClient.java | 1 - .../sdk/server/NewRelicReflector.java | 45 ------------------- 2 files changed, 46 deletions(-) delete mode 100644 src/main/java/com/launchdarkly/sdk/server/NewRelicReflector.java diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClient.java b/src/main/java/com/launchdarkly/sdk/server/LDClient.java index 395d2ea84..4a02e333b 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClient.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClient.java @@ -180,7 +180,6 @@ public void identify(LDUser user) { private void sendFlagRequestEvent(Event.FeatureRequest event) { eventProcessor.sendEvent(event); - NewRelicReflector.annotateTransaction(event.getKey(), String.valueOf(event.getValue())); } @Override diff --git a/src/main/java/com/launchdarkly/sdk/server/NewRelicReflector.java b/src/main/java/com/launchdarkly/sdk/server/NewRelicReflector.java deleted file mode 100644 index 62d660f14..000000000 --- a/src/main/java/com/launchdarkly/sdk/server/NewRelicReflector.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.launchdarkly.sdk.server; - -import com.google.common.base.Joiner; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.lang.reflect.Method; - -final class NewRelicReflector { - - private static Class newRelic = null; - - private static Method addCustomParameter = null; - - private static final Logger logger = LoggerFactory.getLogger(NewRelicReflector.class); - - static { - try { - newRelic = Class.forName(getNewRelicClassName()); - addCustomParameter = newRelic.getDeclaredMethod("addCustomParameter", String.class, String.class); - } catch (ClassNotFoundException | NoSuchMethodException e) { - logger.info("No NewRelic agent detected"); - } - } - - 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); - } catch (Exception e) { - logger.error("Unexpected error in LaunchDarkly NewRelic integration: {}", e.toString()); - logger.debug(e.toString(), e); - } - } - } -} From 9dbefe3811088bd34b912d4160346528755daa25 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 15 Apr 2020 16:42:35 -0700 Subject: [PATCH 381/673] add test for stream not restarting if store doesn't tell it to --- .../sdk/server/StreamProcessorTest.java | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java index 50d2ad165..41a058e7f 100644 --- a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java @@ -554,6 +554,36 @@ public void restartsStreamIfStoreNeedsRefresh() throws Exception { } } + @Test + public void doesNotRestartStreamIfStoreHadOutageButDoesNotNeedRefresh() throws Exception { + TestComponents.DataStoreWithStatusUpdates storeWithStatus = new TestComponents.DataStoreWithStatusUpdates(dataStore); + + SettableFuture restarted = SettableFuture.create(); + mockEventSource.start(); + expectLastCall(); + mockEventSource.restart(); + expectLastCall().andAnswer(() -> { + restarted.set(null); + return null; + }); + mockEventSource.close(); + expectLastCall(); + mockRequestor.close(); + expectLastCall(); + + replayAll(); + + try (StreamProcessor sp = createStreamProcessorWithStore(storeWithStatus)) { + sp.start(); + + storeWithStatus.broadcastStatusChange(new DataStoreStatusProvider.Status(false, false)); + storeWithStatus.broadcastStatusChange(new DataStoreStatusProvider.Status(true, false)); + + Thread.sleep(500); + assertFalse(restarted.isDone()); + } + } + @Test public void storeFailureOnPutCausesStreamRestart() throws Exception { DataStore badStore = dataStoreThatThrowsException(new RuntimeException("sorry")); From c3801438fc56337385222242571208ca60355112 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 15 Apr 2020 19:14:50 -0700 Subject: [PATCH 382/673] 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 89877df4c7f3bcca17da431807cc757e2c838d4a Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 16 Apr 2020 16:29:22 -0700 Subject: [PATCH 383/673] fix shading problem with javax classes --- build.gradle | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index 215bc88b8..1f8b090fb 100644 --- a/build.gradle +++ b/build.gradle @@ -253,14 +253,19 @@ def getPackagesInDependencyJar(jarFile) { // 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)} + configurations.shadow.collectMany { getPackagesInDependencyJar(it) } def topLevelPackages = configurations.internal.collectMany { getPackagesInDependencyJar(it).collect { it.contains(".") ? it.substring(0, it.indexOf(".")) : it } }. unique() topLevelPackages.forEach { top -> - jarTask.relocate(top, "com.launchdarkly.shaded." + top) { + // This special-casing for javax.annotation is hacky, but the issue is that Guava pulls in a jsr305 + // implementation jar that provides javax.annotation, and we *do* want to embed and shade those classes + // so that Guava won't fail to find them and they won't conflict with anyone else's version - but we do + // *not* want references to any classes from javax.net, javax.security, etc. to be munged. + def packageToRelocate = (top == "javax") ? "javax.annotation" : top + jarTask.relocate(packageToRelocate, "com.launchdarkly.shaded." + packageToRelocate) { excludePackages.forEach { exclude(it + ".*") } } } From e239121f08ecc18f7526c0e09c0c9511aa38e335 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 16 Apr 2020 16:41:42 -0700 Subject: [PATCH 384/673] indents --- build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index 1f8b090fb..0feef6ee0 100644 --- a/build.gradle +++ b/build.gradle @@ -263,8 +263,8 @@ def shadeDependencies(jarTask) { // This special-casing for javax.annotation is hacky, but the issue is that Guava pulls in a jsr305 // implementation jar that provides javax.annotation, and we *do* want to embed and shade those classes // so that Guava won't fail to find them and they won't conflict with anyone else's version - but we do - // *not* want references to any classes from javax.net, javax.security, etc. to be munged. - def packageToRelocate = (top == "javax") ? "javax.annotation" : top + // *not* want references to any classes from javax.net, javax.security, etc. to be munged. + def packageToRelocate = (top == "javax") ? "javax.annotation" : top jarTask.relocate(packageToRelocate, "com.launchdarkly.shaded." + packageToRelocate) { excludePackages.forEach { exclude(it + ".*") } } From de95836bc5c76490d5a4184e30f6e0d8be4c2ca0 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 16 Apr 2020 17:55:25 -0700 Subject: [PATCH 385/673] remove deprecated HTTP config properties --- .../launchdarkly/sdk/server/Components.java | 4 - .../com/launchdarkly/sdk/server/LDConfig.java | 164 +----------------- .../HttpConfigurationBuilder.java | 3 - .../sdk/server/DiagnosticEventTest.java | 22 --- .../launchdarkly/sdk/server/LDConfigTest.java | 111 ------------ 5 files changed, 3 insertions(+), 301 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/Components.java b/src/main/java/com/launchdarkly/sdk/server/Components.java index b3960203c..13552bb8f 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Components.java +++ b/src/main/java/com/launchdarkly/sdk/server/Components.java @@ -233,10 +233,6 @@ public static DataSourceFactory externalUpdatesOnly() { * ) * .build(); * - *

    - * These properties will override any equivalent deprecated properties that were set with {@code LDConfig.Builder}, - * such as {@link LDConfig.Builder#connectTimeout(java.time.Duration)}. 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 diff --git a/src/main/java/com/launchdarkly/sdk/server/LDConfig.java b/src/main/java/com/launchdarkly/sdk/server/LDConfig.java index 646c6718e..235ce9196 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDConfig.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDConfig.java @@ -1,6 +1,5 @@ package com.launchdarkly.sdk.server; -import com.launchdarkly.sdk.server.integrations.HttpConfigurationBuilder; import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; import com.launchdarkly.sdk.server.interfaces.EventProcessor; @@ -11,9 +10,6 @@ import java.net.URI; import java.time.Duration; -import javax.net.ssl.SSLSocketFactory; -import javax.net.ssl.X509TrustManager; - /** * This class exposes advanced configuration options for the {@link LDClient}. Instances of this class must be constructed with a {@link com.launchdarkly.sdk.server.LDConfig.Builder}. */ @@ -22,8 +18,6 @@ public final class LDConfig { 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 Duration DEFAULT_CONNECT_TIMEOUT = Duration.ofSeconds(2); - private static final Duration DEFAULT_SOCKET_TIMEOUT = Duration.ofSeconds(10); private static final Duration DEFAULT_START_WAIT = Duration.ofSeconds(5); protected static final LDConfig DEFAULT = new Builder().build(); @@ -41,22 +35,11 @@ protected LDConfig(Builder builder) { this.eventProcessorFactory = builder.eventProcessorFactory; this.dataSourceFactory = builder.dataSourceFactory; this.diagnosticOptOut = builder.diagnosticOptOut; + this.httpConfig = builder.httpConfigFactory == null ? + Components.httpConfiguration().createHttpConfiguration() : + builder.httpConfigFactory.createHttpConfiguration(); this.offline = builder.offline; this.startWait = builder.startWait; - - if (builder.httpConfigFactory != null) { - this.httpConfig = builder.httpConfigFactory.createHttpConfiguration(); - } else { - this.httpConfig = Components.httpConfiguration() - .connectTimeout(builder.connectTimeout) - .proxyHostAndPort(builder.proxyPort == -1 ? null : builder.proxyHost, builder.proxyPort) - .proxyAuth(builder.proxyUsername == null || builder.proxyPassword == null ? null : - Components.httpBasicAuthentication(builder.proxyUsername, builder.proxyPassword)) - .socketTimeout(builder.socketTimeout) - .sslSocketFactory(builder.sslSocketFactory, builder.trustManager) - .wrapper(builder.wrapperName, builder.wrapperVersion) - .createHttpConfiguration(); - } } LDConfig(LDConfig config) { @@ -81,23 +64,13 @@ protected LDConfig(Builder builder) { * */ public static class Builder { - private Duration connectTimeout = DEFAULT_CONNECT_TIMEOUT; private DataSourceFactory dataSourceFactory = null; private DataStoreFactory dataStoreFactory = null; private boolean diagnosticOptOut = false; private EventProcessorFactory eventProcessorFactory = null; private HttpConfigurationFactory httpConfigFactory = null; private boolean offline = false; - private String proxyHost = "localhost"; - private int proxyPort = -1; - private String proxyUsername = null; - private String proxyPassword = null; - private Duration socketTimeout = DEFAULT_SOCKET_TIMEOUT; private Duration startWait = DEFAULT_START_WAIT; - private SSLSocketFactory sslSocketFactory = null; - private X509TrustManager trustManager = null; - private String wrapperName = null; - private String wrapperVersion = null; /** * Creates a builder with all configuration parameters set to the default @@ -105,19 +78,6 @@ public static class Builder { public Builder() { } - /** - * Deprecated method for setting the connection timeout. - * - * @param connectTimeout the connection timeout; null to use the default - * @return the builder - * @deprecated Use {@link Components#httpConfiguration()} with {@link HttpConfigurationBuilder#connectTimeout(Duration)}. - */ - @Deprecated - public Builder connectTimeout(Duration connectTimeout) { - this.connectTimeout = connectTimeout == null ? DEFAULT_CONNECT_TIMEOUT : connectTimeout; - return this; - } - /** * Sets the implementation of the component that receives feature flag data from LaunchDarkly, * using a factory object. Depending on the implementation, the factory may be a builder that @@ -221,96 +181,6 @@ public Builder offline(boolean offline) { return this; } - /** - * 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. - *

    - * - * @param host the proxy hostname - * @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; - } - - /** - * 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)}. - */ - @Deprecated - public Builder proxyPort(int port) { - this.proxyPort = port; - return this; - } - - /** - * 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.sdk.server.interfaces.HttpAuthentication)} - * and {@link Components#httpBasicAuthentication(String, String)}. - */ - @Deprecated - public Builder proxyUsername(String username) { - this.proxyUsername = username; - return this; - } - - /** - * 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.sdk.server.interfaces.HttpAuthentication)} - * and {@link Components#httpBasicAuthentication(String, String)}. - */ - @Deprecated - public Builder proxyPassword(String password) { - this.proxyPassword = password; - return this; - } - - /** - * Deprecated method for setting the socket timeout. - * - * @param socketTimeout the socket timeout; null to use the default - * @return the builder - * @deprecated Use {@link Components#httpConfiguration()} with {@link HttpConfigurationBuilder#connectTimeout(Duration)}. - */ - @Deprecated - public Builder socketTimeout(Duration socketTimeout) { - this.socketTimeout = socketTimeout == null ? DEFAULT_SOCKET_TIMEOUT : socketTimeout; - return this; - } - - /** - * 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)}. - */ - @Deprecated - public Builder sslSocketFactory(SSLSocketFactory sslSocketFactory, X509TrustManager trustManager) { - this.sslSocketFactory = sslSocketFactory; - this.trustManager = trustManager; - return this; - } - /** * Set how long the constructor will block awaiting a successful connection to LaunchDarkly. * Setting this to a zero or negative duration will not block and cause the constructor to return immediately. @@ -324,34 +194,6 @@ public Builder startWait(Duration startWait) { return this; } - /** - * 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)}. - */ - @Deprecated - public Builder wrapperName(String wrapperName) { - this.wrapperName = wrapperName; - return this; - } - - /** - * Deprecated method of specifing a wrapper library identifier. - * - * @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)}. - */ - @Deprecated - public Builder wrapperVersion(String wrapperVersion) { - this.wrapperVersion = wrapperVersion; - return this; - } - /** * Builds the configured {@link com.launchdarkly.sdk.server.LDConfig} object. * diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/HttpConfigurationBuilder.java b/src/main/java/com/launchdarkly/sdk/server/integrations/HttpConfigurationBuilder.java index edf6d996c..7fa33d889 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/HttpConfigurationBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/HttpConfigurationBuilder.java @@ -25,9 +25,6 @@ * .build(); * *

    - * These properties will override any equivalent deprecated properties that were set with {@code LDConfig.Builder}, - * such as {@link com.launchdarkly.sdk.server.LDConfig.Builder#connectTimeout(java.time.Duration)}. - *

    * Note that this class is abstract; the actual implementation is created by calling {@link Components#httpConfiguration()}. * * @since 4.13.0 diff --git a/src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java b/src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java index 46b9e31d4..4da6cc4e5 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java @@ -227,28 +227,6 @@ public void testCustomDiagnosticConfigurationHttpProperties() { assertEquals(expected, diagnosticJson); } - @SuppressWarnings("deprecation") - @Test - public void testCustomDiagnosticConfigurationDeprecatedHttpProperties() { - LDConfig ldConfig = new LDConfig.Builder() - .connectTimeout(Duration.ofSeconds(5)) - .socketTimeout(Duration.ofSeconds(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); - } - private static class DataStoreFactoryWithComponentName implements DataStoreFactory, DiagnosticDescription { @Override public LDValue describeConfiguration(LDConfig config) { diff --git a/src/test/java/com/launchdarkly/sdk/server/LDConfigTest.java b/src/test/java/com/launchdarkly/sdk/server/LDConfigTest.java index 96fd80e9c..b0649573c 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDConfigTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDConfigTest.java @@ -1,22 +1,12 @@ package com.launchdarkly.sdk.server; -import com.launchdarkly.sdk.server.integrations.HttpConfigurationBuilderTest; import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; import org.junit.Test; -import java.net.InetSocketAddress; -import java.net.Proxy; -import java.time.Duration; - -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") @@ -74,105 +64,4 @@ public void testHttpDefaults() { assertNull(hc.getTrustManager()); assertNull(hc.getWrapperIdentifier()); } - - @SuppressWarnings("deprecation") - @Test - public void testDeprecatedHttpConnectTimeout() { - LDConfig config = new LDConfig.Builder().connectTimeout(Duration.ofMillis(999)).build(); - assertEquals(999, config.httpConfig.getConnectTimeout().toMillis()); - } - - @SuppressWarnings("deprecation") - @Test - public void testDeprecatedHttpSocketTimeout() { - LDConfig config = new LDConfig.Builder().socketTimeout(Duration.ofMillis(999)).build(); - assertEquals(999, config.httpConfig.getSocketTimeout().toMillis()); - } - - @SuppressWarnings("deprecation") - @Test - public void testDeprecatedHttpOnlyProxyHostConfiguredIsNull() { - LDConfig config = new LDConfig.Builder().proxyHost("bla").build(); - assertNull(config.httpConfig.getProxy()); - } - - @SuppressWarnings("deprecation") - @Test - public void testDeprecatedHttpOnlyProxyPortConfiguredHasPortAndDefaultHost() { - LDConfig config = new LDConfig.Builder().proxyPort(1234).build(); - assertEquals(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("localhost", 1234)), config.httpConfig.getProxy()); - } - - @SuppressWarnings("deprecation") - @Test - 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.getProxy()); - } - - @SuppressWarnings("deprecation") - @Test - public void testDeprecatedHttpProxyAuth() { - LDConfig config = new LDConfig.Builder() - .proxyHost("localhost2") - .proxyPort(4444) - .proxyUsername("user") - .proxyPassword("pass") - .build(); - assertNotNull(config.httpConfig.getProxy()); - assertNotNull(config.httpConfig.getProxyAuthentication()); - assertEquals("Basic dXNlcjpwYXNz", config.httpConfig.getProxyAuthentication().provideAuthorization(null)); - } - - @SuppressWarnings("deprecation") - @Test - public void testDeprecatedHttpProxyAuthPartialConfig() { - LDConfig config = new LDConfig.Builder() - .proxyHost("localhost2") - .proxyPort(4444) - .proxyUsername("proxyUser") - .build(); - assertNotNull(config.httpConfig.getProxy()); - assertNull(config.httpConfig.getProxyAuthentication()); - - config = new LDConfig.Builder() - .proxyHost("localhost2") - .proxyPort(4444) - .proxyPassword("proxyPassword") - .build(); - assertNotNull(config.httpConfig.getProxy()); - assertNull(config.httpConfig.getProxyAuthentication()); - } - - @SuppressWarnings("deprecation") - @Test - 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 testDeprecatedHttpWrapperNameOnly() { - LDConfig config = new LDConfig.Builder() - .wrapperName("Scala") - .build(); - assertEquals("Scala", config.httpConfig.getWrapperIdentifier()); - } - - @SuppressWarnings("deprecation") - @Test - public void testDeprecatedHttpWrapperWithVersion() { - LDConfig config = new LDConfig.Builder() - .wrapperName("Scala") - .wrapperVersion("0.1.0") - .build(); - assertEquals("Scala/0.1.0", config.httpConfig.getWrapperIdentifier()); - } } \ No newline at end of file From 4b1f6712e8415e128de6765db037c22d3b233cf7 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 16 Apr 2020 18:10:12 -0700 Subject: [PATCH 386/673] don't save entire configuration in ClientContext --- .../sdk/server/ClientContextImpl.java | 40 +++++++++++++++---- .../launchdarkly/sdk/server/Components.java | 20 +++++----- .../sdk/server/DefaultEventProcessor.java | 14 +++---- .../sdk/server/interfaces/ClientContext.java | 14 ++++--- 4 files changed, 59 insertions(+), 29 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java b/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java index 83f010ef8..eae023c78 100644 --- a/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java @@ -1,16 +1,26 @@ package com.launchdarkly.sdk.server; import com.launchdarkly.sdk.server.interfaces.ClientContext; +import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; final class ClientContextImpl implements ClientContext { private final String sdkKey; - private final LDConfig configuration; + private final HttpConfiguration httpConfiguration; + private final boolean offline; private final DiagnosticAccumulator diagnosticAccumulator; + private final DiagnosticEvent.Init diagnosticInitEvent; ClientContextImpl(String sdkKey, LDConfig configuration, DiagnosticAccumulator diagnosticAccumulator) { this.sdkKey = sdkKey; - this.configuration = configuration; - this.diagnosticAccumulator = diagnosticAccumulator; + this.httpConfiguration = configuration.httpConfig; + this.offline = configuration.offline; + if (!configuration.diagnosticOptOut && diagnosticAccumulator != null) { + this.diagnosticAccumulator = diagnosticAccumulator; + this.diagnosticInitEvent = new DiagnosticEvent.Init(diagnosticAccumulator.dataSinceDate, diagnosticAccumulator.diagnosticId, configuration); + } else { + this.diagnosticAccumulator = null; + this.diagnosticInitEvent = null; + } } @Override @@ -19,20 +29,36 @@ public String getSdkKey() { } @Override - public LDConfig getConfiguration() { - return configuration; + public boolean isOffline() { + return offline; + } + + @Override + public HttpConfiguration getHttpConfiguration() { + return httpConfiguration; } - // Note that this property is package-private - it is only used by SDK internal components, not any - // custom components implemented by an application. + // Note that the following two properties are package-private - they are only used by SDK internal components, + // not any custom components implemented by an application. DiagnosticAccumulator getDiagnosticAccumulator() { return diagnosticAccumulator; } + DiagnosticEvent.Init getDiagnosticInitEvent() { + return diagnosticInitEvent; + } + static DiagnosticAccumulator getDiagnosticAccumulator(ClientContext context) { if (context instanceof ClientContextImpl) { return ((ClientContextImpl)context).getDiagnosticAccumulator(); } return null; } + + static DiagnosticEvent.Init getDiagnosticInitEvent(ClientContext context) { + if (context instanceof ClientContextImpl) { + return ((ClientContextImpl)context).getDiagnosticInitEvent(); + } + return null; + } } diff --git a/src/main/java/com/launchdarkly/sdk/server/Components.java b/src/main/java/com/launchdarkly/sdk/server/Components.java index 13552bb8f..3c9036648 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Components.java +++ b/src/main/java/com/launchdarkly/sdk/server/Components.java @@ -330,7 +330,7 @@ private static final class NullDataSourceFactory implements DataSourceFactory, D @Override public DataSource createDataSource(ClientContext context, DataStoreUpdates dataStoreUpdates) { - if (context.getConfiguration().offline) { + if (context.isOffline()) { // 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"); @@ -377,7 +377,7 @@ private static final class StreamingDataSourceBuilderImpl extends StreamingDataS public DataSource createDataSource(ClientContext context, DataStoreUpdates dataStoreUpdates) { // Note, we log startup messages under the LDClient class to keep logs more readable - if (context.getConfiguration().offline) { + if (context.isOffline()) { return Components.externalUpdatesOnly().createDataSource(context, dataStoreUpdates); } @@ -395,14 +395,14 @@ public DataSource createDataSource(ClientContext context, DataStoreUpdates dataS DefaultFeatureRequestor requestor = new DefaultFeatureRequestor( context.getSdkKey(), - context.getConfiguration().httpConfig, + context.getHttpConfiguration(), pollUri, false ); return new StreamProcessor( context.getSdkKey(), - context.getConfiguration().httpConfig, + context.getHttpConfiguration(), requestor, dataStoreUpdates, null, @@ -435,7 +435,7 @@ private static final class PollingDataSourceBuilderImpl extends PollingDataSourc public DataSource createDataSource(ClientContext context, DataStoreUpdates dataStoreUpdates) { // Note, we log startup messages under the LDClient class to keep logs more readable - if (context.getConfiguration().offline) { + if (context.isOffline()) { return Components.externalUpdatesOnly().createDataSource(context, dataStoreUpdates); } @@ -444,7 +444,7 @@ public DataSource createDataSource(ClientContext context, DataStoreUpdates dataS DefaultFeatureRequestor requestor = new DefaultFeatureRequestor( context.getSdkKey(), - context.getConfiguration().httpConfig, + context.getHttpConfiguration(), baseURI == null ? LDConfig.DEFAULT_BASE_URI : baseURI, true ); @@ -471,12 +471,11 @@ private static final class EventProcessorBuilderImpl extends EventProcessorBuild implements DiagnosticDescription { @Override public EventProcessor createEventProcessor(ClientContext context) { - if (context.getConfiguration().offline) { + if (context.isOffline()) { return new NullEventProcessor(); } return new DefaultEventProcessor( context.getSdkKey(), - context.getConfiguration(), new EventsConfiguration( allAttributesPrivate, capacity, @@ -489,8 +488,9 @@ public EventProcessor createEventProcessor(ClientContext context) { userKeysFlushInterval, diagnosticRecordingInterval ), - context.getConfiguration().httpConfig, - ClientContextImpl.getDiagnosticAccumulator(context) + context.getHttpConfiguration(), + ClientContextImpl.getDiagnosticAccumulator(context), + ClientContextImpl.getDiagnosticInitEvent(context) ); } diff --git a/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java index 2c262cd1b..572ac16cb 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java @@ -57,8 +57,8 @@ 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(String sdkKey, EventsConfiguration eventsConfig, HttpConfiguration httpConfig, + DiagnosticAccumulator diagnosticAccumulator, DiagnosticEvent.Init diagnosticInitEvent) { inbox = new ArrayBlockingQueue<>(eventsConfig.capacity); ThreadFactory threadFactory = new ThreadFactoryBuilder() @@ -68,7 +68,7 @@ final class DefaultEventProcessor implements EventProcessor { .build(); scheduler = Executors.newSingleThreadScheduledExecutor(threadFactory); - dispatcher = new EventDispatcher(sdkKey, config, eventsConfig, httpConfig, inbox, threadFactory, closed, diagnosticAccumulator); + dispatcher = new EventDispatcher(sdkKey, eventsConfig, httpConfig, inbox, threadFactory, closed, diagnosticAccumulator, diagnosticInitEvent); Runnable flusher = () -> { postMessageAsync(MessageType.FLUSH, null); @@ -80,7 +80,7 @@ final class DefaultEventProcessor implements EventProcessor { }; this.scheduler.scheduleAtFixedRate(userKeysFlusher, eventsConfig.userKeysFlushInterval.toMillis(), eventsConfig.userKeysFlushInterval.toMillis(), TimeUnit.MILLISECONDS); - if (!config.diagnosticOptOut && diagnosticAccumulator != null) { + if (diagnosticAccumulator != null) { Runnable diagnosticsTrigger = () -> { postMessageAsync(MessageType.DIAGNOSTIC, null); }; @@ -216,11 +216,12 @@ static final class EventDispatcher { private long deduplicatedUsers = 0; - private EventDispatcher(String sdkKey, LDConfig config, EventsConfiguration eventsConfig, HttpConfiguration httpConfig, + private EventDispatcher(String sdkKey, EventsConfiguration eventsConfig, HttpConfiguration httpConfig, final BlockingQueue inbox, ThreadFactory threadFactory, final AtomicBoolean closed, - DiagnosticAccumulator diagnosticAccumulator) { + DiagnosticAccumulator diagnosticAccumulator, + DiagnosticEvent.Init diagnosticInitEvent) { this.eventsConfig = eventsConfig; this.diagnosticAccumulator = diagnosticAccumulator; this.busyFlushWorkersCount = new AtomicInteger(0); @@ -274,7 +275,6 @@ public void uncaughtException(Thread t, Throwable e) { // Set up diagnostics 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)); } else { diagnosticExecutor = null; diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/ClientContext.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/ClientContext.java index fc9c922eb..6c43e21c1 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/ClientContext.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/ClientContext.java @@ -1,7 +1,5 @@ package com.launchdarkly.sdk.server.interfaces; -import com.launchdarkly.sdk.server.LDConfig; - /** * Context information provided by the {@link com.launchdarkly.sdk.server.LDClient} when creating components. *

    @@ -20,8 +18,14 @@ public interface ClientContext { public String getSdkKey(); /** - * The client configuration. - * @return the configuration + * True if {@link com.launchdarkly.sdk.server.LDConfig.Builder#offline(boolean)} was set to true. + * @return the offline status + */ + public boolean isOffline(); + + /** + * The configured networking properties that apply to all components. + * @return the HTTP configuration */ - public LDConfig getConfiguration(); + public HttpConfiguration getHttpConfiguration(); } From e076f779c67eb19cc221d26ce41e8a8c43e57e25 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 16 Apr 2020 18:13:06 -0700 Subject: [PATCH 387/673] 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 388/673] 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 389/673] 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 532126b0a4ddff260a40ca0a050cc922326f2db2 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 21 Apr 2020 18:29:11 -0700 Subject: [PATCH 390/673] don't hold onto the LDConfig instance after creating the client --- .../com/launchdarkly/sdk/server/LDClient.java | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClient.java b/src/main/java/com/launchdarkly/sdk/server/LDClient.java index b9ad57d65..9e484ed4f 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClient.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClient.java @@ -52,7 +52,6 @@ public final class LDClient implements LDClientInterface { private static final String HMAC_ALGORITHM = "HmacSHA256"; static final String CLIENT_VERSION = getClientVersion(); - private final LDConfig config; private final String sdkKey; private final Evaluator evaluator; private final FlagChangeEventPublisher flagChangeEventPublisher; @@ -60,6 +59,7 @@ public final class LDClient implements LDClientInterface { final DataSource dataSource; final DataStore dataStore; private final DataStoreStatusProvider dataStoreStatusProvider; + private final boolean offline; /** * Creates a new client instance that connects to LaunchDarkly with the default configuration. In most @@ -89,11 +89,12 @@ private static final DataModel.Segment getSegment(DataStore store, String key) { * @param config a client configuration object */ public LDClient(String sdkKey, LDConfig config) { - this.config = new LDConfig(checkNotNull(config, "config must not be null")); + checkNotNull(config, "config must not be null"); this.sdkKey = checkNotNull(sdkKey, "sdkKey must not be null"); + this.offline = config.offline; - final EventProcessorFactory epFactory = this.config.eventProcessorFactory == null ? - Components.sendEvents() : this.config.eventProcessorFactory; + final EventProcessorFactory epFactory = config.eventProcessorFactory == null ? + Components.sendEvents() : config.eventProcessorFactory; if (config.httpConfig.getProxy() != null) { if (config.httpConfig.getProxyAuthentication() != null) { @@ -105,7 +106,7 @@ public LDClient(String sdkKey, LDConfig config) { // Do not create diagnostic accumulator if config has specified is opted out, or if we're not using the // standard event processor - final boolean useDiagnostics = !this.config.diagnosticOptOut && epFactory instanceof EventProcessorBuilder; + final boolean useDiagnostics = !config.diagnosticOptOut && epFactory instanceof EventProcessorBuilder; final ClientContextImpl context = new ClientContextImpl(sdkKey, config, useDiagnostics ? new DiagnosticAccumulator(new DiagnosticId(sdkKey)) : null); @@ -128,18 +129,18 @@ public DataModel.Segment getSegment(String key) { this.flagChangeEventPublisher = new FlagChangeEventPublisher(); - DataSourceFactory dataSourceFactory = this.config.dataSourceFactory == null ? - Components.streamingDataSource() : this.config.dataSourceFactory; + DataSourceFactory dataSourceFactory = config.dataSourceFactory == null ? + Components.streamingDataSource() : config.dataSourceFactory; DataStoreUpdates dataStoreUpdates = new DataStoreUpdatesImpl(dataStore, flagChangeEventPublisher); this.dataSource = dataSourceFactory.createDataSource(context, dataStoreUpdates); Future startFuture = dataSource.start(); - if (!this.config.startWait.isZero() && !this.config.startWait.isNegative()) { + if (!config.startWait.isZero() && !config.startWait.isNegative()) { if (!(dataSource instanceof Components.NullDataSource)) { - logger.info("Waiting up to " + this.config.startWait.toMillis() + " milliseconds for LaunchDarkly client to start..."); + logger.info("Waiting up to " + config.startWait.toMillis() + " milliseconds for LaunchDarkly client to start..."); } try { - startFuture.get(this.config.startWait.toMillis(), TimeUnit.MILLISECONDS); + startFuture.get(config.startWait.toMillis(), TimeUnit.MILLISECONDS); } catch (TimeoutException e) { logger.error("Timeout encountered waiting for LaunchDarkly client initialization"); } catch (Exception e) { @@ -421,7 +422,7 @@ public void flush() { @Override public boolean isOffline() { - return config.offline; + return offline; } @Override From 1e30caee9b9c4bec98a1565544d3cb55503714f9 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 21 Apr 2020 18:51:21 -0700 Subject: [PATCH 391/673] 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 392/673] 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 c81deac2f83ed82d04cfc51b3a196d99c91bae29 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 22 Apr 2020 18:57:08 -0700 Subject: [PATCH 393/673] 5.0: fix NPE from allFlagsState when there's a deleted flag --- .../com/launchdarkly/sdk/server/LDClient.java | 3 +++ .../sdk/server/LDClientEvaluationTest.java | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClient.java b/src/main/java/com/launchdarkly/sdk/server/LDClient.java index b9ad57d65..9babe503c 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClient.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClient.java @@ -218,6 +218,9 @@ public FeatureFlagsState allFlagsState(LDUser user, FlagsStateOption... options) boolean clientSideOnly = FlagsStateOption.hasOption(options, FlagsStateOption.CLIENT_SIDE_ONLY); KeyedItems flags = dataStore.getAll(FEATURES); for (Map.Entry entry : flags.getItems()) { + if (entry.getValue().getItem() == null) { + continue; // deleted flag placeholder + } DataModel.FeatureFlag flag = (DataModel.FeatureFlag)entry.getValue().getItem(); if (clientSideOnly && !flag.isClientSide()) { continue; diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java index 09cb86924..d178bd93e 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java @@ -8,12 +8,15 @@ import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import org.junit.Test; import java.time.Duration; import java.util.Map; +import static com.google.common.collect.Iterables.getFirst; +import static com.launchdarkly.sdk.server.DataModel.FEATURES; import static com.launchdarkly.sdk.server.ModelBuilders.booleanFlagWithClauses; import static com.launchdarkly.sdk.server.ModelBuilders.clause; import static com.launchdarkly.sdk.server.ModelBuilders.fallthroughVariation; @@ -420,6 +423,22 @@ public void allFlagsStateCanOmitDetailsForUntrackedFlags() { assertEquals(expected, gson.toJsonTree(state)); } + @Test + public void allFlagsStateFiltersOutDeletedFlags() throws Exception { + DataModel.FeatureFlag flag1 = flagBuilder("key1").version(1).build(); + DataModel.FeatureFlag flag2 = flagBuilder("key2").version(1).build(); + upsertFlag(dataStore, flag1); + upsertFlag(dataStore, flag2); + dataStore.upsert(FEATURES, flag2.getKey(), ItemDescriptor.deletedItem(flag2.getVersion() + 1)); + + FeatureFlagsState state = client.allFlagsState(user); + assertTrue(state.isValid()); + + Map valuesMap = state.toValuesMap(); + assertEquals(1, valuesMap.size()); + assertEquals(flag1.getKey(), getFirst(valuesMap.keySet(), null)); + } + @Test public void allFlagsStateReturnsEmptyStateForNullUser() throws Exception { upsertFlag(dataStore, flagWithValue("key", LDValue.of("value"))); From f1f5d431073a47de9a19c5d8f35e7d1ff153516c Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 24 Apr 2020 18:55:56 -0700 Subject: [PATCH 394/673] use non-nullable integers in EvaluationDetail and events --- .../sdk/server/DefaultEventProcessor.java | 4 +- .../launchdarkly/sdk/server/Evaluator.java | 16 +++--- .../launchdarkly/sdk/server/EventFactory.java | 54 ++++++++++++++----- .../sdk/server/EventOutputFormatter.java | 8 +-- .../sdk/server/EventSummarizer.java | 14 ++--- .../com/launchdarkly/sdk/server/LDClient.java | 7 +-- .../sdk/server/interfaces/Event.java | 28 +++++----- .../sdk/server/EvaluatorTest.java | 13 ++--- .../sdk/server/EventOutputTest.java | 11 ++-- .../sdk/server/EventSummarizerTest.java | 2 +- .../sdk/server/LDClientEvaluationTest.java | 13 ++--- .../sdk/server/LDClientEventTest.java | 9 ++-- 12 files changed, 108 insertions(+), 71 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java index 25fd7030e..a814f81ea 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java @@ -422,8 +422,8 @@ private boolean noticeUser(LDUser user, SimpleLRUCache userKeys) } private boolean shouldDebugEvent(Event.FeatureRequest fe) { - Long debugEventsUntilDate = fe.getDebugEventsUntilDate(); - if (debugEventsUntilDate != null) { + long debugEventsUntilDate = fe.getDebugEventsUntilDate(); + if (debugEventsUntilDate > 0) { // The "last known past time" comes from the last HTTP response we got from the server. // In case the client's time is set wrong, at least we know that any expiration date // earlier than that point is definitely in the past. If there's any discrepancy, we diff --git a/src/main/java/com/launchdarkly/sdk/server/Evaluator.java b/src/main/java/com/launchdarkly/sdk/server/Evaluator.java index 90fd7da40..4e3136dd4 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Evaluator.java +++ b/src/main/java/com/launchdarkly/sdk/server/Evaluator.java @@ -14,6 +14,8 @@ import java.util.ArrayList; import java.util.List; +import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; + /** * Encapsulates the feature flag evaluation logic. The Evaluator has no knowledge of the rest of the SDK environment; * if it needs to retrieve flags or segments that are referenced by a flag, it does so through a read-only interface @@ -49,18 +51,18 @@ static interface Getters { */ static class EvalResult { private LDValue value = LDValue.ofNull(); - private Integer variationIndex = null; + private int variationIndex = NO_VARIATION; private EvaluationReason reason = null; private List prerequisiteEvents; - public EvalResult(LDValue value, Integer variationIndex, EvaluationReason reason) { + public EvalResult(LDValue value, int variationIndex, EvaluationReason reason) { this.value = value; this.variationIndex = variationIndex; this.reason = reason; } public static EvalResult error(EvaluationReason.ErrorKind errorKind) { - return new EvalResult(LDValue.ofNull(), null, EvaluationReason.error(errorKind)); + return new EvalResult(LDValue.ofNull(), NO_VARIATION, EvaluationReason.error(errorKind)); } LDValue getValue() { @@ -71,12 +73,12 @@ void setValue(LDValue value) { this.value = value; } - Integer getVariationIndex() { + int getVariationIndex() { return variationIndex; } boolean isDefault() { - return variationIndex == null; + return variationIndex < 0; } EvaluationReason getReason() { @@ -112,7 +114,7 @@ EvalResult evaluate(DataModel.FeatureFlag flag, LDUser user, EventFactory eventF 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", flag.getKey()); - return new EvalResult(null, null, EvaluationReason.error(EvaluationReason.ErrorKind.USER_NOT_SPECIFIED)); + return new EvalResult(null, NO_VARIATION, EvaluationReason.error(EvaluationReason.ErrorKind.USER_NOT_SPECIFIED)); } // If the flag doesn't have any prerequisites (which most flags don't) then it cannot generate any feature @@ -199,7 +201,7 @@ private EvalResult getVariation(DataModel.FeatureFlag flag, int variation, Evalu private EvalResult getOffValue(DataModel.FeatureFlag flag, EvaluationReason reason) { Integer offVariation = flag.getOffVariation(); if (offVariation == null) { // off variation unspecified - return default value - return new EvalResult(null, null, reason); + return new EvalResult(null, NO_VARIATION, reason); } else { return getVariation(flag, offVariation, reason); } diff --git a/src/main/java/com/launchdarkly/sdk/server/EventFactory.java b/src/main/java/com/launchdarkly/sdk/server/EventFactory.java index 223f767ae..39a9e1346 100644 --- a/src/main/java/com/launchdarkly/sdk/server/EventFactory.java +++ b/src/main/java/com/launchdarkly/sdk/server/EventFactory.java @@ -13,7 +13,7 @@ abstract class EventFactory { protected abstract boolean isIncludeReasons(); public Event.FeatureRequest newFeatureRequestEvent(DataModel.FeatureFlag flag, LDUser user, LDValue value, - Integer variationIndex, EvaluationReason reason, LDValue defaultValue, String prereqOf) { + int variationIndex, EvaluationReason reason, LDValue defaultValue, String prereqOf) { boolean requireExperimentData = isExperiment(flag, reason); return new Event.FeatureRequest( getTimestamp(), @@ -26,39 +26,69 @@ public Event.FeatureRequest newFeatureRequestEvent(DataModel.FeatureFlag flag, L (requireExperimentData || isIncludeReasons()) ? reason : null, prereqOf, requireExperimentData || flag.isTrackEvents(), - flag.getDebugEventsUntilDate(), + flag.getDebugEventsUntilDate() == null ? 0 : flag.getDebugEventsUntilDate().longValue(), false ); } public Event.FeatureRequest newFeatureRequestEvent(DataModel.FeatureFlag flag, LDUser user, Evaluator.EvalResult result, LDValue defaultVal) { return newFeatureRequestEvent(flag, user, result == null ? null : result.getValue(), - result == null ? null : result.getVariationIndex(), result == null ? null : result.getReason(), + result == null ? -1 : result.getVariationIndex(), result == null ? null : result.getReason(), defaultVal, null); } public Event.FeatureRequest newDefaultFeatureRequestEvent(DataModel.FeatureFlag flag, LDUser user, LDValue defaultValue, EvaluationReason.ErrorKind errorKind) { - return new Event.FeatureRequest(getTimestamp(), flag.getKey(), user, flag.getVersion(), - null, defaultValue, defaultValue, isIncludeReasons() ? EvaluationReason.error(errorKind) : null, - null, flag.isTrackEvents(), flag.getDebugEventsUntilDate(), false); + return new Event.FeatureRequest( + getTimestamp(), + flag.getKey(), + user, + flag.getVersion(), + -1, + defaultValue, + defaultValue, + isIncludeReasons() ? EvaluationReason.error(errorKind) : null, + null, + flag.isTrackEvents(), + flag.getDebugEventsUntilDate() == null ? 0 : flag.getDebugEventsUntilDate().longValue(), + false + ); } public Event.FeatureRequest newUnknownFeatureRequestEvent(String key, LDUser user, LDValue defaultValue, EvaluationReason.ErrorKind errorKind) { - return new Event.FeatureRequest(getTimestamp(), key, user, null, null, defaultValue, defaultValue, - isIncludeReasons() ? EvaluationReason.error(errorKind) : null, null, false, null, false); + return new Event.FeatureRequest( + getTimestamp(), + key, + user, + -1, + -1, + defaultValue, + defaultValue, + isIncludeReasons() ? EvaluationReason.error(errorKind) : null, + null, + false, + 0, + false + ); } public Event.FeatureRequest newPrerequisiteFeatureRequestEvent(DataModel.FeatureFlag prereqFlag, LDUser user, Evaluator.EvalResult details, DataModel.FeatureFlag prereqOf) { - return newFeatureRequestEvent(prereqFlag, user, details == null ? null : details.getValue(), - details == null ? null : details.getVariationIndex(), details == null ? null : details.getReason(), - LDValue.ofNull(), prereqOf.getKey()); + return newFeatureRequestEvent( + prereqFlag, + user, + details == null ? null : details.getValue(), + details == null ? -1 : details.getVariationIndex(), + details == null ? null : details.getReason(), + LDValue.ofNull(), + prereqOf.getKey() + ); } public Event.FeatureRequest newDebugEvent(Event.FeatureRequest from) { - return new Event.FeatureRequest(from.getCreationDate(), from.getKey(), from.getUser(), from.getVersion(), from.getVariation(), from.getValue(), + return new Event.FeatureRequest( + from.getCreationDate(), from.getKey(), from.getUser(), from.getVersion(), from.getVariation(), from.getValue(), from.getDefaultVal(), from.getReason(), from.getPrereqOf(), from.isTrackEvents(), from.getDebugEventsUntilDate(), true); } diff --git a/src/main/java/com/launchdarkly/sdk/server/EventOutputFormatter.java b/src/main/java/com/launchdarkly/sdk/server/EventOutputFormatter.java index 5b2dd3943..5984cb2a0 100644 --- a/src/main/java/com/launchdarkly/sdk/server/EventOutputFormatter.java +++ b/src/main/java/com/launchdarkly/sdk/server/EventOutputFormatter.java @@ -49,11 +49,11 @@ private boolean writeOutputEvent(Event event, JsonWriter jw) throws IOException Event.FeatureRequest fe = (Event.FeatureRequest)event; startEvent(fe, fe.isDebug() ? "debug" : "feature", fe.getKey(), jw); writeUserOrKey(fe, fe.isDebug(), jw); - if (fe.getVersion() != null) { + if (fe.getVersion() >= 0) { jw.name("version"); jw.value(fe.getVersion()); } - if (fe.getVariation() != null) { + if (fe.getVariation() >= 0) { jw.name("variation"); jw.value(fe.getVariation()); } @@ -130,11 +130,11 @@ private void writeSummaryEvent(EventSummarizer.EventSummary summary, JsonWriter jw.beginObject(); - if (keyForThisFlag.variation != null) { + if (keyForThisFlag.variation >= 0) { jw.name("variation"); jw.value(keyForThisFlag.variation); } - if (keyForThisFlag.version != null) { + if (keyForThisFlag.version >= 0) { jw.name("version"); jw.value(keyForThisFlag.version); } else { diff --git a/src/main/java/com/launchdarkly/sdk/server/EventSummarizer.java b/src/main/java/com/launchdarkly/sdk/server/EventSummarizer.java index 1d90a2e65..eaa3bd583 100644 --- a/src/main/java/com/launchdarkly/sdk/server/EventSummarizer.java +++ b/src/main/java/com/launchdarkly/sdk/server/EventSummarizer.java @@ -65,7 +65,7 @@ boolean isEmpty() { return counters.isEmpty(); } - void incrementCounter(String flagKey, Integer variation, Integer version, LDValue flagValue, LDValue defaultVal) { + void incrementCounter(String flagKey, int variation, int version, LDValue flagValue, LDValue defaultVal) { CounterKey key = new CounterKey(flagKey, variation, version); CounterValue value = counters.get(key); @@ -102,10 +102,10 @@ public int hashCode() { static final class CounterKey { final String key; - final Integer variation; - final Integer version; + final int variation; + final int version; - CounterKey(String key, Integer variation, Integer version) { + CounterKey(String key, int variation, int version) { this.key = key; this.variation = variation; this.version = version; @@ -115,15 +115,15 @@ static final class CounterKey { public boolean equals(Object other) { if (other instanceof CounterKey) { CounterKey o = (CounterKey)other; - return o.key.equals(this.key) && Objects.equals(o.variation, this.variation) && - Objects.equals(o.version, this.version); + return o.key.equals(this.key) && o.variation == this.variation && + o.version == this.version; } return false; } @Override public int hashCode() { - return key.hashCode() + 31 * (Objects.hashCode(variation) + 31 * Objects.hashCode(version)); + return key.hashCode() + 31 * (variation + 31 * version); } @Override diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClient.java b/src/main/java/com/launchdarkly/sdk/server/LDClient.java index 2f81253de..dc1b53b18 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClient.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClient.java @@ -38,6 +38,7 @@ import javax.crypto.spec.SecretKeySpec; import static com.google.common.base.Preconditions.checkNotNull; +import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; import static com.launchdarkly.sdk.server.DataModel.FEATURES; import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; @@ -232,7 +233,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(flag, new Evaluator.EvalResult(LDValue.ofNull(), null, EvaluationReason.exception(e))); + builder.addFlag(flag, new Evaluator.EvalResult(LDValue.ofNull(), NO_VARIATION, EvaluationReason.exception(e))); } } return builder.build(); @@ -329,7 +330,7 @@ private LDValue evaluate(String featureKey, LDUser user, LDValue defaultValue, b } private Evaluator.EvalResult errorResult(EvaluationReason.ErrorKind errorKind, final LDValue defaultValue) { - return new Evaluator.EvalResult(defaultValue, null, EvaluationReason.error(errorKind)); + return new Evaluator.EvalResult(defaultValue, NO_VARIATION, EvaluationReason.error(errorKind)); } private Evaluator.EvalResult evaluateInternal(String featureKey, LDUser user, LDValue defaultValue, boolean checkType, @@ -390,7 +391,7 @@ private Evaluator.EvalResult evaluateInternal(String featureKey, LDUser user, LD sendFlagRequestEvent(eventFactory.newDefaultFeatureRequestEvent(featureFlag, user, defaultValue, EvaluationReason.ErrorKind.EXCEPTION)); } - return new Evaluator.EvalResult(defaultValue, null, EvaluationReason.exception(e)); + return new Evaluator.EvalResult(defaultValue, NO_VARIATION, EvaluationReason.exception(e)); } } diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/Event.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/Event.java index bad2815b8..0cba86ac8 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/Event.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/Event.java @@ -124,13 +124,13 @@ public Index(long timestamp, LDUser user) { */ public static final class FeatureRequest extends Event { private final String key; - private final Integer variation; + private final int variation; private final LDValue value; private final LDValue defaultVal; - private final Integer version; + private final int version; private final String prereqOf; private final boolean trackEvents; - private final Long debugEventsUntilDate; + private final long debugEventsUntilDate; private final EvaluationReason reason; private final boolean debug; @@ -139,8 +139,8 @@ public static final class FeatureRequest extends 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 version the flag version, or -1 if the flag was not found + * @param variation the result variation, or -1 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 @@ -150,8 +150,8 @@ public static final class FeatureRequest extends Event { * @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, - LDValue defaultVal, EvaluationReason reason, String prereqOf, boolean trackEvents, Long debugEventsUntilDate, boolean debug) { + public FeatureRequest(long timestamp, String key, LDUser user, int version, int variation, LDValue value, + LDValue defaultVal, EvaluationReason reason, String prereqOf, boolean trackEvents, long debugEventsUntilDate, boolean debug) { super(timestamp, user); this.key = key; this.version = version; @@ -174,10 +174,10 @@ public String getKey() { } /** - * The index of the selected flag variation, or null if the application default value was used. - * @return zero-based index of the variation, or null + * The index of the selected flag variation, or -1 if the application default value was used. + * @return zero-based index of the variation, or -1 */ - public Integer getVariation() { + public int getVariation() { return variation; } @@ -198,10 +198,10 @@ public LDValue getDefaultVal() { } /** - * The version of the feature flag that was evaluated, or null if the flag was not found. + * The version of the feature flag that was evaluated, or -1 if the flag was not found. * @return the flag version or null */ - public Integer getVersion() { + public int getVersion() { return version; } @@ -223,9 +223,9 @@ public boolean isTrackEvents() { /** * If debugging is enabled for this flag, the Unix millisecond time at which to stop debugging. - * @return a timestamp or null + * @return a timestamp or zero */ - public Long getDebugEventsUntilDate() { + public long getDebugEventsUntilDate() { return debugEventsUntilDate; } diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java index 22596c4b4..ffdf26ed8 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java @@ -10,6 +10,7 @@ import org.junit.Test; +import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; import static com.launchdarkly.sdk.EvaluationDetail.fromValue; import static com.launchdarkly.sdk.server.EvaluatorTestUtil.BASE_EVALUATOR; import static com.launchdarkly.sdk.server.EvaluatorTestUtil.evaluatorBuilder; @@ -52,7 +53,7 @@ public void flagReturnsNullIfFlagIsOffAndOffVariationIsUnspecified() throws Exce .build(); Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); - assertEquals(fromValue(LDValue.ofNull(), null, EvaluationReason.off()), result.getDetails()); + assertEquals(fromValue(LDValue.ofNull(), NO_VARIATION, EvaluationReason.off()), result.getDetails()); assertThat(result.getPrerequisiteEvents(), emptyIterable()); } @@ -199,7 +200,7 @@ public void flagReturnsOffVariationAndEventIfPrerequisiteIsOff() throws Exceptio Event.FeatureRequest event = Iterables.get(result.getPrerequisiteEvents(), 0); assertEquals(f1.getKey(), event.getKey()); assertEquals(LDValue.of("go"), event.getValue()); - assertEquals(f1.getVersion(), event.getVersion().intValue()); + assertEquals(f1.getVersion(), event.getVersion()); assertEquals(f0.getKey(), event.getPrereqOf()); } @@ -229,7 +230,7 @@ public void flagReturnsOffVariationAndEventIfPrerequisiteIsNotMet() throws Excep Event.FeatureRequest event = Iterables.get(result.getPrerequisiteEvents(), 0); assertEquals(f1.getKey(), event.getKey()); assertEquals(LDValue.of("nogo"), event.getValue()); - assertEquals(f1.getVersion(), event.getVersion().intValue()); + assertEquals(f1.getVersion(), event.getVersion()); assertEquals(f0.getKey(), event.getPrereqOf()); } @@ -276,7 +277,7 @@ public void flagReturnsFallthroughVariationAndEventIfPrerequisiteIsMetAndThereAr Event.FeatureRequest event = Iterables.get(result.getPrerequisiteEvents(), 0); assertEquals(f1.getKey(), event.getKey()); assertEquals(LDValue.of("go"), event.getValue()); - assertEquals(f1.getVersion(), event.getVersion().intValue()); + assertEquals(f1.getVersion(), event.getVersion()); assertEquals(f0.getKey(), event.getPrereqOf()); } @@ -312,13 +313,13 @@ public void multipleLevelsOfPrerequisitesProduceMultipleEvents() throws Exceptio Event.FeatureRequest event0 = Iterables.get(result.getPrerequisiteEvents(), 0); assertEquals(f2.getKey(), event0.getKey()); assertEquals(LDValue.of("go"), event0.getValue()); - assertEquals(f2.getVersion(), event0.getVersion().intValue()); + assertEquals(f2.getVersion(), event0.getVersion()); assertEquals(f1.getKey(), event0.getPrereqOf()); Event.FeatureRequest event1 = Iterables.get(result.getPrerequisiteEvents(), 1); assertEquals(f1.getKey(), event1.getKey()); assertEquals(LDValue.of("go"), event1.getValue()); - assertEquals(f1.getVersion(), event1.getVersion().intValue()); + assertEquals(f1.getVersion(), event1.getVersion()); assertEquals(f0.getKey(), event1.getPrereqOf()); } diff --git a/src/test/java/com/launchdarkly/sdk/server/EventOutputTest.java b/src/test/java/com/launchdarkly/sdk/server/EventOutputTest.java index 187b7429a..c254855e2 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EventOutputTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EventOutputTest.java @@ -17,6 +17,7 @@ import java.io.StringWriter; import java.util.Set; +import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; import static com.launchdarkly.sdk.server.TestComponents.defaultEventsConfig; import static com.launchdarkly.sdk.server.TestComponents.makeEventsConfig; @@ -80,7 +81,7 @@ public void userKeyIsSetInsteadOfUserWhenNotInlined() throws Exception { Event.FeatureRequest featureEvent = EventFactory.DEFAULT.newFeatureRequestEvent( flagBuilder("flag").build(), user, - new Evaluator.EvalResult(LDValue.ofNull(), null, EvaluationReason.off()), + new Evaluator.EvalResult(LDValue.ofNull(), NO_VARIATION, EvaluationReason.off()), LDValue.ofNull()); LDValue outputEvent = getSingleOutputEvent(f, featureEvent); assertEquals(LDValue.ofNull(), outputEvent.get("user")); @@ -189,7 +190,7 @@ public void featureEventIsSerialized() throws Exception { assertEquals(feJson1, getSingleOutputEvent(f, feWithVariation)); FeatureRequest feWithoutVariationOrDefault = factory.newFeatureRequestEvent(flag, user, - new Evaluator.EvalResult(LDValue.of("flagvalue"), null, EvaluationReason.off()), + new Evaluator.EvalResult(LDValue.of("flagvalue"), NO_VARIATION, EvaluationReason.off()), LDValue.ofNull()); LDValue feJson2 = parseValue("{" + "\"kind\":\"feature\"," + @@ -323,9 +324,9 @@ public void summaryEventIsSerialized() throws Exception { 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("second"), -1, 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.incrementCounter(new String("third"), -1, -1, LDValue.of("default3"), LDValue.of("default3")); // flag doesn't exist (no version) summary.noteTimestamp(1000); summary.noteTimestamp(1002); @@ -395,7 +396,7 @@ private void testInlineUserSerialization(LDUser user, LDValue expectedJsonValue, Event.FeatureRequest featureEvent = EventFactory.DEFAULT.newFeatureRequestEvent( flagBuilder("flag").build(), user, - new Evaluator.EvalResult(LDValue.ofNull(), null, EvaluationReason.off()), + new Evaluator.EvalResult(LDValue.ofNull(), NO_VARIATION, EvaluationReason.off()), LDValue.ofNull()); LDValue outputEvent = getSingleOutputEvent(f, featureEvent); assertEquals(LDValue.ofNull(), outputEvent.get("userKey")); diff --git a/src/test/java/com/launchdarkly/sdk/server/EventSummarizerTest.java b/src/test/java/com/launchdarkly/sdk/server/EventSummarizerTest.java index f294eb0b9..c8294de9d 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EventSummarizerTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EventSummarizerTest.java @@ -102,7 +102,7 @@ public void summarizeEventIncrementsCounters() { new EventSummarizer.CounterValue(1, LDValue.of("value2"), LDValue.of("default1"))); expected.put(new EventSummarizer.CounterKey(flag2.getKey(), 1, flag2.getVersion()), new EventSummarizer.CounterValue(1, LDValue.of("value99"), LDValue.of("default2"))); - expected.put(new EventSummarizer.CounterKey(unknownFlagKey, null, null), + expected.put(new EventSummarizer.CounterKey(unknownFlagKey, -1, -1), new EventSummarizer.CounterValue(1, LDValue.of("default3"), LDValue.of("default3"))); assertThat(data.counters, equalTo(expected)); } diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java index d178bd93e..ed745ad25 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java @@ -16,6 +16,7 @@ import java.util.Map; import static com.google.common.collect.Iterables.getFirst; +import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; import static com.launchdarkly.sdk.server.DataModel.FEATURES; import static com.launchdarkly.sdk.server.ModelBuilders.booleanFlagWithClauses; import static com.launchdarkly.sdk.server.ModelBuilders.clause; @@ -218,7 +219,7 @@ public void variationDetailReturnsDefaultIfFlagEvaluatesToNull() { upsertFlag(dataStore, flag); EvaluationDetail expected = EvaluationDetail.fromValue("default", - null, EvaluationReason.off()); + NO_VARIATION, EvaluationReason.off()); EvaluationDetail actual = client.stringVariationDetail("key", user, "default"); assertEquals(expected, actual); assertTrue(actual.isDefaultValue()); @@ -234,7 +235,7 @@ public void appropriateErrorIfClientNotInitialized() throws Exception { .startWait(Duration.ZERO) .build(); try (LDClientInterface badClient = new LDClient("SDK_KEY", badConfig)) { - EvaluationDetail expectedResult = EvaluationDetail.fromValue(false, null, + EvaluationDetail expectedResult = EvaluationDetail.fromValue(false, NO_VARIATION, EvaluationReason.error(EvaluationReason.ErrorKind.CLIENT_NOT_READY)); assertEquals(expectedResult, badClient.boolVariationDetail("key", user, false)); } @@ -242,7 +243,7 @@ public void appropriateErrorIfClientNotInitialized() throws Exception { @Test public void appropriateErrorIfFlagDoesNotExist() throws Exception { - EvaluationDetail expectedResult = EvaluationDetail.fromValue("default", null, + EvaluationDetail expectedResult = EvaluationDetail.fromValue("default", NO_VARIATION, EvaluationReason.error(EvaluationReason.ErrorKind.FLAG_NOT_FOUND)); assertEquals(expectedResult, client.stringVariationDetail("key", user, "default")); } @@ -251,7 +252,7 @@ public void appropriateErrorIfFlagDoesNotExist() throws Exception { public void appropriateErrorIfUserNotSpecified() throws Exception { upsertFlag(dataStore, flagWithValue("key", LDValue.of(true))); - EvaluationDetail expectedResult = EvaluationDetail.fromValue("default", null, + EvaluationDetail expectedResult = EvaluationDetail.fromValue("default", NO_VARIATION, EvaluationReason.error(EvaluationReason.ErrorKind.USER_NOT_SPECIFIED)); assertEquals(expectedResult, client.stringVariationDetail("key", null, "default")); } @@ -260,7 +261,7 @@ public void appropriateErrorIfUserNotSpecified() throws Exception { public void appropriateErrorIfValueWrongType() throws Exception { upsertFlag(dataStore, flagWithValue("key", LDValue.of(true))); - EvaluationDetail expectedResult = EvaluationDetail.fromValue(3, null, + EvaluationDetail expectedResult = EvaluationDetail.fromValue(3, NO_VARIATION, EvaluationReason.error(EvaluationReason.ErrorKind.WRONG_TYPE)); assertEquals(expectedResult, client.intVariationDetail("key", user, 3)); } @@ -275,7 +276,7 @@ public void appropriateErrorForUnexpectedException() throws Exception { .dataSource(Components.externalUpdatesOnly()) .build(); try (LDClientInterface badClient = new LDClient("SDK_KEY", badConfig)) { - EvaluationDetail expectedResult = EvaluationDetail.fromValue(false, null, + EvaluationDetail expectedResult = EvaluationDetail.fromValue(false, NO_VARIATION, EvaluationReason.exception(exception)); assertEquals(expectedResult, badClient.boolVariationDetail("key", user, false)); } diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientEventTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientEventTest.java index c20eb2bb3..c0cd0c61b 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientEventTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientEventTest.java @@ -479,13 +479,13 @@ private void checkFeatureEvent(Event e, DataModel.FeatureFlag flag, LDValue valu Event.FeatureRequest fe = (Event.FeatureRequest)e; assertEquals(flag.getKey(), fe.getKey()); assertEquals(user.getKey(), fe.getUser().getKey()); - assertEquals(new Integer(flag.getVersion()), fe.getVersion()); + assertEquals(flag.getVersion(), fe.getVersion()); assertEquals(value, fe.getValue()); assertEquals(defaultVal, fe.getDefaultVal()); assertEquals(prereqOf, fe.getPrereqOf()); assertEquals(reason, fe.getReason()); assertEquals(flag.isTrackEvents(), fe.isTrackEvents()); - assertEquals(flag.getDebugEventsUntilDate(), fe.getDebugEventsUntilDate()); + assertEquals(flag.getDebugEventsUntilDate() == null ? 0L : flag.getDebugEventsUntilDate().longValue(), fe.getDebugEventsUntilDate()); } private void checkUnknownFeatureEvent(Event e, String key, LDValue defaultVal, String prereqOf, @@ -494,12 +494,13 @@ private void checkUnknownFeatureEvent(Event e, String key, LDValue defaultVal, S Event.FeatureRequest fe = (Event.FeatureRequest)e; assertEquals(key, fe.getKey()); assertEquals(user.getKey(), fe.getUser().getKey()); - assertNull(fe.getVersion()); + assertEquals(-1, fe.getVersion()); + assertEquals(-1, fe.getVariation()); assertEquals(defaultVal, fe.getValue()); assertEquals(defaultVal, fe.getDefaultVal()); assertEquals(prereqOf, fe.getPrereqOf()); assertEquals(reason, fe.getReason()); assertFalse(fe.isTrackEvents()); - assertNull(fe.getDebugEventsUntilDate()); + assertEquals(0L, fe.getDebugEventsUntilDate()); } } From 29d947edc47c3384ccbc7b7140a2cce1a4145198 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 24 Apr 2020 19:23:09 -0700 Subject: [PATCH 395/673] minor readme updates --- CONTRIBUTING.md | 6 ++++-- README.md | 37 +++++++++++++------------------------ 2 files changed, 17 insertions(+), 26 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 656464d56..229d7cad7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,8 +15,10 @@ We encourage pull requests and other contributions from the community. Before su ### Prerequisites -The SDK builds with [Gradle](https://gradle.org/) and should be built against Java 7. - +The SDK builds with [Gradle](https://gradle.org/) and should be built against Java 8. + +Many basic classes are implemented in the module `launchdarkly-java-sdk-common`, whose source code is in the [`launchdarkly/java-sdk-common`](https://github.com/launchdarkly/java-sdk-common) repository; this is so the common code can be shared with the LaunchDarkly Android SDK. By design, the LaunchDarkly Java SDK distribution does not expose a dependency on that module; instead, its classes and Javadoc content are embedded in the SDK jars. + ### Building To build the SDK without running any tests: diff --git a/README.md b/README.md index 2a2ac92f3..6075a7ab2 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,19 @@ -LaunchDarkly Server-side 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-java-server-sdk.svg)](http://javadoc.io/doc/com.launchdarkly/launchdarkly-java-server-sdk) -LaunchDarkly overview -------------------------- +## 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 ------------------------ +## Supported Java versions This version of the LaunchDarkly SDK works with Java 8 and above. -Distributions -------------- +## Distributions Three variants of the SDK jar are published to Maven: @@ -24,13 +21,11 @@ Three variants of the SDK jar are published to Maven: * The extended uberjar - add `all` in Maven, or `:all` in Gradle. This is the same as the default uberjar except that SLF4J is also bundled, without shading (and is exported in OSGi). * The "thin" jar - add `thin` in Maven, or `:thin` in Gradle. This contains _only_ the SDK classes. -Getting started ------------ +## Getting started 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 -------- +## 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. @@ -38,35 +33,29 @@ 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 ---------------------------- +## 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 ------------------- +## 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. 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 ----------- +## Learn more 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 -------- +## 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 ------------- +## Contributing 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 ------------ +## About LaunchDarkly * LaunchDarkly is a continuous delivery platform that provides feature flags as a service and allows developers to iterate quickly and safely. We allow you to easily flag your features and manage them from the LaunchDarkly dashboard. With LaunchDarkly, you can: * Roll out a new feature to a subset of your users (like a group of users who opt-in to a beta tester group), gathering feedback and bug reports from real-world use cases. From acae1ed3d556462effbeaf192548a59955454b55 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 27 Apr 2020 09:54:30 -0700 Subject: [PATCH 396/673] 5.0: change build so Gson adapter is usable, improve packaging tests (#212) --- build.gradle | 54 ++++++++++++++- packaging-test/Makefile | 59 ++++++++-------- packaging-test/run-non-osgi-test.sh | 36 +++++++++- packaging-test/run-osgi-test.sh | 34 ++++++--- packaging-test/test-app/build.gradle | 6 +- .../com/newrelic/api/agent/NewRelic.java | 9 --- .../java/com/newrelic/api/agent/NewRelic.java | 8 --- .../testapp/JsonSerializationTestData.java | 44 ++++++++++++ .../src/main/java/testapp/TestApp.java | 69 ++++++++++++++++--- .../main/java/testapp/TestAppGsonTests.java | 42 +++++++++++ .../java/testapp/TestAppOsgiEntryPoint.java | 2 +- .../sdk/server/FeatureFlagsState.java | 25 ++++--- .../sdk/server/FeatureFlagsStateTest.java | 22 +++--- 13 files changed, 318 insertions(+), 92 deletions(-) delete mode 100644 packaging-test/test-app/src/main/java/com/launchdarkly/shaded/com/newrelic/api/agent/NewRelic.java delete mode 100644 packaging-test/test-app/src/main/java/com/newrelic/api/agent/NewRelic.java create mode 100644 packaging-test/test-app/src/main/java/testapp/JsonSerializationTestData.java create mode 100644 packaging-test/test-app/src/main/java/testapp/TestAppGsonTests.java diff --git a/build.gradle b/build.gradle index 0feef6ee0..bf5d42172 100644 --- a/build.gradle +++ b/build.gradle @@ -1,3 +1,6 @@ +import java.nio.file.Files +import java.nio.file.FileSystems +import java.nio.file.StandardCopyOption buildscript { repositories { @@ -160,6 +163,10 @@ shadowJar { // objects with detailed information about the resolved dependencies. addOsgiManifest(project.tasks.shadowJar, [ project.configurations.shadow ], []) } + + doLast { + replaceUnshadedClasses(project.tasks.shadowJar) + } } // This builds the "-all"/"fat" jar, which is the same as the default uberjar except that @@ -181,6 +188,10 @@ task shadowJarAll(type: com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJ // higher version if one is provided by another bundle. addOsgiManifest(project.tasks.shadowJarAll, [ project.configurations.shadow ], [ project.configurations.shadow ]) } + + doLast { + replaceUnshadedClasses(project.tasks.shadowJarAll) + } } task testJar(type: Jar, dependsOn: testClasses) { @@ -220,7 +231,8 @@ if (JavaVersion.current().isJava8Compatible()) { // 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 = [ "com.launchdarkly.sdk" ] // base package classes come from launchdarkly-java-sdk-common + // base package classes come from launchdarkly-java-sdk-common + def names = [ "com.launchdarkly.sdk", "com.launchdarkly.sdk.json" ] project.convention.getPlugin(JavaPluginConvention).sourceSets.main.output.each { baseDir -> if (baseDir.getPath().contains("classes" + File.separator + "java" + File.separator + "main")) { baseDir.eachFileRecurse { f -> @@ -271,6 +283,40 @@ def shadeDependencies(jarTask) { } } +def replaceUnshadedClasses(jarTask) { + // The LDGson class is a special case where we do *not* want any of the Gson class names it uses to be + // modified by shading (because its purpose is to interoperate with a non-shaded instance of Gson). + // Shadow doesn't seem to provide a way to say "make this class file immune from the changes that result + // from shading *other* classes", so the workaround is to simply recopy the original class file. Note that + // we use a wildcard to make sure we also get any inner classes. + def protectedClassFilePattern = 'com/launchdarkly/sdk/json/LDGson*.class' + jarTask.exclude protectedClassFilePattern + def protectedClassFiles = configurations.commonClasses.collectMany { + zipTree(it).matching { + include protectedClassFilePattern + } getFiles() + } + def jarPath = jarTask.archiveFile.asFile.get().toPath() + FileSystems.newFileSystem(jarPath, null).withCloseable { fs -> + protectedClassFiles.forEach { classFile -> + def classSubpath = classFile.path.substring(classFile.path.indexOf("com/launchdarkly")) + Files.copy(classFile.toPath(), fs.getPath(classSubpath), StandardCopyOption.REPLACE_EXISTING) + } + } +} + +def getFileFromClasspath(config, filePath) { + def files = config.collectMany { + zipTree(it) matching { + include filePath + } getFiles() + } + if (files.size != 1) { + throw new RuntimeException("could not find " + filePath); + } + return files[0] +} + def addOsgiManifest(jarTask, List importConfigs, List exportConfigs) { jarTask.manifest { attributes( @@ -285,10 +331,14 @@ def addOsgiManifest(jarTask, List importConfigs, List bundleImport(p, a.moduleVersion.id.version, nextMajorVersion(a.moduleVersion.id.version)) }) + systemPackageImports + imports += "com.google.gson;resolution:=optional" + imports += "com.google.gson.reflect;resolution:=optional" + imports += "com.google.gson.stream;resolution:=optional" attributes("Import-Package": imports.join(",")) // Similarly, we're adding package exports for every package in whatever libraries we're diff --git a/packaging-test/Makefile b/packaging-test/Makefile index bd234d009..7548fda60 100644 --- a/packaging-test/Makefile +++ b/packaging-test/Makefile @@ -23,6 +23,7 @@ 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 +TEST_APP_SOURCES=$(shell find $(BASE_DIR)/test-app -name *.java) $(BASE_DIR)/test-app/build.gradle # Felix OSGi container export FELIX_DIR=$(TEMP_DIR)/felix @@ -34,26 +35,25 @@ export TEMP_BUNDLE_DIR=$(FELIX_DIR)/app-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) \ + $(shell ls $(TEMP_DIR)/dependencies-external/gson*.jar 2>/dev/null) RUN_JARS_test-default-jar=$(TEST_APP_JAR) $(SDK_DEFAULT_JAR) \ - $(shell ls $(TEMP_DIR)/dependencies-external/*.jar) + $(shell ls $(TEMP_DIR)/dependencies-external/*.jar 2>/dev/null) 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) - -# The test-app displays this message on success -export SUCCESS_MESSAGE=@@@ successfully created LD client @@@ + $(shell ls $(TEMP_DIR)/dependencies-internal/*.jar 2>/dev/null) \ + $(shell ls $(TEMP_DIR)/dependencies-external/*.jar 2>/dev/null) 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 +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) verify_sdk_classes= \ - $(call classes_should_contain,'^com/launchdarkly/sdk/[^/]*$$',com.launchdarkly.sdk) && \ + $(call classes_should_contain,com/launchdarkly/sdk,com.launchdarkly.sdk) && \ + $(call classes_should_contain,com/launchdarkly/sdk/json,com.launchdarkly.sdk.json) && \ $(foreach subpkg,$(sdk_subpackage_names), \ - $(call classes_should_contain,'^com/launchdarkly/sdk/$(subpkg)/',com.launchdarkly.sdk.$(subpkg)) && ) true + $(call classes_should_contain,com/launchdarkly/sdk/$(subpkg),com.launchdarkly.sdk.$(subst /,.,$(subpkg))) && ) true sdk_subpackage_names= \ - $(shell ls -d $(PROJECT_DIR)/src/main/java/com/launchdarkly/sdk/*/ | sed -e 's@^.*/\([^/]*\)/@\1@') + $(shell cd $(PROJECT_DIR)/src/main/java/com/launchdarkly/sdk && find . ! -path . -type d | sed -e 's@^\./@@') caption=echo "" && echo "$(1)" @@ -61,44 +61,42 @@ all: test-all-jar test-default-jar test-thin-jar clean: rm -rf $(TEMP_DIR)/* + rm -rf test-app/build # 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: $$@-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. - @if [ "$@" != "test-thin-jar" ]; then \ - ./run-osgi-test.sh $(RUN_JARS_$@); \ - fi + @./run-non-osgi-test.sh $(RUN_JARS_$@) + @./run-osgi-test.sh $(RUN_JARS_$@) test-all-jar-classes: $(SDK_ALL_JAR) $(TEMP_DIR) @$(call caption,$@) @$(call classes_prepare,$<) @$(call verify_sdk_classes) - @$(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/sdk',shaded SDK classes) - @$(call classes_should_contain,'^com/launchdarkly/shaded/com/google/gson',shaded Gson) - @$(call classes_should_not_contain,'^com/google/gson/',Gson (unshaded)) - @$(call classes_should_not_contain,'^com/launchdarkly/shaded/org/slf4j',shaded SLF4j) + @$(call classes_should_contain,org/slf4j,unshaded SLF4j) + @$(call classes_should_not_contain,com/launchdarkly/shaded/com/launchdarkly/sdk,shaded SDK classes) + @$(call classes_should_contain,com/launchdarkly/shaded/com/google/gson,shaded Gson) + @$(call classes_should_not_contain,com/google/gson,unshaded Gson) + @$(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) - @$(call classes_should_not_contain,'^com/launchdarkly/shaded/com/launchdarkly/sdk',shaded SDK classes) - @$(call classes_should_contain,'^com/launchdarkly/shaded/com/google/gson',shaded Gson) - @$(call classes_should_not_contain,'^com/google/gson/',Gson (unshaded)) - @$(call classes_should_not_contain,'org/slf4j/',SLF4j (shaded or unshaded)) + @$(call classes_should_not_contain,com/launchdarkly/shaded/com/launchdarkly/sdk,shaded SDK classes) + @$(call classes_should_contain,com/launchdarkly/shaded/com/google/gson,shaded Gson) + @$(call classes_should_not_contain,com/launchdarkly/shaded/org/slf4j,shaded SLF4j) + @$(call classes_should_not_contain,com/google/gson,unshaded Gson) + @$(call classes_should_not_contain,org/slf4j,unshaded SLF4j) 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/sdk/',anything other than SDK classes) + @echo " should not contain anything other than SDK classes" + @! grep -v "^com/launchdarkly/sdk" $(TEMP_OUTPUT) $(SDK_DEFAULT_JAR): cd .. && ./gradlew shadowJar @@ -109,7 +107,7 @@ $(SDK_ALL_JAR): $(SDK_THIN_JAR): cd .. && ./gradlew jar -$(TEST_APP_JAR): $(SDK_THIN_JAR) | $(TEMP_DIR) +$(TEST_APP_JAR): $(SDK_THIN_JAR) $(TEST_APP_SOURCES) | $(TEMP_DIR) cd test-app && ../../gradlew jar cp $(BASE_DIR)/test-app/build/libs/test-app-*.jar $@ @@ -122,6 +120,7 @@ $(TEMP_DIR)/dependencies-all: | $(TEMP_DIR) $(TEMP_DIR)/dependencies-external: $(TEMP_DIR)/dependencies-all [ -d $@ ] || mkdir -p $@ cp $(TEMP_DIR)/dependencies-all/slf4j*.jar $@ + cp $(TEMP_DIR)/dependencies-all/gson*.jar $@ $(TEMP_DIR)/dependencies-internal: $(TEMP_DIR)/dependencies-all [ -d $@ ] || mkdir -p $@ diff --git a/packaging-test/run-non-osgi-test.sh b/packaging-test/run-non-osgi-test.sh index dcf9c24db..49b8d953d 100755 --- a/packaging-test/run-non-osgi-test.sh +++ b/packaging-test/run-non-osgi-test.sh @@ -1,6 +1,36 @@ #!/bin/bash +function run_test() { + rm -f ${TEMP_OUTPUT} + touch ${TEMP_OUTPUT} + classpath=$(echo "$@" | sed -e 's/ /:/g') + java -classpath "$classpath" testapp.TestApp | tee ${TEMP_OUTPUT} + grep "TestApp: PASS" ${TEMP_OUTPUT} >/dev/null +} + +echo "" +echo " non-OSGi runtime test - with Gson" +run_test $@ +grep "LDGson tests OK" ${TEMP_OUTPUT} >/dev/null || (echo "FAIL: should have run LDGson tests but did not" && exit 1) + +# It does not make sense to test the "thin" jar without Gson. The SDK uses Gson internally +# and can't work without it; in the default jar and the "all" jar, it has its own embedded +# copy of Gson, but the "thin" jar does not include any third-party dependencies so you must +# provide all of them including Gson. +thin_sdk_regex=".*launchdarkly-java-server-sdk-[^ ]*-thin\\.jar" +if [[ "$@" =~ $thin_sdk_regex ]]; then + exit 0 +fi + 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 +echo " non-OSGi runtime test - without Gson" +deps_except_json="" +json_jar_regex=".*gson.*" +for dep in $@; do + if [[ ! "$dep" =~ $json_jar_regex ]]; then + deps_except_json="$deps_except_json $dep" + fi +done +run_test $deps_except_json +grep "skipping LDGson tests" ${TEMP_OUTPUT} >/dev/null || \ + (echo "FAIL: should have skipped LDGson tests but did not; test setup was incorrect" && exit 1) diff --git a/packaging-test/run-osgi-test.sh b/packaging-test/run-osgi-test.sh index 62439fedf..df5f69739 100755 --- a/packaging-test/run-osgi-test.sh +++ b/packaging-test/run-osgi-test.sh @@ -1,14 +1,32 @@ #!/bin/bash -echo "" -echo " OSGi runtime test" +# We can't test the "thin" jar in OSGi, because some of our third-party dependencies +# aren't available as OSGi bundles. That isn't a plausible use case anyway. +thin_sdk_regex=".*launchdarkly-java-server-sdk-[^ ]*-thin\\.jar" +if [[ "$@" =~ $thin_sdk_regex ]]; then + exit 0 +fi + 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} +function run_test() { + 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 "TestApp: PASS" ${TEMP_OUTPUT} >/dev/null +} -grep "${SUCCESS_MESSAGE}" ${TEMP_OUTPUT} >/dev/null +echo "" +echo " OSGi runtime test - with Gson" +cp $@ ${FELIX_BASE_BUNDLE_DIR}/* ${TEMP_BUNDLE_DIR} +run_test +grep "LDGson tests OK" ${TEMP_OUTPUT} >/dev/null || (echo "FAIL: should have run LDGson tests but did not" && exit 1) + +echo "" +echo " OSGi runtime test - without Gson" +rm ${TEMP_BUNDLE_DIR}/*gson*.jar +run_test +grep "skipping LDGson tests" ${TEMP_OUTPUT} >/dev/null || \ + (echo "FAIL: should have skipped LDGson tests but did not; test setup was incorrect" && exit 1) diff --git a/packaging-test/test-app/build.gradle b/packaging-test/test-app/build.gradle index 59d3fd936..9673b74eb 100644 --- a/packaging-test/test-app/build.gradle +++ b/packaging-test/test-app/build.gradle @@ -36,7 +36,11 @@ dependencies { jar { bnd( - 'Bundle-Activator': 'testapp.TestAppOsgiEntryPoint' + 'Bundle-Activator': 'testapp.TestAppOsgiEntryPoint', + 'Import-Package': 'com.launchdarkly.sdk,com.launchdarkly.sdk.json' + + ',com.launchdarkly.sdk.server,org.slf4j' + + ',org.osgi.framework' + + ',com.google.gson;resolution:=optional' ) } 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 deleted file mode 100644 index 83bae9f3b..000000000 --- a/packaging-test/test-app/src/main/java/com/launchdarkly/shaded/com/newrelic/api/agent/NewRelic.java +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index 5b106c460..000000000 --- a/packaging-test/test-app/src/main/java/com/newrelic/api/agent/NewRelic.java +++ /dev/null @@ -1,8 +0,0 @@ -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/JsonSerializationTestData.java b/packaging-test/test-app/src/main/java/testapp/JsonSerializationTestData.java new file mode 100644 index 000000000..8bd8a9493 --- /dev/null +++ b/packaging-test/test-app/src/main/java/testapp/JsonSerializationTestData.java @@ -0,0 +1,44 @@ +package testapp; + +import com.launchdarkly.sdk.*; +import java.util.*; + +public class JsonSerializationTestData { + public static class TestItem { + final Object objectToSerialize; + final String expectedJson; + + private TestItem(Object objectToSerialize, String expectedJson) { + this.objectToSerialize = objectToSerialize; + this.expectedJson = expectedJson; + } + } + + public static TestItem[] TEST_ITEMS = new TestItem[] { + new TestItem( + LDValue.buildArray().add(1).add(2).build(), + "[1,2]" + ), + new TestItem( + Collections.singletonMap("value", LDValue.buildArray().add(1).add(2).build()), + "{\"value\":[1,2]}" + ), + new TestItem( + EvaluationReason.off(), + "{\"kind\":\"OFF\"}" + ), + new TestItem( + new LDUser.Builder("userkey").build(), + "{\"key\":\"userkey\"}" + ) + }; + + public static boolean assertJsonEquals(String expectedJson, String actualJson, Object objectToSerialize) { + if (!LDValue.parse(actualJson).equals(LDValue.parse(expectedJson))) { + TestApp.addError("JSON encoding of " + objectToSerialize.getClass() + " should have been " + + expectedJson + ", was " + actualJson, null); + return false; + } + return true; + } +} \ No newline at end of file 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 d15bfb217..034852cbe 100644 --- a/packaging-test/test-app/src/main/java/testapp/TestApp.java +++ b/packaging-test/test-app/src/main/java/testapp/TestApp.java @@ -1,21 +1,74 @@ package testapp; import com.launchdarkly.sdk.*; +import com.launchdarkly.sdk.json.*; import com.launchdarkly.sdk.server.*; +import java.util.*; import org.slf4j.*; public class TestApp { - private static final Logger logger = LoggerFactory.getLogger(TestApp.class); + private static final Logger logger = LoggerFactory.getLogger(TestApp.class); // proves SLF4J API is on classpath + + private static List errors = new ArrayList<>(); public static void main(String[] args) throws Exception { - LDConfig config = new LDConfig.Builder() - .offline(true) - .build(); - LDClient client = new LDClient("fake-sdk-key", config); + try { + LDConfig config = new LDConfig.Builder() + .offline(true) + .build(); + LDClient client = new LDClient("fake-sdk-key", config); + log("client creation OK"); + } catch (RuntimeException e) { + addError("client creation failed", e); + } + + try { + boolean jsonOk = true; + for (JsonSerializationTestData.TestItem item: JsonSerializationTestData.TEST_ITEMS) { + if (!(item instanceof JsonSerializable)) { + continue; // things without our marker interface, like a Map, can't be passed to JsonSerialization.serialize + } + String actualJson = JsonSerialization.serialize((JsonSerializable)item.objectToSerialize); + if (!JsonSerializationTestData.assertJsonEquals(item.expectedJson, actualJson, item.objectToSerialize)) { + jsonOk = false; + } + } + if (jsonOk) { + log("JsonSerialization tests OK"); + } + } catch (RuntimeException e) { + addError("unexpected error in JsonSerialization tests", e); + } + + try { + Class.forName("testapp.TestAppGsonTests"); // see TestAppGsonTests for why we're loading it in this way + } catch (NoClassDefFoundError e) { + log("skipping LDGson tests because Gson is not in the classpath"); + } catch (RuntimeException e) { + addError("unexpected error in LDGson tests", e); + } - // Also do a flag evaluation, to ensure that it calls NewRelicReflector.annotateTransaction() - client.boolVariation("flag-key", new LDUser("user-key"), false); + if (errors.isEmpty()) { + log("PASS"); + } else { + for (String err: errors) { + log("ERROR: " + err); + } + log("FAIL"); + System.exit(1); + } + } + + public static void addError(String message, Throwable e) { + if (e != null) { + errors.add(message + ": " + e); + e.printStackTrace(); + } else { + errors.add(message); + } + } - System.out.println("@@@ successfully created LD client @@@"); + public static void log(String message) { + System.out.println("TestApp: " + message); } } \ No newline at end of file diff --git a/packaging-test/test-app/src/main/java/testapp/TestAppGsonTests.java b/packaging-test/test-app/src/main/java/testapp/TestAppGsonTests.java new file mode 100644 index 000000000..1ea44e03c --- /dev/null +++ b/packaging-test/test-app/src/main/java/testapp/TestAppGsonTests.java @@ -0,0 +1,42 @@ +package testapp; + +import com.google.gson.*; +import com.launchdarkly.sdk.*; +import com.launchdarkly.sdk.json.*; + +// This code is in its own class that is loaded dynamically because some of our test scenarios +// involve running TestApp without having Gson in the classpath, to make sure the SDK does not +// *require* the presence of an external Gson even though it can interoperate with one. + +public class TestAppGsonTests { + // Use static block so simply loading this class causes the tests to execute + static { + // First try referencing Gson, so we fail right away if it's not on the classpath + Class c = Gson.class; + try { + runGsonTests(); + } catch (NoClassDefFoundError e) { + // If we've even gotten to this static block, then Gson itself *is* on the application's + // classpath, so this must be some other kind of classloading error that we do want to + // report. For instance, a NoClassDefFound error for Gson at this point, if we're in + // OSGi, would mean that the SDK bundle is unable to see the external Gson classes. + TestApp.addError("unexpected error in LDGson tests", e); + } + } + + public static void runGsonTests() { + Gson gson = new GsonBuilder().registerTypeAdapterFactory(LDGson.typeAdapters()).create(); + + boolean ok = true; + for (JsonSerializationTestData.TestItem item: JsonSerializationTestData.TEST_ITEMS) { + String actualJson = gson.toJson(item.objectToSerialize); + if (!JsonSerializationTestData.assertJsonEquals(item.expectedJson, actualJson, item.objectToSerialize)) { + ok = false; + } + } + + if (ok) { + TestApp.log("LDGson tests OK"); + } + } +} \ 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 index ed42ccb1a..65602cd29 100644 --- a/packaging-test/test-app/src/main/java/testapp/TestAppOsgiEntryPoint.java +++ b/packaging-test/test-app/src/main/java/testapp/TestAppOsgiEntryPoint.java @@ -5,7 +5,7 @@ public class TestAppOsgiEntryPoint implements BundleActivator { public void start(BundleContext context) throws Exception { - System.out.println("@@@ starting test bundle @@@"); + System.out.println("TestApp: starting test bundle"); TestApp.main(new String[0]); diff --git a/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java b/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java index 487abc992..6f5226058 100644 --- a/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java +++ b/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java @@ -1,32 +1,41 @@ package com.launchdarkly.sdk.server; import com.google.common.base.Objects; -import com.google.gson.Gson; import com.google.gson.TypeAdapter; import com.google.gson.annotations.JsonAdapter; import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonWriter; import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.json.JsonSerializable; import java.io.IOException; import java.util.Collections; import java.util.HashMap; import java.util.Map; +import static com.launchdarkly.sdk.server.JsonHelpers.gsonInstance; + /** * A snapshot of the state of all feature flags with regard to a specific user, generated by * calling {@link LDClientInterface#allFlagsState(com.launchdarkly.sdk.LDUser, FlagsStateOption...)}. *

    * Serializing this object to JSON using Gson will produce the appropriate data structure for * bootstrapping the LaunchDarkly JavaScript client. + *

    + * LaunchDarkly defines a standard JSON encoding for this object, suitable for + * bootstrapping + * the LaunchDarkly JavaScript browser SDK. You can convert it to JSON in one of two ways: + *

      + *
    1. With {@link com.launchdarkly.sdk.json.JsonSerialization}. + *
    2. With Gson, if and only if you configure your Gson instance with + * {@link com.launchdarkly.sdk.json.LDGson}. + *
    * * @since 4.3.0 */ @JsonAdapter(FeatureFlagsState.JsonSerialization.class) -public class FeatureFlagsState { - private static final Gson gson = new Gson(); - +public class FeatureFlagsState implements JsonSerializable { private final Map flagValues; private final Map flagMetadata; private final boolean valid; @@ -170,10 +179,10 @@ 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(), LDValue.class, out); + gsonInstance().toJson(entry.getValue(), LDValue.class, out); } out.name("$flagsState"); - gson.toJson(state.flagMetadata, Map.class, out); + gsonInstance().toJson(state.flagMetadata, Map.class, out); out.name("$valid"); out.value(state.valid); out.endObject(); @@ -192,14 +201,14 @@ public FeatureFlagsState read(JsonReader in) throws IOException { in.beginObject(); while (in.hasNext()) { String metaName = in.nextName(); - FlagMetadata meta = gson.fromJson(in, FlagMetadata.class); + FlagMetadata meta = gsonInstance().fromJson(in, FlagMetadata.class); flagMetadata.put(metaName, meta); } in.endObject(); } else if (name.equals("$valid")) { valid = in.nextBoolean(); } else { - LDValue value = gson.fromJson(in, LDValue.class); + LDValue value = gsonInstance().fromJson(in, LDValue.class); flagValues.put(name, value); } } diff --git a/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java b/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java index 742ca71a6..c158e0405 100644 --- a/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java @@ -1,14 +1,10 @@ package com.launchdarkly.sdk.server; import com.google.common.collect.ImmutableMap; -import com.google.gson.Gson; -import com.google.gson.JsonElement; import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDValue; -import com.launchdarkly.sdk.server.DataModel; -import com.launchdarkly.sdk.server.Evaluator; -import com.launchdarkly.sdk.server.FeatureFlagsState; -import com.launchdarkly.sdk.server.FlagsStateOption; +import com.launchdarkly.sdk.json.JsonSerialization; +import com.launchdarkly.sdk.json.SerializationException; import org.junit.Test; @@ -18,8 +14,6 @@ @SuppressWarnings("javadoc") public class FeatureFlagsStateTest { - private static final Gson gson = new Gson(); - @Test public void canGetFlagValue() { Evaluator.EvalResult eval = new Evaluator.EvalResult(LDValue.of("value"), 1, EvaluationReason.off()); @@ -93,7 +87,7 @@ public void canConvertToJson() { FeatureFlagsState state = new FeatureFlagsState.Builder(FlagsStateOption.WITH_REASONS) .addFlag(flag1, eval1).addFlag(flag2, eval2).build(); - String json = "{\"key1\":\"value1\",\"key2\":\"value2\"," + + String expectedJsonString = "{\"key1\":\"value1\",\"key2\":\"value2\"," + "\"$flagsState\":{" + "\"key1\":{" + "\"variation\":0,\"version\":100,\"reason\":{\"kind\":\"OFF\"}" + // note, "trackEvents: false" is omitted @@ -103,12 +97,12 @@ public void canConvertToJson() { "}," + "\"$valid\":true" + "}"; - JsonElement expected = gson.fromJson(json, JsonElement.class); - assertEquals(expected, gson.toJsonTree(state)); + String actualJsonString = JsonSerialization.serialize(state); + assertEquals(LDValue.parse(expectedJsonString), LDValue.parse(actualJsonString)); } @Test - public void canConvertFromJson() { + public void canConvertFromJson() throws SerializationException { Evaluator.EvalResult eval1 = new Evaluator.EvalResult(LDValue.of("value1"), 0, EvaluationReason.off()); DataModel.FeatureFlag flag1 = flagBuilder("key1").version(100).trackEvents(false).build(); Evaluator.EvalResult eval2 = new Evaluator.EvalResult(LDValue.of("value2"), 1, EvaluationReason.off()); @@ -116,8 +110,8 @@ public void canConvertFromJson() { FeatureFlagsState state = new FeatureFlagsState.Builder() .addFlag(flag1, eval1).addFlag(flag2, eval2).build(); - String json = gson.toJson(state); - FeatureFlagsState state1 = gson.fromJson(json, FeatureFlagsState.class); + String json = JsonSerialization.serialize(state); + FeatureFlagsState state1 = JsonSerialization.deserialize(json, FeatureFlagsState.class); assertEquals(state, state1); } } From 5452b4f3d5852b4d8e076aeaa9e1a6cf2afca344 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 28 Apr 2020 10:13:13 -0700 Subject: [PATCH 397/673] (5.0 - #2) changes to support the Jackson adapter in java-sdk-common (#218) --- build.gradle | 16 +++-- .../checkstyle/checkstyle.xml | 4 ++ config/checkstyle/suppressions.xml | 12 ++++ .../sdk/json/SdkSerializationExtensions.java | 14 ++++ .../sdk/server/FeatureFlagsState.java | 9 ++- .../sdk/server/FeatureFlagsStateTest.java | 64 ++++++++++++------- 6 files changed, 87 insertions(+), 32 deletions(-) rename checkstyle.xml => config/checkstyle/checkstyle.xml (73%) create mode 100644 config/checkstyle/suppressions.xml create mode 100644 src/main/java/com/launchdarkly/sdk/json/SdkSerializationExtensions.java diff --git a/build.gradle b/build.gradle index bf5d42172..ff4f47f04 100644 --- a/build.gradle +++ b/build.gradle @@ -70,6 +70,7 @@ ext.versions = [ "commonsCodec": "1.10", "gson": "2.7", "guava": "28.2-jre", + "jackson": "2.10.0", "launchdarklyJavaSdkCommon": "1.0.0-SNAPSHOT", "okhttpEventsource": "2.1.0-SNAPSHOT", "slf4j": "1.7.21", @@ -103,7 +104,9 @@ libraries.test = [ "org.hamcrest:hamcrest-all:1.3", "org.easymock:easymock:3.4", "junit:junit:4.12", - "ch.qos.logback:logback-classic:1.1.7" + "ch.qos.logback:logback-classic:1.1.7", + "com.fasterxml.jackson.core:jackson-core:${versions.jackson}", + "com.fasterxml.jackson.core:jackson-databind:${versions.jackson}" ] dependencies { @@ -127,7 +130,8 @@ configurations { } checkstyle { - configFile file("${project.rootDir}/checkstyle.xml") + configFile file("${project.rootDir}/config/checkstyle/checkstyle.xml") + configDir file("${project.rootDir}/config/checkstyle") } jar { @@ -213,6 +217,10 @@ task javadocJar(type: Jar, dependsOn: javadoc) { javadoc { source configurations.commonDoc.collect { zipTree(it) } include '**/*.java' + + // Use test classpath so Javadoc won't complain about java-sdk-common classes that internally + // reference stuff we don't use directly, like Jackson + classpath = sourceSets.test.compileClasspath } // Force the Javadoc build to fail if there are any Javadoc warnings. See: https://discuss.gradle.org/t/javadoc-fail-on-warning/18141/3 @@ -252,8 +260,8 @@ def getAllSdkPackages() { 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() + it.name.contains("/") ? it.name.substring(0, it.name.lastIndexOf("/")).replace("/", ".") : "" + }.findAll { !it.equals("") }.unique() } } diff --git a/checkstyle.xml b/config/checkstyle/checkstyle.xml similarity index 73% rename from checkstyle.xml rename to config/checkstyle/checkstyle.xml index 0b201f9c0..a1d367afe 100644 --- a/checkstyle.xml +++ b/config/checkstyle/checkstyle.xml @@ -3,6 +3,10 @@ "-//Checkstyle//DTD Checkstyle Configuration 1.3//EN" "https://checkstyle.org/dtds/configuration_1_3.dtd"> + + + + diff --git a/config/checkstyle/suppressions.xml b/config/checkstyle/suppressions.xml new file mode 100644 index 000000000..1959e98eb --- /dev/null +++ b/config/checkstyle/suppressions.xml @@ -0,0 +1,12 @@ + + + + + + + diff --git a/src/main/java/com/launchdarkly/sdk/json/SdkSerializationExtensions.java b/src/main/java/com/launchdarkly/sdk/json/SdkSerializationExtensions.java new file mode 100644 index 000000000..cf3dac109 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/json/SdkSerializationExtensions.java @@ -0,0 +1,14 @@ +package com.launchdarkly.sdk.json; + +import com.google.common.collect.ImmutableList; +import com.launchdarkly.sdk.server.FeatureFlagsState; + +// See JsonSerialization.getDeserializableClasses in java-sdk-common. + +class SdkSerializationExtensions { + public static Iterable> getDeserializableClasses() { + return ImmutableList.>of( + FeatureFlagsState.class + ); + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java b/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java index 6f5226058..0f0306af2 100644 --- a/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java +++ b/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java @@ -20,16 +20,15 @@ * A snapshot of the state of all feature flags with regard to a specific user, generated by * calling {@link LDClientInterface#allFlagsState(com.launchdarkly.sdk.LDUser, FlagsStateOption...)}. *

    - * Serializing this object to JSON using Gson will produce the appropriate data structure for - * bootstrapping the LaunchDarkly JavaScript client. - *

    * LaunchDarkly defines a standard JSON encoding for this object, suitable for * bootstrapping - * the LaunchDarkly JavaScript browser SDK. You can convert it to JSON in one of two ways: + * the LaunchDarkly JavaScript browser SDK. You can convert it to JSON in any of these ways: *

      *
    1. With {@link com.launchdarkly.sdk.json.JsonSerialization}. - *
    2. With Gson, if and only if you configure your Gson instance with + *
    3. With Gson, if and only if you configure your {@code Gson} instance with * {@link com.launchdarkly.sdk.json.LDGson}. + *
    4. With Jackson, if and only if you configure your {@code ObjectMapper} instance with + * {@link com.launchdarkly.sdk.json.LDJackson}. *
    * * @since 4.3.0 diff --git a/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java b/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java index c158e0405..e9e36bbff 100644 --- a/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java @@ -1,9 +1,11 @@ package com.launchdarkly.sdk.server; +import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.collect.ImmutableMap; import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.json.JsonSerialization; +import com.launchdarkly.sdk.json.LDJackson; import com.launchdarkly.sdk.json.SerializationException; import org.junit.Test; @@ -80,38 +82,54 @@ public void canConvertToValuesMap() { @Test public void canConvertToJson() { + String actualJsonString = JsonSerialization.serialize(makeInstanceForSerialization()); + assertEquals(LDValue.parse(makeExpectedJsonSerialization()), LDValue.parse(actualJsonString)); + } + + @Test + public void canConvertFromJson() throws SerializationException { + FeatureFlagsState state = JsonSerialization.deserialize(makeExpectedJsonSerialization(), FeatureFlagsState.class); + assertEquals(makeInstanceForSerialization(), state); + } + + private static FeatureFlagsState makeInstanceForSerialization() { Evaluator.EvalResult eval1 = new Evaluator.EvalResult(LDValue.of("value1"), 0, EvaluationReason.off()); DataModel.FeatureFlag flag1 = flagBuilder("key1").version(100).trackEvents(false).build(); Evaluator.EvalResult eval2 = new Evaluator.EvalResult(LDValue.of("value2"), 1, EvaluationReason.fallthrough()); DataModel.FeatureFlag flag2 = flagBuilder("key2").version(200).trackEvents(true).debugEventsUntilDate(1000L).build(); - FeatureFlagsState state = new FeatureFlagsState.Builder(FlagsStateOption.WITH_REASONS) + return new FeatureFlagsState.Builder(FlagsStateOption.WITH_REASONS) .addFlag(flag1, eval1).addFlag(flag2, eval2).build(); - - String expectedJsonString = "{\"key1\":\"value1\",\"key2\":\"value2\"," + + } + + private static String makeExpectedJsonSerialization() { + return "{\"key1\":\"value1\",\"key2\":\"value2\"," + "\"$flagsState\":{" + - "\"key1\":{" + - "\"variation\":0,\"version\":100,\"reason\":{\"kind\":\"OFF\"}" + // note, "trackEvents: false" is omitted - "},\"key2\":{" + - "\"variation\":1,\"version\":200,\"reason\":{\"kind\":\"FALLTHROUGH\"},\"trackEvents\":true,\"debugEventsUntilDate\":1000" + - "}" + - "}," + - "\"$valid\":true" + - "}"; - String actualJsonString = JsonSerialization.serialize(state); - assertEquals(LDValue.parse(expectedJsonString), LDValue.parse(actualJsonString)); + "\"key1\":{" + + "\"variation\":0,\"version\":100,\"reason\":{\"kind\":\"OFF\"}" + // note, "trackEvents: false" is omitted + "},\"key2\":{" + + "\"variation\":1,\"version\":200,\"reason\":{\"kind\":\"FALLTHROUGH\"},\"trackEvents\":true,\"debugEventsUntilDate\":1000" + + "}" + + "}," + + "\"$valid\":true" + + "}"; } @Test - public void canConvertFromJson() throws SerializationException { - Evaluator.EvalResult eval1 = new Evaluator.EvalResult(LDValue.of("value1"), 0, EvaluationReason.off()); - DataModel.FeatureFlag flag1 = flagBuilder("key1").version(100).trackEvents(false).build(); - Evaluator.EvalResult eval2 = new Evaluator.EvalResult(LDValue.of("value2"), 1, EvaluationReason.off()); - DataModel.FeatureFlag flag2 = flagBuilder("key2").version(200).trackEvents(true).debugEventsUntilDate(1000L).build(); - FeatureFlagsState state = new FeatureFlagsState.Builder() - .addFlag(flag1, eval1).addFlag(flag2, eval2).build(); + public void canSerializeAndDeserializeWithJackson() throws Exception { + // FeatureFlagsState, being a JsonSerializable, should get the same custom serialization/deserialization + // support that is provided by java-sdk-common for Gson and Jackson. Our Gson interoperability just relies + // on the same Gson annotations that we use internally, but the Jackson adapter will only work if the + // java-server-sdk and java-sdk-common packages are configured together correctly. So we'll test that here. + // If it fails, the symptom will be something like Jackson complaining that it doesn't know how to + // instantiate the FeatureFlagsState class. + + ObjectMapper jacksonMapper = new ObjectMapper(); + jacksonMapper.registerModule(LDJackson.module()); + + String actualJsonString = jacksonMapper.writeValueAsString(makeInstanceForSerialization()); + assertEquals(LDValue.parse(makeExpectedJsonSerialization()), LDValue.parse(actualJsonString)); - String json = JsonSerialization.serialize(state); - FeatureFlagsState state1 = JsonSerialization.deserialize(json, FeatureFlagsState.class); - assertEquals(state, state1); + FeatureFlagsState state = jacksonMapper.readValue(makeExpectedJsonSerialization(), FeatureFlagsState.class); + assertEquals(makeInstanceForSerialization(), state); } } From 7753892aeb001ab3d1169d70133d20b9e5ae527b Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 28 Apr 2020 21:44:47 -0700 Subject: [PATCH 398/673] remove unnecessary Guava usage --- .../com/launchdarkly/sdk/server/Components.java | 5 ++--- .../launchdarkly/sdk/server/FeatureFlagsState.java | 14 +++++++------- .../launchdarkly/sdk/server/PollingProcessor.java | 8 ++++---- .../launchdarkly/sdk/server/StreamProcessor.java | 14 +++++++------- .../java/com/launchdarkly/sdk/server/Util.java | 7 +------ .../server/integrations/FileDataSourceImpl.java | 4 ++-- .../sdk/server/interfaces/DataStoreTypes.java | 6 +++--- .../sdk/server/DataStoreTestTypes.java | 8 ++++---- .../sdk/server/StreamProcessorTest.java | 10 +++++----- .../launchdarkly/sdk/server/TestComponents.java | 7 +++---- 10 files changed, 38 insertions(+), 45 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/Components.java b/src/main/java/com/launchdarkly/sdk/server/Components.java index 3c9036648..6ffdd37c7 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Components.java +++ b/src/main/java/com/launchdarkly/sdk/server/Components.java @@ -28,10 +28,9 @@ import java.net.InetSocketAddress; import java.net.Proxy; import java.net.URI; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.Future; -import static com.google.common.util.concurrent.Futures.immediateFuture; - import okhttp3.Credentials; /** @@ -359,7 +358,7 @@ static final class NullDataSource implements DataSource { static final DataSource INSTANCE = new NullDataSource(); @Override public Future start() { - return immediateFuture(null); + return CompletableFuture.completedFuture(null); } @Override diff --git a/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java b/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java index 0f0306af2..d28324fa9 100644 --- a/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java +++ b/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java @@ -1,6 +1,5 @@ package com.launchdarkly.sdk.server; -import com.google.common.base.Objects; import com.google.gson.TypeAdapter; import com.google.gson.annotations.JsonAdapter; import com.google.gson.stream.JsonReader; @@ -13,6 +12,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.Map; +import java.util.Objects; import static com.launchdarkly.sdk.server.JsonHelpers.gsonInstance; @@ -59,17 +59,17 @@ static class FlagMetadata { public boolean equals(Object other) { if (other instanceof FlagMetadata) { FlagMetadata o = (FlagMetadata)other; - return Objects.equal(variation, o.variation) && - Objects.equal(version, o.version) && - Objects.equal(trackEvents, o.trackEvents) && - Objects.equal(debugEventsUntilDate, o.debugEventsUntilDate); + return Objects.equals(variation, o.variation) && + Objects.equals(version, o.version) && + Objects.equals(trackEvents, o.trackEvents) && + Objects.equals(debugEventsUntilDate, o.debugEventsUntilDate); } return false; } @Override public int hashCode() { - return Objects.hashCode(variation, version, trackEvents, debugEventsUntilDate); + return Objects.hash(variation, version, trackEvents, debugEventsUntilDate); } } @@ -133,7 +133,7 @@ public boolean equals(Object other) { @Override public int hashCode() { - return Objects.hashCode(flagValues, flagMetadata, valid); + return Objects.hash(flagValues, flagMetadata, valid); } static class Builder { diff --git a/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java b/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java index a748597ef..6bb689245 100644 --- a/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java @@ -1,7 +1,6 @@ package com.launchdarkly.sdk.server; import com.google.common.annotations.VisibleForTesting; -import com.google.common.util.concurrent.SettableFuture; import com.google.common.util.concurrent.ThreadFactoryBuilder; import com.launchdarkly.sdk.server.interfaces.DataSource; import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; @@ -12,6 +11,7 @@ import java.io.IOException; import java.time.Duration; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.ScheduledExecutorService; @@ -55,7 +55,7 @@ public void close() throws IOException { public Future start() { logger.info("Starting LaunchDarkly polling client with interval: " + pollInterval.toMillis() + " milliseconds"); - final SettableFuture initFuture = SettableFuture.create(); + final CompletableFuture initFuture = new CompletableFuture<>(); ThreadFactory threadFactory = new ThreadFactoryBuilder() .setNameFormat("LaunchDarkly-PollingProcessor-%d") .build(); @@ -67,13 +67,13 @@ public Future start() { dataStoreUpdates.init(allData.toFullDataSet()); if (!initialized.getAndSet(true)) { logger.info("Initialized LaunchDarkly client."); - initFuture.set(null); + initFuture.complete(null); } } catch (HttpErrorException e) { logger.error(httpErrorMessage(e.getStatus(), "polling request", "will retry")); if (!isHttpErrorRecoverable(e.getStatus())) { scheduler.shutdown(); - initFuture.set(null); // if client is initializing, make it stop waiting; has no effect if already inited + initFuture.complete(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.toString()); diff --git a/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java index 658647015..83b376abf 100644 --- a/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java @@ -1,7 +1,6 @@ package com.launchdarkly.sdk.server; import com.google.common.annotations.VisibleForTesting; -import com.google.common.util.concurrent.SettableFuture; import com.google.gson.JsonElement; import com.launchdarkly.eventsource.ConnectionErrorHandler; import com.launchdarkly.eventsource.ConnectionErrorHandler.Action; @@ -27,6 +26,7 @@ import java.time.Duration; import java.util.AbstractMap; import java.util.Map; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicBoolean; @@ -173,12 +173,12 @@ private ConnectionErrorHandler createDefaultConnectionErrorHandler() { @Override public Future start() { - final SettableFuture initFuture = SettableFuture.create(); + final CompletableFuture initFuture = new CompletableFuture<>(); ConnectionErrorHandler wrappedConnectionErrorHandler = (Throwable t) -> { 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 + initFuture.complete(null); // if client is initializing, make it stop waiting; has no effect if already inited } return result; }; @@ -220,9 +220,9 @@ public boolean isInitialized() { } private class StreamEventHandler implements EventHandler { - private final SettableFuture initFuture; + private final CompletableFuture initFuture; - StreamEventHandler(SettableFuture initFuture) { + StreamEventHandler(CompletableFuture initFuture) { this.initFuture = initFuture; } @@ -298,7 +298,7 @@ private void handlePut(String eventData) throws StreamInputException, StreamStor throw new StreamStoreException(e); } if (!initialized.getAndSet(true)) { - initFuture.set(null); + initFuture.complete(null); logger.info("Initialized LaunchDarkly client."); } } @@ -349,7 +349,7 @@ private void handleIndirectPut() throws StreamInputException, StreamStoreExcepti throw new StreamStoreException(e); } if (!initialized.getAndSet(true)) { - initFuture.set(null); + initFuture.complete(null); logger.info("Initialized LaunchDarkly client."); } } diff --git a/src/main/java/com/launchdarkly/sdk/server/Util.java b/src/main/java/com/launchdarkly/sdk/server/Util.java index a5992b8b9..b77811c61 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Util.java +++ b/src/main/java/com/launchdarkly/sdk/server/Util.java @@ -1,6 +1,5 @@ package com.launchdarkly.sdk.server; -import com.google.common.base.Function; import com.launchdarkly.sdk.server.interfaces.HttpAuthentication; import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; @@ -61,11 +60,7 @@ public Request authenticate(Route route, Response response) throws IOException { 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()); - } - }); + c -> new HttpAuthentication.Challenge(c.scheme(), c.realm())); String credential = strategy.provideAuthorization(challenges); return response.request().newBuilder() .header(responseHeaderName, credential) diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java index 5dafc9bdb..f4d93ffcb 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java @@ -1,7 +1,6 @@ package com.launchdarkly.sdk.server.integrations; import com.google.common.collect.ImmutableList; -import com.google.common.util.concurrent.Futures; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FileDataException; import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FlagFactory; @@ -34,6 +33,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicBoolean; @@ -73,7 +73,7 @@ final class FileDataSourceImpl implements DataSource { @Override public Future start() { - final Future initFuture = Futures.immediateFuture(null); + final Future initFuture = CompletableFuture.completedFuture(null); reload(); diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypes.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypes.java index 12d7380c8..04fda02f9 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypes.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypes.java @@ -1,9 +1,9 @@ package com.launchdarkly.sdk.server.interfaces; -import com.google.common.base.Objects; import com.google.common.collect.ImmutableList; import java.util.Map; +import java.util.Objects; import java.util.function.Function; /** @@ -152,7 +152,7 @@ public static ItemDescriptor deletedItem(int version) { public boolean equals(Object o) { if (o instanceof ItemDescriptor) { ItemDescriptor other = (ItemDescriptor)o; - return version == other.version && Objects.equal(item, other.item); + return version == other.version && Objects.equals(item, other.item); } return false; } @@ -223,7 +223,7 @@ public boolean equals(Object o) { if (o instanceof SerializedItemDescriptor) { SerializedItemDescriptor other = (SerializedItemDescriptor)o; return version == other.version && deleted == other.deleted && - Objects.equal(serializedItem, other.serializedItem); + Objects.equals(serializedItem, other.serializedItem); } return false; } diff --git a/src/test/java/com/launchdarkly/sdk/server/DataStoreTestTypes.java b/src/test/java/com/launchdarkly/sdk/server/DataStoreTestTypes.java index 2ac22ab0f..e69eeb77d 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataStoreTestTypes.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataStoreTestTypes.java @@ -1,6 +1,5 @@ package com.launchdarkly.sdk.server; -import com.google.common.base.Objects; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.Maps; @@ -14,6 +13,7 @@ import java.util.AbstractMap; import java.util.HashMap; import java.util.Map; +import java.util.Objects; import static com.google.common.collect.Iterables.transform; import static com.launchdarkly.sdk.server.TestUtil.TEST_GSON_INSTANCE; @@ -88,8 +88,8 @@ public SerializedItemDescriptor toSerializedItemDescriptor() { public boolean equals(Object other) { if (other instanceof TestItem) { TestItem o = (TestItem)other; - return Objects.equal(name, o.name) && - Objects.equal(key, o.key) && + return Objects.equals(name, o.name) && + Objects.equals(key, o.key) && version == o.version; } return false; @@ -97,7 +97,7 @@ public boolean equals(Object other) { @Override public int hashCode() { - return Objects.hashCode(name, key, version); + return Objects.hash(name, key, version); } @Override diff --git a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java index d7e1292b7..ce466454f 100644 --- a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java @@ -1,6 +1,5 @@ package com.launchdarkly.sdk.server; -import com.google.common.util.concurrent.SettableFuture; import com.launchdarkly.eventsource.ConnectionErrorHandler; import com.launchdarkly.eventsource.EventHandler; import com.launchdarkly.eventsource.EventSource; @@ -22,6 +21,7 @@ import java.time.Duration; import java.util.Collections; import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.Future; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; @@ -528,12 +528,12 @@ public void indirectPatchWithFailedPollCausesStreamRestart() throws Exception { public void restartsStreamIfStoreNeedsRefresh() throws Exception { TestComponents.DataStoreWithStatusUpdates storeWithStatus = new TestComponents.DataStoreWithStatusUpdates(dataStore); - SettableFuture restarted = SettableFuture.create(); + CompletableFuture restarted = new CompletableFuture<>(); mockEventSource.start(); expectLastCall(); mockEventSource.restart(); expectLastCall().andAnswer(() -> { - restarted.set(null); + restarted.complete(null); return null; }); mockEventSource.close(); @@ -557,12 +557,12 @@ public void restartsStreamIfStoreNeedsRefresh() throws Exception { public void doesNotRestartStreamIfStoreHadOutageButDoesNotNeedRefresh() throws Exception { TestComponents.DataStoreWithStatusUpdates storeWithStatus = new TestComponents.DataStoreWithStatusUpdates(dataStore); - SettableFuture restarted = SettableFuture.create(); + CompletableFuture restarted = new CompletableFuture<>(); mockEventSource.start(); expectLastCall(); mockEventSource.restart(); expectLastCall().andAnswer(() -> { - restarted.set(null); + restarted.complete(null); return null; }); mockEventSource.close(); diff --git a/src/test/java/com/launchdarkly/sdk/server/TestComponents.java b/src/test/java/com/launchdarkly/sdk/server/TestComponents.java index 0300a2791..cace1de30 100644 --- a/src/test/java/com/launchdarkly/sdk/server/TestComponents.java +++ b/src/test/java/com/launchdarkly/sdk/server/TestComponents.java @@ -1,7 +1,5 @@ package com.launchdarkly.sdk.server; -import com.google.common.util.concurrent.Futures; -import com.google.common.util.concurrent.SettableFuture; import com.launchdarkly.eventsource.EventSource; import com.launchdarkly.sdk.UserAttribute; import com.launchdarkly.sdk.server.DataModel.FeatureFlag; @@ -26,6 +24,7 @@ import java.util.List; import java.util.Set; import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.Future; import java.util.concurrent.LinkedBlockingQueue; @@ -130,7 +129,7 @@ public void updateFlag(FeatureFlag flag) { private static class DataSourceThatNeverInitializes implements DataSource { public Future start() { - return SettableFuture.create(); + return new CompletableFuture<>(); } public boolean isInitialized() { @@ -152,7 +151,7 @@ private static class DataSourceWithData implements DataSource { public Future start() { dataStoreUpdates.init(data); - return Futures.immediateFuture(null); + return CompletableFuture.completedFuture(null); } public boolean isInitialized() { From ac0a86cf3c0df33144da21131ac2cbe0f31c6e18 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 28 Apr 2020 21:51:14 -0700 Subject: [PATCH 399/673] don't serialize -1 variation index in FeatureFlagsState --- .../com/launchdarkly/sdk/server/FeatureFlagsState.java | 3 ++- .../launchdarkly/sdk/server/FeatureFlagsStateTest.java | 10 ++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java b/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java index 0f0306af2..ec9551c3d 100644 --- a/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java +++ b/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java @@ -158,7 +158,8 @@ Builder addFlag(DataModel.FeatureFlag flag, Evaluator.EvalResult eval) { final boolean flagIsTracked = flag.isTrackEvents() || (flag.getDebugEventsUntilDate() != null && flag.getDebugEventsUntilDate() > System.currentTimeMillis()); final boolean wantDetails = !detailsOnlyForTrackedFlags || flagIsTracked; - FlagMetadata data = new FlagMetadata(eval.getVariationIndex(), + FlagMetadata data = new FlagMetadata( + eval.isDefault() ? null : eval.getVariationIndex(), (saveReasons && wantDetails) ? eval.getReason() : null, wantDetails ? flag.getVersion() : null, flag.isTrackEvents(), diff --git a/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java b/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java index e9e36bbff..d442141b6 100644 --- a/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java @@ -10,6 +10,8 @@ import org.junit.Test; +import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; +import static com.launchdarkly.sdk.EvaluationReason.ErrorKind.MALFORMED_FLAG; import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; @@ -97,17 +99,21 @@ private static FeatureFlagsState makeInstanceForSerialization() { DataModel.FeatureFlag flag1 = flagBuilder("key1").version(100).trackEvents(false).build(); Evaluator.EvalResult eval2 = new Evaluator.EvalResult(LDValue.of("value2"), 1, EvaluationReason.fallthrough()); DataModel.FeatureFlag flag2 = flagBuilder("key2").version(200).trackEvents(true).debugEventsUntilDate(1000L).build(); + Evaluator.EvalResult eval3 = new Evaluator.EvalResult(LDValue.of("default"), NO_VARIATION, EvaluationReason.error(MALFORMED_FLAG)); + DataModel.FeatureFlag flag3 = flagBuilder("key3").version(300).build(); return new FeatureFlagsState.Builder(FlagsStateOption.WITH_REASONS) - .addFlag(flag1, eval1).addFlag(flag2, eval2).build(); + .addFlag(flag1, eval1).addFlag(flag2, eval2).addFlag(flag3, eval3).build(); } private static String makeExpectedJsonSerialization() { - return "{\"key1\":\"value1\",\"key2\":\"value2\"," + + return "{\"key1\":\"value1\",\"key2\":\"value2\",\"key3\":\"default\"," + "\"$flagsState\":{" + "\"key1\":{" + "\"variation\":0,\"version\":100,\"reason\":{\"kind\":\"OFF\"}" + // note, "trackEvents: false" is omitted "},\"key2\":{" + "\"variation\":1,\"version\":200,\"reason\":{\"kind\":\"FALLTHROUGH\"},\"trackEvents\":true,\"debugEventsUntilDate\":1000" + + "},\"key3\":{" + + "\"version\":300,\"reason\":{\"kind\":\"ERROR\",\"errorKind\":\"MALFORMED_FLAG\"}" + "}" + "}," + "\"$valid\":true" + From 4e0991e5b7ecab217de9aca616bc026fc01f31a6 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 29 Apr 2020 12:10:50 -0700 Subject: [PATCH 400/673] use java-sdk-common 1.0.0-rc1 --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index ff4f47f04..d515c7b9f 100644 --- a/build.gradle +++ b/build.gradle @@ -71,7 +71,7 @@ ext.versions = [ "gson": "2.7", "guava": "28.2-jre", "jackson": "2.10.0", - "launchdarklyJavaSdkCommon": "1.0.0-SNAPSHOT", + "launchdarklyJavaSdkCommon": "1.0.0-rc1", "okhttpEventsource": "2.1.0-SNAPSHOT", "slf4j": "1.7.21", "snakeyaml": "1.19", From b1f70e9dc971137c0fd8377b85adc4ebe95525c2 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 29 Apr 2020 12:26:49 -0700 Subject: [PATCH 401/673] use okhttp-eventsource 2.1.0 --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index d515c7b9f..863ec9204 100644 --- a/build.gradle +++ b/build.gradle @@ -72,7 +72,7 @@ ext.versions = [ "guava": "28.2-jre", "jackson": "2.10.0", "launchdarklyJavaSdkCommon": "1.0.0-rc1", - "okhttpEventsource": "2.1.0-SNAPSHOT", + "okhttpEventsource": "2.1.0", "slf4j": "1.7.21", "snakeyaml": "1.19", "jedis": "2.9.0" From 8f3b157ea818ec3e4c40969440930c45f558fac6 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 29 Apr 2020 18:13:23 -0700 Subject: [PATCH 402/673] 5.0.0-rc1 --- CHANGELOG.md | 32 ++++++++++++++++++++++++++++++++ gradle.properties | 2 +- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de4ce922b..546d9decf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,38 @@ All notable changes to the LaunchDarkly Java SDK will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org). +## [5.0.0-rc1] - 2020-04-29 +This beta release is being made available for testing and user feedback, due to the large number of changes from Java SDK 4.x. Features are still subject to change in the final 5.0.0 release. Until the final release, the beta source code will be on the [5.x branch](https://github.com/launchdarkly/java-server-sdk/tree/5.x). Javadocs can be found on [javadoc.io](https://javadoc.io/doc/com.launchdarkly/launchdarkly-server-sdk/5.0.0-rc1/index.html). + +This is a major rewrite that introduces a cleaner API design, adds new features, and makes the SDK code easier to maintain and extend. See the [Java 4.x to 5.0 migration guide](https://docs.launchdarkly.com/sdk/server-side/java/migration-4-to-5) for an in-depth look at the changes in this version; the following is a summary. + +### Added: +- You can tell the SDK to notify you whenever a feature flag's configuration has changed in any way, using `FlagChangeListener` and `LDClient.registerFlagChangeListener()`. +- Or, you can tell the SDK to notify you only if the _value_ of a flag for some particular `LDUser` has changed, using `FlagValueChangeListener` and `Components.flagValueMonitoringListener()`. +- You can monitor the status of a persistent data store (for instance, to get caching statistics, or to be notified if the store's availability changes due to a database outage) with `LDClient.getDataStoreStatusProvider()`. +- The `UserAttribute` class provides a less error-prone way to refer to user attribute names in configuration, and can also be used to get an arbitrary attribute from a user. +- The `LDGson` and `LDJackson` classes allow SDK classes like LDUser to be easily converted to or from JSON using the popular Gson and Jackson frameworks. + +### Changed: +- The minimum supported Java version is now 8. +- Package names have changed: the main SDK classes are now in `com.launchdarkly.sdk` and `com.launchdarkly.sdk.server`. +- Many rarely-used classes and interfaces have been moved out of the main SDK package into `com.launchdarkly.sdk.server.integrations` and `com.launchdarkly.sdk.server.interfaces`. +- The type `java.time.Duration` is now used for configuration properties that represent an amount of time, instead of using a number of milliseconds or seconds. +- When using a persistent data store such as Redis, if there is a database outage, the SDK will wait until the end of the outage and then restart the stream connection to ensure that it has the latest data. Previously, it would try to restart the connection immediately and continue restarting if the database was still not available, causing unnecessary overhead. +- `EvaluationDetail.getVariationIndex()` now returns `int` instead of `Integer`. +- `EvaluationReason` is now a single concrete class rather than an abstract base class. +- The SDK no longer exposes a Gson dependency or any Gson types. +- Third-party libraries like Gson, Guava, and OkHttp that are used internally by the SDK have been updated to newer versions since Java 7 compatibility is no longer required. +- The component interfaces `FeatureStore` and UpdateProcessor have been renamed to `DataStore` and `DataSource`. The factory interfaces for these components now receive SDK configuration options in a different way that does not expose other components' configurations to each other. +- The `PersistentDataStore` interface for creating your own database integrations has been simplified by moving all of the serialization and caching logic into the main SDK code. + +### Removed: +- All types and methods that were deprecated as of Java SDK 4.13.0 have been removed. This includes many `LDConfig.Builder()` methods, which have been replaced by the modular configuration syntax that was already added in the 4.12.0 and 4.13.0 releases. See the [migration guide](https://docs.launchdarkly.com/sdk/server-side/java/migration-4-to-5) for details on how to update your configuration code if you were using the older syntax. +- The Redis integration is no longer built into the main SDK library (see below). +- The deprecated New Relic integration has been removed. + +If you want to test this release and you are using Consul, DynamoDB, or Redis as a persistent data store, you will also need to update to version 2.0.0-rc1 of the [Consul integration](https://github.com/launchdarkly/java-server-sdk-consul/tree/2.x), 3.0.0-rc1 of the [DynamoDB integration](https://github.com/launchdarkly/java-server-sdk-dynamodb/tree/3.x), or 1.0.0-rc1 of the [Redis integration](http://github.com/launchdarkly/java-server-sdk-redis) (previously the Redis integration was built in; now it is a separate module). + ## [4.13.0] - 2020-04-21 ### Added: - The new methods `Components.httpConfiguration()` and `LDConfig.Builder.http()`, and the new class `HttpConfigurationBuilder`, provide a subcomponent configuration model that groups together HTTP-related options such as `connectTimeoutMillis` and `proxyHost` - similar to how `Components.streamingDataSource()` works for streaming-related options or `Components.sendEvents()` for event-related options. The individual `LDConfig.Builder` methods for those options will still work, but are deprecated and will be removed in version 5.0. diff --git a/gradle.properties b/gradle.properties index 5d1025599..e4d9755c4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -version=4.13.0 +version=5.0.0-rc1 # 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= From 9812c3a2c53b9e237e9d0a825b071ac388c0a1dc Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 29 Apr 2020 18:19:04 -0700 Subject: [PATCH 403/673] prepare 5.0.0-rc1 release (#191) --- .circleci/config.yml | 13 - .ldrelease/config.yml | 2 - CHANGELOG.md | 32 + CONTRIBUTING.md | 8 +- README.md | 43 +- build.gradle | 139 +- .../checkstyle/checkstyle.xml | 4 + config/checkstyle/suppressions.xml | 12 + gradle.properties | 2 +- packaging-test/Makefile | 62 +- packaging-test/run-non-osgi-test.sh | 36 +- packaging-test/run-osgi-test.sh | 34 +- packaging-test/test-app/build.gradle | 6 +- .../com/newrelic/api/agent/NewRelic.java | 9 - .../java/com/newrelic/api/agent/NewRelic.java | 8 - .../testapp/JsonSerializationTestData.java | 44 + .../src/main/java/testapp/TestApp.java | 81 +- .../main/java/testapp/TestAppGsonTests.java | 42 + .../java/testapp/TestAppOsgiEntryPoint.java | 2 +- .../java/com/launchdarkly/client/Clause.java | 110 - .../com/launchdarkly/client/Components.java | 757 ----- .../launchdarkly/client/EvaluationDetail.java | 103 - .../client/EvaluationException.java | 11 - .../launchdarkly/client/EvaluationReason.java | 445 --- .../java/com/launchdarkly/client/Event.java | 181 -- .../EventProcessorFactoryWithDiagnostics.java | 6 - .../client/EventsConfiguration.java | 36 - .../com/launchdarkly/client/FeatureFlag.java | 257 -- .../client/FeatureFlagBuilder.java | 130 - .../launchdarkly/client/FeatureRequestor.java | 23 - .../com/launchdarkly/client/FeatureStore.java | 87 - .../client/FeatureStoreCacheConfig.java | 289 -- .../client/FeatureStoreClientWrapper.java | 54 - .../client/FeatureStoreDataSetSorter.java | 77 - .../client/FeatureStoreFactory.java | 14 - .../client/InMemoryFeatureStore.java | 143 - .../com/launchdarkly/client/LDConfig.java | 757 ----- .../launchdarkly/client/LDCountryCode.java | 2658 ----------------- .../java/com/launchdarkly/client/LDUser.java | 886 ------ .../client/NewRelicReflector.java | 45 - .../com/launchdarkly/client/OperandType.java | 42 - .../com/launchdarkly/client/Operator.java | 133 - .../launchdarkly/client/PollingProcessor.java | 88 - .../com/launchdarkly/client/Prerequisite.java | 33 - .../client/RedisFeatureStore.java | 89 - .../client/RedisFeatureStoreBuilder.java | 291 -- .../java/com/launchdarkly/client/Rule.java | 62 - .../java/com/launchdarkly/client/Segment.java | 136 - .../com/launchdarkly/client/SegmentRule.java | 53 - .../launchdarkly/client/StreamProcessor.java | 402 --- .../java/com/launchdarkly/client/Target.java | 24 - .../launchdarkly/client/TestFeatureStore.java | 133 - .../launchdarkly/client/UpdateProcessor.java | 55 - .../client/UpdateProcessorFactory.java | 17 - ...UpdateProcessorFactoryWithDiagnostics.java | 6 - .../launchdarkly/client/UserAttribute.java | 64 - .../client/VariationOrRollout.java | 127 - .../launchdarkly/client/VersionedData.java | 23 - .../client/VersionedDataKind.java | 168 -- .../client/files/FileComponents.java | 18 - .../client/files/FileDataSourceFactory.java | 75 - .../client/files/package-info.java | 4 - .../client/integrations/CacheMonitor.java | 151 - .../client/integrations/Redis.java | 35 - .../integrations/RedisDataStoreBuilder.java | 185 -- .../integrations/RedisDataStoreImpl.java | 196 -- .../client/integrations/package-info.java | 13 - .../PersistentDataStoreFactory.java | 26 - .../com/launchdarkly/client/package-info.java | 8 - .../client/utils/CachingStoreWrapper.java | 391 --- .../client/utils/FeatureStoreCore.java | 86 - .../client/utils/FeatureStoreHelpers.java | 61 - .../client/utils/package-info.java | 4 - .../client/value/ArrayBuilder.java | 86 - .../launchdarkly/client/value/LDValue.java | 672 ----- .../client/value/LDValueArray.java | 64 - .../client/value/LDValueBool.java | 50 - .../client/value/LDValueJsonElement.java | 206 -- .../client/value/LDValueNull.java | 35 - .../client/value/LDValueNumber.java | 75 - .../client/value/LDValueObject.java | 69 - .../client/value/LDValueString.java | 46 - .../client/value/LDValueType.java | 34 - .../client/value/LDValueTypeAdapter.java | 53 - .../client/value/ObjectBuilder.java | 104 - .../client/value/package-info.java | 4 - .../sdk/json/SdkSerializationExtensions.java | 14 + .../sdk/server/ClientContextImpl.java | 64 + .../launchdarkly/sdk/server/Components.java | 555 ++++ .../launchdarkly/sdk/server/DataModel.java | 502 ++++ .../sdk/server/DataModelDependencies.java | 250 ++ .../server/DataStoreStatusProviderImpl.java | 36 + .../sdk/server/DataStoreUpdatesImpl.java | 153 + .../server}/DefaultEventProcessor.java | 116 +- .../server}/DefaultFeatureRequestor.java | 55 +- .../server}/DiagnosticAccumulator.java | 2 +- .../server}/DiagnosticEvent.java | 22 +- .../{client => sdk/server}/DiagnosticId.java | 2 +- .../launchdarkly/sdk/server/Evaluator.java | 326 ++ .../sdk/server/EvaluatorBucketing.java | 67 + .../sdk/server/EvaluatorOperators.java | 143 + .../{client => sdk/server}/EventFactory.java | 84 +- .../server}/EventOutputFormatter.java | 57 +- .../server}/EventSummarizer.java | 23 +- .../sdk/server/EventUserSerialization.java | 111 + .../sdk/server/EventsConfiguration.java | 38 + .../server}/FeatureFlagsState.java | 72 +- .../sdk/server/FeatureRequestor.java | 53 + .../sdk/server/FlagChangeEventPublisher.java | 58 + .../server/FlagValueMonitoringListener.java | 42 + .../server}/FlagsStateOption.java | 6 +- .../server}/HttpConfigurationImpl.java | 27 +- .../server}/HttpErrorException.java | 2 +- .../sdk/server/InMemoryDataStore.java | 117 + .../{client => sdk/server}/JsonHelpers.java | 17 +- .../{client => sdk/server}/LDClient.java | 322 +- .../server}/LDClientInterface.java | 138 +- .../com/launchdarkly/sdk/server/LDConfig.java | 206 ++ .../sdk/server/PollingProcessor.java | 88 + .../server}/SemanticVersion.java | 2 +- .../server}/SimpleLRUCache.java | 2 +- .../sdk/server/StreamProcessor.java | 481 +++ .../{client => sdk/server}/Util.java | 43 +- .../integrations/EventProcessorBuilder.java | 111 +- .../server}/integrations/FileData.java | 8 +- .../integrations/FileDataSourceBuilder.java | 18 +- .../integrations/FileDataSourceImpl.java | 83 +- .../integrations/FileDataSourceParsing.java | 57 +- .../HttpConfigurationBuilder.java | 45 +- .../PersistentDataStoreBuilder.java | 92 +- .../PersistentDataStoreStatusManager.java | 138 + .../PersistentDataStoreWrapper.java | 479 +++ .../PollingDataSourceBuilder.java | 34 +- .../StreamingDataSourceBuilder.java | 28 +- .../sdk/server/integrations/package-info.java | 11 + .../sdk/server/interfaces/ClientContext.java | 31 + .../sdk/server/interfaces/DataSource.java | 30 + .../server/interfaces/DataSourceFactory.java | 19 + .../sdk/server/interfaces/DataStore.java | 81 + .../server/interfaces/DataStoreFactory.java | 18 + .../interfaces/DataStoreStatusProvider.java | 230 ++ .../sdk/server/interfaces/DataStoreTypes.java | 296 ++ .../server/interfaces/DataStoreUpdates.java | 55 + .../interfaces/DiagnosticDescription.java | 12 +- .../sdk/server/interfaces/Event.java | 248 ++ .../server/interfaces}/EventProcessor.java | 24 +- .../interfaces}/EventProcessorFactory.java | 10 +- .../server/interfaces/FlagChangeEvent.java | 38 + .../server/interfaces/FlagChangeListener.java | 29 + .../interfaces/FlagValueChangeEvent.java | 64 + .../interfaces/FlagValueChangeListener.java | 42 + .../interfaces/HttpAuthentication.java | 2 +- .../server}/interfaces/HttpConfiguration.java | 15 +- .../interfaces/HttpConfigurationFactory.java | 6 +- .../interfaces/PersistentDataStore.java | 138 + .../PersistentDataStoreFactory.java | 23 + .../interfaces/SerializationException.java | 2 +- .../server}/interfaces/package-info.java | 2 +- .../launchdarkly/sdk/server/package-info.java | 10 + .../client/DataStoreTestTypes.java | 117 - .../DeprecatedRedisFeatureStoreTest.java | 80 - .../client/EvaluationReasonTest.java | 200 -- .../launchdarkly/client/FeatureFlagTest.java | 679 ----- .../client/FeatureFlagsStateTest.java | 119 - .../client/FeatureStoreCachingTest.java | 125 - .../client/FeatureStoreDatabaseTestBase.java | 238 -- .../client/FeatureStoreTestBase.java | 180 -- .../client/InMemoryFeatureStoreTest.java | 9 - .../LDClientExternalUpdatesOnlyTest.java | 109 - .../com/launchdarkly/client/LDClientTest.java | 475 --- .../com/launchdarkly/client/LDConfigTest.java | 218 -- .../com/launchdarkly/client/LDUserTest.java | 511 ---- .../client/OperatorParameterizedTest.java | 132 - .../com/launchdarkly/client/OperatorTest.java | 19 - .../com/launchdarkly/client/RuleBuilder.java | 44 - .../com/launchdarkly/client/SegmentTest.java | 142 - .../com/launchdarkly/client/TestUtil.java | 317 -- .../com/launchdarkly/client/UtilTest.java | 109 - .../ClientWithFileDataSourceTest.java | 47 - .../PersistentDataStoreTestBase.java | 329 -- .../RedisDataStoreBuilderTest.java | 81 - .../integrations/RedisDataStoreImplTest.java | 51 - .../client/utils/CachingStoreWrapperTest.java | 599 ---- .../client/value/LDValueTest.java | 529 ---- .../server/DataModelSerializationTest.java | 236 ++ .../sdk/server/DataStoreTestBase.java | 168 ++ .../sdk/server/DataStoreTestTypes.java | 181 ++ .../sdk/server/DataStoreUpdatesImplTest.java | 373 +++ .../server}/DefaultEventProcessorTest.java | 231 +- .../server}/DiagnosticAccumulatorTest.java | 8 +- .../server}/DiagnosticEventTest.java | 120 +- .../server}/DiagnosticIdTest.java | 5 +- .../server}/DiagnosticSdkTest.java | 7 +- .../server/EvaluatorBucketingTest.java} | 30 +- .../sdk/server/EvaluatorClauseTest.java | 135 + .../EvaluatorOperatorsParameterizedTest.java | 134 + .../sdk/server/EvaluatorOperatorsTest.java | 21 + .../sdk/server/EvaluatorRuleTest.java | 101 + .../sdk/server/EvaluatorSegmentMatchTest.java | 119 + .../sdk/server/EvaluatorTest.java | 365 +++ .../sdk/server/EvaluatorTestUtil.java | 105 + .../server}/EventOutputTest.java | 64 +- .../server}/EventSummarizerTest.java | 23 +- .../server/EventUserSerializationTest.java | 148 + .../sdk/server/FeatureFlagsStateTest.java | 141 + .../server}/FeatureRequestorTest.java | 37 +- .../server}/FlagModelDeserializationTest.java | 8 +- .../sdk/server/InMemoryDataStoreTest.java | 13 + .../server}/LDClientEndToEndTest.java | 23 +- .../server}/LDClientEvaluationTest.java | 243 +- .../server}/LDClientEventTest.java | 308 +- .../LDClientExternalUpdatesOnlyTest.java | 63 + .../server}/LDClientOfflineTest.java | 50 +- .../launchdarkly/sdk/server/LDClientTest.java | 484 +++ .../launchdarkly/sdk/server/LDConfigTest.java | 67 + .../sdk/server/ModelBuilders.java | 347 +++ .../server}/PollingProcessorTest.java | 100 +- .../server}/SemanticVersionTest.java | 6 +- .../server}/SimpleLRUCacheTest.java | 5 +- .../server}/StreamProcessorTest.java | 355 ++- .../sdk/server/TestComponents.java | 281 ++ .../{client => sdk/server}/TestHttpUtil.java | 7 +- .../com/launchdarkly/sdk/server/TestUtil.java | 176 ++ .../com/launchdarkly/sdk/server/UtilTest.java | 40 + .../ClientWithFileDataSourceTest.java | 50 + .../server}/integrations/DataLoaderTest.java | 31 +- .../EventProcessorBuilderTest.java | 18 +- .../integrations/FileDataSourceTest.java | 73 +- .../integrations/FileDataSourceTestData.java | 31 +- .../integrations/FlagFileParserJsonTest.java | 4 +- .../integrations/FlagFileParserTestBase.java | 16 +- .../integrations/FlagFileParserYamlTest.java | 4 +- .../HttpConfigurationBuilderTest.java | 19 +- .../integrations/MockPersistentDataStore.java | 165 + .../PersistentDataStoreGenericTest.java | 76 + .../PersistentDataStoreTestBase.java | 353 +++ .../PersistentDataStoreWrapperTest.java | 685 +++++ 237 files changed, 12363 insertions(+), 19119 deletions(-) rename checkstyle.xml => config/checkstyle/checkstyle.xml (73%) create mode 100644 config/checkstyle/suppressions.xml delete mode 100644 packaging-test/test-app/src/main/java/com/launchdarkly/shaded/com/newrelic/api/agent/NewRelic.java delete mode 100644 packaging-test/test-app/src/main/java/com/newrelic/api/agent/NewRelic.java create mode 100644 packaging-test/test-app/src/main/java/testapp/JsonSerializationTestData.java create mode 100644 packaging-test/test-app/src/main/java/testapp/TestAppGsonTests.java delete mode 100644 src/main/java/com/launchdarkly/client/Clause.java delete mode 100644 src/main/java/com/launchdarkly/client/Components.java delete mode 100644 src/main/java/com/launchdarkly/client/EvaluationDetail.java delete mode 100644 src/main/java/com/launchdarkly/client/EvaluationException.java delete mode 100644 src/main/java/com/launchdarkly/client/EvaluationReason.java delete mode 100644 src/main/java/com/launchdarkly/client/Event.java delete mode 100644 src/main/java/com/launchdarkly/client/EventProcessorFactoryWithDiagnostics.java delete mode 100644 src/main/java/com/launchdarkly/client/EventsConfiguration.java delete mode 100644 src/main/java/com/launchdarkly/client/FeatureFlag.java delete mode 100644 src/main/java/com/launchdarkly/client/FeatureFlagBuilder.java delete mode 100644 src/main/java/com/launchdarkly/client/FeatureRequestor.java delete mode 100644 src/main/java/com/launchdarkly/client/FeatureStore.java delete mode 100644 src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java delete mode 100644 src/main/java/com/launchdarkly/client/FeatureStoreClientWrapper.java delete mode 100644 src/main/java/com/launchdarkly/client/FeatureStoreDataSetSorter.java delete mode 100644 src/main/java/com/launchdarkly/client/FeatureStoreFactory.java delete mode 100644 src/main/java/com/launchdarkly/client/InMemoryFeatureStore.java delete mode 100644 src/main/java/com/launchdarkly/client/LDConfig.java delete mode 100644 src/main/java/com/launchdarkly/client/LDCountryCode.java delete mode 100644 src/main/java/com/launchdarkly/client/LDUser.java delete mode 100644 src/main/java/com/launchdarkly/client/NewRelicReflector.java delete mode 100644 src/main/java/com/launchdarkly/client/OperandType.java delete mode 100644 src/main/java/com/launchdarkly/client/Operator.java delete mode 100644 src/main/java/com/launchdarkly/client/PollingProcessor.java delete mode 100644 src/main/java/com/launchdarkly/client/Prerequisite.java delete mode 100644 src/main/java/com/launchdarkly/client/RedisFeatureStore.java delete mode 100644 src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java delete mode 100644 src/main/java/com/launchdarkly/client/Rule.java delete mode 100644 src/main/java/com/launchdarkly/client/Segment.java delete mode 100644 src/main/java/com/launchdarkly/client/SegmentRule.java delete mode 100644 src/main/java/com/launchdarkly/client/StreamProcessor.java delete mode 100644 src/main/java/com/launchdarkly/client/Target.java delete mode 100644 src/main/java/com/launchdarkly/client/TestFeatureStore.java delete mode 100644 src/main/java/com/launchdarkly/client/UpdateProcessor.java delete mode 100644 src/main/java/com/launchdarkly/client/UpdateProcessorFactory.java delete mode 100644 src/main/java/com/launchdarkly/client/UpdateProcessorFactoryWithDiagnostics.java delete mode 100644 src/main/java/com/launchdarkly/client/UserAttribute.java delete mode 100644 src/main/java/com/launchdarkly/client/VariationOrRollout.java delete mode 100644 src/main/java/com/launchdarkly/client/VersionedData.java delete mode 100644 src/main/java/com/launchdarkly/client/VersionedDataKind.java delete mode 100644 src/main/java/com/launchdarkly/client/files/FileComponents.java delete mode 100644 src/main/java/com/launchdarkly/client/files/FileDataSourceFactory.java delete mode 100644 src/main/java/com/launchdarkly/client/files/package-info.java delete mode 100644 src/main/java/com/launchdarkly/client/integrations/CacheMonitor.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 delete mode 100644 src/main/java/com/launchdarkly/client/interfaces/PersistentDataStoreFactory.java delete mode 100644 src/main/java/com/launchdarkly/client/package-info.java delete mode 100644 src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java delete mode 100644 src/main/java/com/launchdarkly/client/utils/FeatureStoreCore.java delete mode 100644 src/main/java/com/launchdarkly/client/utils/FeatureStoreHelpers.java delete mode 100644 src/main/java/com/launchdarkly/client/utils/package-info.java delete mode 100644 src/main/java/com/launchdarkly/client/value/ArrayBuilder.java delete mode 100644 src/main/java/com/launchdarkly/client/value/LDValue.java delete mode 100644 src/main/java/com/launchdarkly/client/value/LDValueArray.java delete mode 100644 src/main/java/com/launchdarkly/client/value/LDValueBool.java delete mode 100644 src/main/java/com/launchdarkly/client/value/LDValueJsonElement.java delete mode 100644 src/main/java/com/launchdarkly/client/value/LDValueNull.java delete mode 100644 src/main/java/com/launchdarkly/client/value/LDValueNumber.java delete mode 100644 src/main/java/com/launchdarkly/client/value/LDValueObject.java delete mode 100644 src/main/java/com/launchdarkly/client/value/LDValueString.java delete mode 100644 src/main/java/com/launchdarkly/client/value/LDValueType.java delete mode 100644 src/main/java/com/launchdarkly/client/value/LDValueTypeAdapter.java delete mode 100644 src/main/java/com/launchdarkly/client/value/ObjectBuilder.java delete mode 100644 src/main/java/com/launchdarkly/client/value/package-info.java create mode 100644 src/main/java/com/launchdarkly/sdk/json/SdkSerializationExtensions.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/Components.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/DataModel.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/DataModelDependencies.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImpl.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/DataStoreUpdatesImpl.java rename src/main/java/com/launchdarkly/{client => sdk/server}/DefaultEventProcessor.java (87%) rename src/main/java/com/launchdarkly/{client => sdk/server}/DefaultFeatureRequestor.java (62%) rename src/main/java/com/launchdarkly/{client => sdk/server}/DiagnosticAccumulator.java (97%) rename src/main/java/com/launchdarkly/{client => sdk/server}/DiagnosticEvent.java (91%) rename src/main/java/com/launchdarkly/{client => sdk/server}/DiagnosticId.java (89%) create mode 100644 src/main/java/com/launchdarkly/sdk/server/Evaluator.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/EvaluatorBucketing.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/EvaluatorOperators.java rename src/main/java/com/launchdarkly/{client => sdk/server}/EventFactory.java (52%) rename src/main/java/com/launchdarkly/{client => sdk/server}/EventOutputFormatter.java (80%) rename src/main/java/com/launchdarkly/{client => sdk/server}/EventSummarizer.java (83%) create mode 100644 src/main/java/com/launchdarkly/sdk/server/EventUserSerialization.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/EventsConfiguration.java rename src/main/java/com/launchdarkly/{client => sdk/server}/FeatureFlagsState.java (71%) create mode 100644 src/main/java/com/launchdarkly/sdk/server/FeatureRequestor.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/FlagChangeEventPublisher.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/FlagValueMonitoringListener.java rename src/main/java/com/launchdarkly/{client => sdk/server}/FlagsStateOption.java (90%) rename src/main/java/com/launchdarkly/{client => sdk/server}/HttpConfigurationImpl.java (59%) rename src/main/java/com/launchdarkly/{client => sdk/server}/HttpErrorException.java (88%) create mode 100644 src/main/java/com/launchdarkly/sdk/server/InMemoryDataStore.java rename src/main/java/com/launchdarkly/{client => sdk/server}/JsonHelpers.java (86%) rename src/main/java/com/launchdarkly/{client => sdk/server}/LDClient.java (53%) rename src/main/java/com/launchdarkly/{client => sdk/server}/LDClientInterface.java (70%) create mode 100644 src/main/java/com/launchdarkly/sdk/server/LDConfig.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java rename src/main/java/com/launchdarkly/{client => sdk/server}/SemanticVersion.java (99%) rename src/main/java/com/launchdarkly/{client => sdk/server}/SimpleLRUCache.java (94%) create mode 100644 src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java rename src/main/java/com/launchdarkly/{client => sdk/server}/Util.java (73%) rename src/main/java/com/launchdarkly/{client => sdk/server}/integrations/EventProcessorBuilder.java (57%) rename src/main/java/com/launchdarkly/{client => sdk/server}/integrations/FileData.java (93%) rename src/main/java/com/launchdarkly/{client => sdk/server}/integrations/FileDataSourceBuilder.java (80%) rename src/main/java/com/launchdarkly/{client => sdk/server}/integrations/FileDataSourceImpl.java (69%) rename src/main/java/com/launchdarkly/{client => sdk/server}/integrations/FileDataSourceParsing.java (82%) rename src/main/java/com/launchdarkly/{client => sdk/server}/integrations/HttpConfigurationBuilder.java (70%) rename src/main/java/com/launchdarkly/{client => sdk/server}/integrations/PersistentDataStoreBuilder.java (70%) create mode 100644 src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreStatusManager.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapper.java rename src/main/java/com/launchdarkly/{client => sdk/server}/integrations/PollingDataSourceBuilder.java (64%) rename src/main/java/com/launchdarkly/{client => sdk/server}/integrations/StreamingDataSourceBuilder.java (72%) create mode 100644 src/main/java/com/launchdarkly/sdk/server/integrations/package-info.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/interfaces/ClientContext.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/interfaces/DataSource.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceFactory.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/interfaces/DataStore.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreFactory.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProvider.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypes.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreUpdates.java rename src/main/java/com/launchdarkly/{client => sdk/server}/interfaces/DiagnosticDescription.java (70%) create mode 100644 src/main/java/com/launchdarkly/sdk/server/interfaces/Event.java rename src/main/java/com/launchdarkly/{client => sdk/server/interfaces}/EventProcessor.java (51%) rename src/main/java/com/launchdarkly/{client => sdk/server/interfaces}/EventProcessorFactory.java (54%) create mode 100644 src/main/java/com/launchdarkly/sdk/server/interfaces/FlagChangeEvent.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/interfaces/FlagChangeListener.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeEvent.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeListener.java rename src/main/java/com/launchdarkly/{client => sdk/server}/interfaces/HttpAuthentication.java (96%) rename src/main/java/com/launchdarkly/{client => sdk/server}/interfaces/HttpConfiguration.java (82%) rename src/main/java/com/launchdarkly/{client => sdk/server}/interfaces/HttpConfigurationFactory.java (59%) create mode 100644 src/main/java/com/launchdarkly/sdk/server/interfaces/PersistentDataStore.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/interfaces/PersistentDataStoreFactory.java rename src/main/java/com/launchdarkly/{client => sdk/server}/interfaces/SerializationException.java (94%) rename src/main/java/com/launchdarkly/{client => sdk/server}/interfaces/package-info.java (83%) create mode 100644 src/main/java/com/launchdarkly/sdk/server/package-info.java delete mode 100644 src/test/java/com/launchdarkly/client/DataStoreTestTypes.java delete mode 100644 src/test/java/com/launchdarkly/client/DeprecatedRedisFeatureStoreTest.java delete mode 100644 src/test/java/com/launchdarkly/client/EvaluationReasonTest.java delete mode 100644 src/test/java/com/launchdarkly/client/FeatureFlagTest.java delete mode 100644 src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java delete mode 100644 src/test/java/com/launchdarkly/client/FeatureStoreCachingTest.java delete mode 100644 src/test/java/com/launchdarkly/client/FeatureStoreDatabaseTestBase.java delete mode 100644 src/test/java/com/launchdarkly/client/FeatureStoreTestBase.java delete mode 100644 src/test/java/com/launchdarkly/client/InMemoryFeatureStoreTest.java delete mode 100644 src/test/java/com/launchdarkly/client/LDClientExternalUpdatesOnlyTest.java delete mode 100644 src/test/java/com/launchdarkly/client/LDClientTest.java delete mode 100644 src/test/java/com/launchdarkly/client/LDConfigTest.java delete mode 100644 src/test/java/com/launchdarkly/client/LDUserTest.java delete mode 100644 src/test/java/com/launchdarkly/client/OperatorParameterizedTest.java delete mode 100644 src/test/java/com/launchdarkly/client/OperatorTest.java delete mode 100644 src/test/java/com/launchdarkly/client/RuleBuilder.java delete mode 100644 src/test/java/com/launchdarkly/client/SegmentTest.java delete mode 100644 src/test/java/com/launchdarkly/client/TestUtil.java delete mode 100644 src/test/java/com/launchdarkly/client/UtilTest.java delete mode 100644 src/test/java/com/launchdarkly/client/integrations/ClientWithFileDataSourceTest.java delete mode 100644 src/test/java/com/launchdarkly/client/integrations/PersistentDataStoreTestBase.java delete mode 100644 src/test/java/com/launchdarkly/client/integrations/RedisDataStoreBuilderTest.java delete mode 100644 src/test/java/com/launchdarkly/client/integrations/RedisDataStoreImplTest.java delete mode 100644 src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java delete mode 100644 src/test/java/com/launchdarkly/client/value/LDValueTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/DataStoreTestBase.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/DataStoreTestTypes.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/DataStoreUpdatesImplTest.java rename src/test/java/com/launchdarkly/{client => sdk/server}/DefaultEventProcessorTest.java (77%) rename src/test/java/com/launchdarkly/{client => sdk/server}/DiagnosticAccumulatorTest.java (91%) rename src/test/java/com/launchdarkly/{client => sdk/server}/DiagnosticEventTest.java (70%) rename src/test/java/com/launchdarkly/{client => sdk/server}/DiagnosticIdTest.java (92%) rename src/test/java/com/launchdarkly/{client => sdk/server}/DiagnosticSdkTest.java (92%) rename src/test/java/com/launchdarkly/{client/VariationOrRolloutTest.java => sdk/server/EvaluatorBucketingTest.java} (64%) create mode 100644 src/test/java/com/launchdarkly/sdk/server/EvaluatorClauseTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/EvaluatorOperatorsParameterizedTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/EvaluatorOperatorsTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/EvaluatorRuleTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/EvaluatorSegmentMatchTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/EvaluatorTestUtil.java rename src/test/java/com/launchdarkly/{client => sdk/server}/EventOutputTest.java (85%) rename src/test/java/com/launchdarkly/{client => sdk/server}/EventSummarizerTest.java (83%) create mode 100644 src/test/java/com/launchdarkly/sdk/server/EventUserSerializationTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java rename src/test/java/com/launchdarkly/{client => sdk/server}/FeatureRequestorTest.java (87%) rename src/test/java/com/launchdarkly/{client => sdk/server}/FlagModelDeserializationTest.java (80%) create mode 100644 src/test/java/com/launchdarkly/sdk/server/InMemoryDataStoreTest.java rename src/test/java/com/launchdarkly/{client => sdk/server}/LDClientEndToEndTest.java (85%) rename src/test/java/com/launchdarkly/{client => sdk/server}/LDClientEvaluationTest.java (64%) rename src/test/java/com/launchdarkly/{client => sdk/server}/LDClientEventTest.java (61%) create mode 100644 src/test/java/com/launchdarkly/sdk/server/LDClientExternalUpdatesOnlyTest.java rename src/test/java/com/launchdarkly/{client => sdk/server}/LDClientOfflineTest.java (56%) create mode 100644 src/test/java/com/launchdarkly/sdk/server/LDClientTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/LDConfigTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/ModelBuilders.java rename src/test/java/com/launchdarkly/{client => sdk/server}/PollingProcessorTest.java (59%) rename src/test/java/com/launchdarkly/{client => sdk/server}/SemanticVersionTest.java (98%) rename src/test/java/com/launchdarkly/{client => sdk/server}/SimpleLRUCacheTest.java (92%) rename src/test/java/com/launchdarkly/{client => sdk/server}/StreamProcessorTest.java (63%) create mode 100644 src/test/java/com/launchdarkly/sdk/server/TestComponents.java rename src/test/java/com/launchdarkly/{client => sdk/server}/TestHttpUtil.java (91%) create mode 100644 src/test/java/com/launchdarkly/sdk/server/TestUtil.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/UtilTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/integrations/ClientWithFileDataSourceTest.java rename src/test/java/com/launchdarkly/{client => sdk/server}/integrations/DataLoaderTest.java (74%) rename src/test/java/com/launchdarkly/{client => sdk/server}/integrations/EventProcessorBuilderTest.java (50%) rename src/test/java/com/launchdarkly/{client => sdk/server}/integrations/FileDataSourceTest.java (62%) rename src/test/java/com/launchdarkly/{client => sdk/server}/integrations/FileDataSourceTestData.java (54%) rename src/test/java/com/launchdarkly/{client => sdk/server}/integrations/FlagFileParserJsonTest.java (57%) rename src/test/java/com/launchdarkly/{client => sdk/server}/integrations/FlagFileParserTestBase.java (77%) rename src/test/java/com/launchdarkly/{client => sdk/server}/integrations/FlagFileParserYamlTest.java (57%) rename src/test/java/com/launchdarkly/{client => sdk/server}/integrations/HttpConfigurationBuilderTest.java (90%) create mode 100644 src/test/java/com/launchdarkly/sdk/server/integrations/MockPersistentDataStore.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreGenericTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreTestBase.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapperTest.java diff --git a/.circleci/config.yml b/.circleci/config.yml index 554291282..449edfca5 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -54,7 +54,6 @@ jobs: type: string docker: - image: <> - - image: redis steps: - checkout - run: cp gradle.properties.example gradle.properties @@ -85,18 +84,6 @@ jobs: $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: | diff --git a/.ldrelease/config.yml b/.ldrelease/config.yml index 09d702867..bdd13d5cc 100644 --- a/.ldrelease/config.yml +++ b/.ldrelease/config.yml @@ -10,8 +10,6 @@ publications: template: name: gradle - env: - LD_SKIP_DATABASE_TESTS: 1 documentation: githubPages: true diff --git a/CHANGELOG.md b/CHANGELOG.md index de4ce922b..546d9decf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,38 @@ All notable changes to the LaunchDarkly Java SDK will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org). +## [5.0.0-rc1] - 2020-04-29 +This beta release is being made available for testing and user feedback, due to the large number of changes from Java SDK 4.x. Features are still subject to change in the final 5.0.0 release. Until the final release, the beta source code will be on the [5.x branch](https://github.com/launchdarkly/java-server-sdk/tree/5.x). Javadocs can be found on [javadoc.io](https://javadoc.io/doc/com.launchdarkly/launchdarkly-server-sdk/5.0.0-rc1/index.html). + +This is a major rewrite that introduces a cleaner API design, adds new features, and makes the SDK code easier to maintain and extend. See the [Java 4.x to 5.0 migration guide](https://docs.launchdarkly.com/sdk/server-side/java/migration-4-to-5) for an in-depth look at the changes in this version; the following is a summary. + +### Added: +- You can tell the SDK to notify you whenever a feature flag's configuration has changed in any way, using `FlagChangeListener` and `LDClient.registerFlagChangeListener()`. +- Or, you can tell the SDK to notify you only if the _value_ of a flag for some particular `LDUser` has changed, using `FlagValueChangeListener` and `Components.flagValueMonitoringListener()`. +- You can monitor the status of a persistent data store (for instance, to get caching statistics, or to be notified if the store's availability changes due to a database outage) with `LDClient.getDataStoreStatusProvider()`. +- The `UserAttribute` class provides a less error-prone way to refer to user attribute names in configuration, and can also be used to get an arbitrary attribute from a user. +- The `LDGson` and `LDJackson` classes allow SDK classes like LDUser to be easily converted to or from JSON using the popular Gson and Jackson frameworks. + +### Changed: +- The minimum supported Java version is now 8. +- Package names have changed: the main SDK classes are now in `com.launchdarkly.sdk` and `com.launchdarkly.sdk.server`. +- Many rarely-used classes and interfaces have been moved out of the main SDK package into `com.launchdarkly.sdk.server.integrations` and `com.launchdarkly.sdk.server.interfaces`. +- The type `java.time.Duration` is now used for configuration properties that represent an amount of time, instead of using a number of milliseconds or seconds. +- When using a persistent data store such as Redis, if there is a database outage, the SDK will wait until the end of the outage and then restart the stream connection to ensure that it has the latest data. Previously, it would try to restart the connection immediately and continue restarting if the database was still not available, causing unnecessary overhead. +- `EvaluationDetail.getVariationIndex()` now returns `int` instead of `Integer`. +- `EvaluationReason` is now a single concrete class rather than an abstract base class. +- The SDK no longer exposes a Gson dependency or any Gson types. +- Third-party libraries like Gson, Guava, and OkHttp that are used internally by the SDK have been updated to newer versions since Java 7 compatibility is no longer required. +- The component interfaces `FeatureStore` and UpdateProcessor have been renamed to `DataStore` and `DataSource`. The factory interfaces for these components now receive SDK configuration options in a different way that does not expose other components' configurations to each other. +- The `PersistentDataStore` interface for creating your own database integrations has been simplified by moving all of the serialization and caching logic into the main SDK code. + +### Removed: +- All types and methods that were deprecated as of Java SDK 4.13.0 have been removed. This includes many `LDConfig.Builder()` methods, which have been replaced by the modular configuration syntax that was already added in the 4.12.0 and 4.13.0 releases. See the [migration guide](https://docs.launchdarkly.com/sdk/server-side/java/migration-4-to-5) for details on how to update your configuration code if you were using the older syntax. +- The Redis integration is no longer built into the main SDK library (see below). +- The deprecated New Relic integration has been removed. + +If you want to test this release and you are using Consul, DynamoDB, or Redis as a persistent data store, you will also need to update to version 2.0.0-rc1 of the [Consul integration](https://github.com/launchdarkly/java-server-sdk-consul/tree/2.x), 3.0.0-rc1 of the [DynamoDB integration](https://github.com/launchdarkly/java-server-sdk-dynamodb/tree/3.x), or 1.0.0-rc1 of the [Redis integration](http://github.com/launchdarkly/java-server-sdk-redis) (previously the Redis integration was built in; now it is a separate module). + ## [4.13.0] - 2020-04-21 ### Added: - The new methods `Components.httpConfiguration()` and `LDConfig.Builder.http()`, and the new class `HttpConfigurationBuilder`, provide a subcomponent configuration model that groups together HTTP-related options such as `connectTimeoutMillis` and `proxyHost` - similar to how `Components.streamingDataSource()` works for streaming-related options or `Components.sendEvents()` for event-related options. The individual `LDConfig.Builder` methods for those options will still work, but are deprecated and will be removed in version 5.0. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3e5c76bf0..229d7cad7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,8 +15,10 @@ We encourage pull requests and other contributions from the community. Before su ### Prerequisites -The SDK builds with [Gradle](https://gradle.org/) and should be built against Java 7. - +The SDK builds with [Gradle](https://gradle.org/) and should be built against Java 8. + +Many basic classes are implemented in the module `launchdarkly-java-sdk-common`, whose source code is in the [`launchdarkly/java-sdk-common`](https://github.com/launchdarkly/java-sdk-common) repository; this is so the common code can be shared with the LaunchDarkly Android SDK. By design, the LaunchDarkly Java SDK distribution does not expose a dependency on that module; instead, its classes and Javadoc content are embedded in the SDK jars. + ### Building To build the SDK without running any tests: @@ -40,5 +42,3 @@ To build the SDK and run all unit tests: ``` ./gradlew test ``` - -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. diff --git a/README.md b/README.md index adbf2eeb7..6075a7ab2 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,31 @@ -LaunchDarkly Server-side 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-java-server-sdk.svg)](http://javadoc.io/doc/com.launchdarkly/launchdarkly-java-server-sdk) -LaunchDarkly overview -------------------------- +## 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 ------------------------ +## Supported Java versions -This version of the LaunchDarkly SDK works with Java 7 and above. +This version of the LaunchDarkly SDK works with Java 8 and above. -Distributions -------------- +## Distributions Three variants of the SDK jar are published to Maven: -* 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 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 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 SLF4J is also bundled, without shading (and is exported in OSGi). * The "thin" jar - add `thin` in Maven, or `:thin` in Gradle. This contains _only_ the SDK classes. -Getting started ------------ +## Getting started 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 -------- +## 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. @@ -38,35 +33,29 @@ 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 ---------------------------- +## 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 ------------------- +## 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. 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 ----------- +## Learn more 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 -------- +## 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 ------------- +## Contributing 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 ------------ +## About LaunchDarkly * LaunchDarkly is a continuous delivery platform that provides feature flags as a service and allows developers to iterate quickly and safely. We allow you to easily flag your features and manage them from the LaunchDarkly dashboard. With LaunchDarkly, you can: * Roll out a new feature to a subset of your users (like a group of users who opt-in to a beta tester group), gathering feedback and bug reports from real-world use cases. diff --git a/build.gradle b/build.gradle index 4e40ec5bd..863ec9204 100644 --- a/build.gradle +++ b/build.gradle @@ -1,3 +1,6 @@ +import java.nio.file.Files +import java.nio.file.FileSystems +import java.nio.file.StandardCopyOption buildscript { repositories { @@ -30,6 +33,15 @@ repositories { mavenCentral() } +configurations { + commonClasses { + transitive false + } + commonDoc { + transitive false + } +} + configurations.all { // check for updates every build for dependencies with: 'changing: true' resolutionStrategy.cacheChangingModulesFor 0, 'seconds' @@ -39,13 +51,14 @@ allprojects { group = 'com.launchdarkly' version = "${version}" archivesBaseName = 'launchdarkly-java-server-sdk' - sourceCompatibility = 1.7 - targetCompatibility = 1.7 + sourceCompatibility = 1.8 + targetCompatibility = 1.8 } ext { - sdkBasePackage = "com.launchdarkly.client" - + sdkBasePackage = "com.launchdarkly.sdk" + 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. systemPackageImports = [ "javax.net", "javax.net.ssl" ] @@ -56,9 +69,10 @@ ext.libraries = [:] ext.versions = [ "commonsCodec": "1.10", "gson": "2.7", - "guava": "19.0", - "jodaTime": "2.9.3", - "okhttpEventsource": "1.11.0", + "guava": "28.2-jre", + "jackson": "2.10.0", + "launchdarklyJavaSdkCommon": "1.0.0-rc1", + "okhttpEventsource": "2.1.0", "slf4j": "1.7.21", "snakeyaml": "1.19", "jedis": "2.9.0" @@ -68,29 +82,31 @@ ext.versions = [ // will be completely omitted from the "thin" jar, and will be embedded with shaded names // in the other two SDK jars. libraries.internal = [ + "com.launchdarkly:launchdarkly-java-sdk-common:${versions.launchdarklyJavaSdkCommon}", "commons-codec:commons-codec:${versions.commonsCodec}", + "com.google.code.gson:gson:${versions.gson}", "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:${versions.gson}", "org.slf4j:slf4j-api:${versions.slf4j}" ] // 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", + // Note that the okhttp3 test deps must be kept in sync with the okhttp version used in okhttp-eventsource + "com.squareup.okhttp3:mockwebserver:4.5.0", + "com.squareup.okhttp3:okhttp-tls:4.5.0", "org.hamcrest:hamcrest-all:1.3", "org.easymock:easymock:3.4", "junit:junit:4.12", - "ch.qos.logback:logback-classic:1.1.7" + "ch.qos.logback:logback-classic:1.1.7", + "com.fasterxml.jackson.core:jackson-core:${versions.jackson}", + "com.fasterxml.jackson.core:jackson-databind:${versions.jackson}" ] dependencies { @@ -98,6 +114,9 @@ dependencies { api libraries.external testImplementation libraries.test, libraries.internal, libraries.external + commonClasses "com.launchdarkly:launchdarkly-java-sdk-common:${versions.launchdarklyJavaSdkCommon}" + commonDoc "com.launchdarkly:launchdarkly-java-sdk-common:${versions.launchdarklyJavaSdkCommon}:sources" + // 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 @@ -111,7 +130,8 @@ configurations { } checkstyle { - configFile file("${project.rootDir}/checkstyle.xml") + configFile file("${project.rootDir}/config/checkstyle/checkstyle.xml") + configDir file("${project.rootDir}/config/checkstyle") } jar { @@ -119,6 +139,8 @@ jar { // but is opt-in since users will have to specify it. classifier = 'thin' + from configurations.commonClasses.collect { zipTree(it) } + // 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 { @@ -135,7 +157,6 @@ shadowJar { 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 @@ -146,6 +167,10 @@ shadowJar { // objects with detailed information about the resolved dependencies. addOsgiManifest(project.tasks.shadowJar, [ project.configurations.shadow ], []) } + + doLast { + replaceUnshadedClasses(project.tasks.shadowJar) + } } // This builds the "-all"/"fat" jar, which is the same as the default uberjar except that @@ -167,6 +192,10 @@ task shadowJarAll(type: com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJ // higher version if one is provided by another bundle. addOsgiManifest(project.tasks.shadowJarAll, [ project.configurations.shadow ], [ project.configurations.shadow ]) } + + doLast { + replaceUnshadedClasses(project.tasks.shadowJarAll) + } } task testJar(type: Jar, dependsOn: testClasses) { @@ -185,24 +214,33 @@ task javadocJar(type: Jar, dependsOn: javadoc) { from javadoc.destinationDir } +javadoc { + source configurations.commonDoc.collect { zipTree(it) } + include '**/*.java' + + // Use test classpath so Javadoc won't complain about java-sdk-common classes that internally + // reference stuff we don't use directly, like Jackson + classpath = sourceSets.test.compileClasspath +} // 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 + 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') - } + options.addStringOption('Xwerror', '-quiet') + } } // 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 = [] + // base package classes come from launchdarkly-java-sdk-common + def names = [ "com.launchdarkly.sdk", "com.launchdarkly.sdk.json" ] project.convention.getPlugin(JavaPluginConvention).sourceSets.main.output.each { baseDir -> if (baseDir.getPath().contains("classes" + File.separator + "java" + File.separator + "main")) { baseDir.eachFileRecurse { f -> @@ -222,8 +260,8 @@ def getAllSdkPackages() { 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() + it.name.contains("/") ? it.name.substring(0, it.name.lastIndexOf("/")).replace("/", ".") : "" + }.findAll { !it.equals("") }.unique() } } @@ -235,24 +273,63 @@ def getPackagesInDependencyJar(jarFile) { // 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)} + configurations.shadow.collectMany { getPackagesInDependencyJar(it) } def topLevelPackages = configurations.internal.collectMany { getPackagesInDependencyJar(it).collect { it.contains(".") ? it.substring(0, it.indexOf(".")) : it } }. unique() topLevelPackages.forEach { top -> - jarTask.relocate(top, "com.launchdarkly.shaded." + top) { + // This special-casing for javax.annotation is hacky, but the issue is that Guava pulls in a jsr305 + // implementation jar that provides javax.annotation, and we *do* want to embed and shade those classes + // so that Guava won't fail to find them and they won't conflict with anyone else's version - but we do + // *not* want references to any classes from javax.net, javax.security, etc. to be munged. + def packageToRelocate = (top == "javax") ? "javax.annotation" : top + jarTask.relocate(packageToRelocate, "com.launchdarkly.shaded." + packageToRelocate) { excludePackages.forEach { exclude(it + ".*") } } } } +def replaceUnshadedClasses(jarTask) { + // The LDGson class is a special case where we do *not* want any of the Gson class names it uses to be + // modified by shading (because its purpose is to interoperate with a non-shaded instance of Gson). + // Shadow doesn't seem to provide a way to say "make this class file immune from the changes that result + // from shading *other* classes", so the workaround is to simply recopy the original class file. Note that + // we use a wildcard to make sure we also get any inner classes. + def protectedClassFilePattern = 'com/launchdarkly/sdk/json/LDGson*.class' + jarTask.exclude protectedClassFilePattern + def protectedClassFiles = configurations.commonClasses.collectMany { + zipTree(it).matching { + include protectedClassFilePattern + } getFiles() + } + def jarPath = jarTask.archiveFile.asFile.get().toPath() + FileSystems.newFileSystem(jarPath, null).withCloseable { fs -> + protectedClassFiles.forEach { classFile -> + def classSubpath = classFile.path.substring(classFile.path.indexOf("com/launchdarkly")) + Files.copy(classFile.toPath(), fs.getPath(classSubpath), StandardCopyOption.REPLACE_EXISTING) + } + } +} + +def getFileFromClasspath(config, filePath) { + def files = config.collectMany { + zipTree(it) matching { + include filePath + } getFiles() + } + if (files.size != 1) { + throw new RuntimeException("could not find " + filePath); + } + return files[0] +} + def addOsgiManifest(jarTask, List importConfigs, List exportConfigs) { jarTask.manifest { attributes( "Implementation-Version": version, - "Bundle-SymbolicName": "com.launchdarkly.client", + "Bundle-SymbolicName": "com.launchdarkly.sdk", "Bundle-Version": version, "Bundle-Name": "LaunchDarkly SDK", "Bundle-ManifestVersion": "2", @@ -262,10 +339,14 @@ def addOsgiManifest(jarTask, List importConfigs, List bundleImport(p, a.moduleVersion.id.version, nextMajorVersion(a.moduleVersion.id.version)) }) + systemPackageImports + imports += "com.google.gson;resolution:=optional" + imports += "com.google.gson.reflect;resolution:=optional" + imports += "com.google.gson.stream;resolution:=optional" attributes("Import-Package": imports.join(",")) // Similarly, we're adding package exports for every package in whatever libraries we're diff --git a/checkstyle.xml b/config/checkstyle/checkstyle.xml similarity index 73% rename from checkstyle.xml rename to config/checkstyle/checkstyle.xml index 0b201f9c0..a1d367afe 100644 --- a/checkstyle.xml +++ b/config/checkstyle/checkstyle.xml @@ -3,6 +3,10 @@ "-//Checkstyle//DTD Checkstyle Configuration 1.3//EN" "https://checkstyle.org/dtds/configuration_1_3.dtd"> + + + + diff --git a/config/checkstyle/suppressions.xml b/config/checkstyle/suppressions.xml new file mode 100644 index 000000000..1959e98eb --- /dev/null +++ b/config/checkstyle/suppressions.xml @@ -0,0 +1,12 @@ + + + + + + + diff --git a/gradle.properties b/gradle.properties index 5d1025599..e4d9755c4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -version=4.13.0 +version=5.0.0-rc1 # 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 ffc0bbe30..7548fda60 100644 --- a/packaging-test/Makefile +++ b/packaging-test/Makefile @@ -23,6 +23,7 @@ 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 +TEST_APP_SOURCES=$(shell find $(BASE_DIR)/test-app -name *.java) $(BASE_DIR)/test-app/build.gradle # Felix OSGi container export FELIX_DIR=$(TEMP_DIR)/felix @@ -34,26 +35,25 @@ export TEMP_BUNDLE_DIR=$(FELIX_DIR)/app-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) \ + $(shell ls $(TEMP_DIR)/dependencies-external/gson*.jar 2>/dev/null) RUN_JARS_test-default-jar=$(TEST_APP_JAR) $(SDK_DEFAULT_JAR) \ - $(shell ls $(TEMP_DIR)/dependencies-external/*.jar) + $(shell ls $(TEMP_DIR)/dependencies-external/*.jar 2>/dev/null) 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) - -# The test-app displays this message on success -export SUCCESS_MESSAGE=@@@ successfully created LD client @@@ + $(shell ls $(TEMP_DIR)/dependencies-internal/*.jar 2>/dev/null) \ + $(shell ls $(TEMP_DIR)/dependencies-external/*.jar 2>/dev/null) 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 +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) verify_sdk_classes= \ - $(call classes_should_contain,'^com/launchdarkly/client/[^/]*$$',com.launchdarkly.client) && \ + $(call classes_should_contain,com/launchdarkly/sdk,com.launchdarkly.sdk) && \ + $(call classes_should_contain,com/launchdarkly/sdk/json,com.launchdarkly.sdk.json) && \ $(foreach subpkg,$(sdk_subpackage_names), \ - $(call classes_should_contain,'^com/launchdarkly/client/$(subpkg)/',com.launchdarkly.client.$(subpkg)) && ) true + $(call classes_should_contain,com/launchdarkly/sdk/$(subpkg),com.launchdarkly.sdk.$(subst /,.,$(subpkg))) && ) true sdk_subpackage_names= \ - $(shell ls -d $(PROJECT_DIR)/src/main/java/com/launchdarkly/client/*/ | sed -e 's@^.*/\([^/]*\)/@\1@') + $(shell cd $(PROJECT_DIR)/src/main/java/com/launchdarkly/sdk && find . ! -path . -type d | sed -e 's@^\./@@') caption=echo "" && echo "$(1)" @@ -61,43 +61,42 @@ all: test-all-jar test-default-jar test-thin-jar clean: rm -rf $(TEMP_DIR)/* + rm -rf test-app/build # 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: $$@-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. - @if [ "$@" != "test-thin-jar" ]; then \ - ./run-osgi-test.sh $(RUN_JARS_$@); \ - fi + @./run-non-osgi-test.sh $(RUN_JARS_$@) + @./run-osgi-test.sh $(RUN_JARS_$@) 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)) - @$(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) + @$(call classes_should_contain,org/slf4j,unshaded SLF4j) + @$(call classes_should_not_contain,com/launchdarkly/shaded/com/launchdarkly/sdk,shaded SDK classes) + @$(call classes_should_contain,com/launchdarkly/shaded/com/google/gson,shaded Gson) + @$(call classes_should_not_contain,com/google/gson,unshaded Gson) + @$(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) - @$(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)) + @$(call classes_should_not_contain,com/launchdarkly/shaded/com/launchdarkly/sdk,shaded SDK classes) + @$(call classes_should_contain,com/launchdarkly/shaded/com/google/gson,shaded Gson) + @$(call classes_should_not_contain,com/launchdarkly/shaded/org/slf4j,shaded SLF4j) + @$(call classes_should_not_contain,com/google/gson,unshaded Gson) + @$(call classes_should_not_contain,org/slf4j,unshaded SLF4j) 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) + @echo " should not contain anything other than SDK classes" + @! grep -v "^com/launchdarkly/sdk" $(TEMP_OUTPUT) $(SDK_DEFAULT_JAR): cd .. && ./gradlew shadowJar @@ -108,7 +107,7 @@ $(SDK_ALL_JAR): $(SDK_THIN_JAR): cd .. && ./gradlew jar -$(TEST_APP_JAR): $(SDK_THIN_JAR) | $(TEMP_DIR) +$(TEST_APP_JAR): $(SDK_THIN_JAR) $(TEST_APP_SOURCES) | $(TEMP_DIR) cd test-app && ../../gradlew jar cp $(BASE_DIR)/test-app/build/libs/test-app-*.jar $@ @@ -120,12 +119,13 @@ $(TEMP_DIR)/dependencies-all: | $(TEMP_DIR) $(TEMP_DIR)/dependencies-external: $(TEMP_DIR)/dependencies-all [ -d $@ ] || mkdir -p $@ - cp $(TEMP_DIR)/dependencies-all/gson*.jar $(TEMP_DIR)/dependencies-all/slf4j*.jar $@ + cp $(TEMP_DIR)/dependencies-all/slf4j*.jar $@ + cp $(TEMP_DIR)/dependencies-all/gson*.jar $@ $(TEMP_DIR)/dependencies-internal: $(TEMP_DIR)/dependencies-all [ -d $@ ] || mkdir -p $@ cp $(TEMP_DIR)/dependencies-all/*.jar $@ - rm $@/gson*.jar $@/slf4j*.jar + rm $@/slf4j*.jar $(FELIX_JAR): $(FELIX_DIR) diff --git a/packaging-test/run-non-osgi-test.sh b/packaging-test/run-non-osgi-test.sh index dcf9c24db..49b8d953d 100755 --- a/packaging-test/run-non-osgi-test.sh +++ b/packaging-test/run-non-osgi-test.sh @@ -1,6 +1,36 @@ #!/bin/bash +function run_test() { + rm -f ${TEMP_OUTPUT} + touch ${TEMP_OUTPUT} + classpath=$(echo "$@" | sed -e 's/ /:/g') + java -classpath "$classpath" testapp.TestApp | tee ${TEMP_OUTPUT} + grep "TestApp: PASS" ${TEMP_OUTPUT} >/dev/null +} + +echo "" +echo " non-OSGi runtime test - with Gson" +run_test $@ +grep "LDGson tests OK" ${TEMP_OUTPUT} >/dev/null || (echo "FAIL: should have run LDGson tests but did not" && exit 1) + +# It does not make sense to test the "thin" jar without Gson. The SDK uses Gson internally +# and can't work without it; in the default jar and the "all" jar, it has its own embedded +# copy of Gson, but the "thin" jar does not include any third-party dependencies so you must +# provide all of them including Gson. +thin_sdk_regex=".*launchdarkly-java-server-sdk-[^ ]*-thin\\.jar" +if [[ "$@" =~ $thin_sdk_regex ]]; then + exit 0 +fi + 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 +echo " non-OSGi runtime test - without Gson" +deps_except_json="" +json_jar_regex=".*gson.*" +for dep in $@; do + if [[ ! "$dep" =~ $json_jar_regex ]]; then + deps_except_json="$deps_except_json $dep" + fi +done +run_test $deps_except_json +grep "skipping LDGson tests" ${TEMP_OUTPUT} >/dev/null || \ + (echo "FAIL: should have skipped LDGson tests but did not; test setup was incorrect" && exit 1) diff --git a/packaging-test/run-osgi-test.sh b/packaging-test/run-osgi-test.sh index 62439fedf..df5f69739 100755 --- a/packaging-test/run-osgi-test.sh +++ b/packaging-test/run-osgi-test.sh @@ -1,14 +1,32 @@ #!/bin/bash -echo "" -echo " OSGi runtime test" +# We can't test the "thin" jar in OSGi, because some of our third-party dependencies +# aren't available as OSGi bundles. That isn't a plausible use case anyway. +thin_sdk_regex=".*launchdarkly-java-server-sdk-[^ ]*-thin\\.jar" +if [[ "$@" =~ $thin_sdk_regex ]]; then + exit 0 +fi + 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} +function run_test() { + 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 "TestApp: PASS" ${TEMP_OUTPUT} >/dev/null +} -grep "${SUCCESS_MESSAGE}" ${TEMP_OUTPUT} >/dev/null +echo "" +echo " OSGi runtime test - with Gson" +cp $@ ${FELIX_BASE_BUNDLE_DIR}/* ${TEMP_BUNDLE_DIR} +run_test +grep "LDGson tests OK" ${TEMP_OUTPUT} >/dev/null || (echo "FAIL: should have run LDGson tests but did not" && exit 1) + +echo "" +echo " OSGi runtime test - without Gson" +rm ${TEMP_BUNDLE_DIR}/*gson*.jar +run_test +grep "skipping LDGson tests" ${TEMP_OUTPUT} >/dev/null || \ + (echo "FAIL: should have skipped LDGson tests but did not; test setup was incorrect" && exit 1) diff --git a/packaging-test/test-app/build.gradle b/packaging-test/test-app/build.gradle index 59d3fd936..9673b74eb 100644 --- a/packaging-test/test-app/build.gradle +++ b/packaging-test/test-app/build.gradle @@ -36,7 +36,11 @@ dependencies { jar { bnd( - 'Bundle-Activator': 'testapp.TestAppOsgiEntryPoint' + 'Bundle-Activator': 'testapp.TestAppOsgiEntryPoint', + 'Import-Package': 'com.launchdarkly.sdk,com.launchdarkly.sdk.json' + + ',com.launchdarkly.sdk.server,org.slf4j' + + ',org.osgi.framework' + + ',com.google.gson;resolution:=optional' ) } 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 deleted file mode 100644 index 83bae9f3b..000000000 --- a/packaging-test/test-app/src/main/java/com/launchdarkly/shaded/com/newrelic/api/agent/NewRelic.java +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index 5b106c460..000000000 --- a/packaging-test/test-app/src/main/java/com/newrelic/api/agent/NewRelic.java +++ /dev/null @@ -1,8 +0,0 @@ -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/JsonSerializationTestData.java b/packaging-test/test-app/src/main/java/testapp/JsonSerializationTestData.java new file mode 100644 index 000000000..8bd8a9493 --- /dev/null +++ b/packaging-test/test-app/src/main/java/testapp/JsonSerializationTestData.java @@ -0,0 +1,44 @@ +package testapp; + +import com.launchdarkly.sdk.*; +import java.util.*; + +public class JsonSerializationTestData { + public static class TestItem { + final Object objectToSerialize; + final String expectedJson; + + private TestItem(Object objectToSerialize, String expectedJson) { + this.objectToSerialize = objectToSerialize; + this.expectedJson = expectedJson; + } + } + + public static TestItem[] TEST_ITEMS = new TestItem[] { + new TestItem( + LDValue.buildArray().add(1).add(2).build(), + "[1,2]" + ), + new TestItem( + Collections.singletonMap("value", LDValue.buildArray().add(1).add(2).build()), + "{\"value\":[1,2]}" + ), + new TestItem( + EvaluationReason.off(), + "{\"kind\":\"OFF\"}" + ), + new TestItem( + new LDUser.Builder("userkey").build(), + "{\"key\":\"userkey\"}" + ) + }; + + public static boolean assertJsonEquals(String expectedJson, String actualJson, Object objectToSerialize) { + if (!LDValue.parse(actualJson).equals(LDValue.parse(expectedJson))) { + TestApp.addError("JSON encoding of " + objectToSerialize.getClass() + " should have been " + + expectedJson + ", was " + actualJson, null); + return false; + } + return true; + } +} \ No newline at end of file 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 bfec1bfdb..034852cbe 100644 --- a/packaging-test/test-app/src/main/java/testapp/TestApp.java +++ b/packaging-test/test-app/src/main/java/testapp/TestApp.java @@ -1,37 +1,74 @@ package testapp; -import com.launchdarkly.client.*; -import com.launchdarkly.client.integrations.*; -import com.google.gson.*; +import com.launchdarkly.sdk.*; +import com.launchdarkly.sdk.json.*; +import com.launchdarkly.sdk.server.*; +import java.util.*; import org.slf4j.*; public class TestApp { - private static final Logger logger = LoggerFactory.getLogger(TestApp.class); + private static final Logger logger = LoggerFactory.getLogger(TestApp.class); // proves SLF4J API is on classpath + + private static List errors = new ArrayList<>(); 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); + try { + LDConfig config = new LDConfig.Builder() + .offline(true) + .build(); + LDClient client = new LDClient("fake-sdk-key", config); + log("client creation OK"); + } catch (RuntimeException e) { + addError("client creation failed", e); } - if (!RedisFeatureStoreBuilder.DEFAULT_URI.toString().equals("redis://localhost:6379")) { - System.out.println("*** error: RedisFeatureStoreBuilder.DEFAULT_URI is " + RedisFeatureStoreBuilder.DEFAULT_URI); - System.exit(1); + + try { + boolean jsonOk = true; + for (JsonSerializationTestData.TestItem item: JsonSerializationTestData.TEST_ITEMS) { + if (!(item instanceof JsonSerializable)) { + continue; // things without our marker interface, like a Map, can't be passed to JsonSerialization.serialize + } + String actualJson = JsonSerialization.serialize((JsonSerializable)item.objectToSerialize); + if (!JsonSerializationTestData.assertJsonEquals(item.expectedJson, actualJson, item.objectToSerialize)) { + jsonOk = false; + } + } + if (jsonOk) { + log("JsonSerialization tests OK"); + } + } catch (RuntimeException e) { + addError("unexpected error in JsonSerialization tests", e); } - LDConfig config = new LDConfig.Builder() - .offline(true) - .build(); - LDClient client = new LDClient("fake-sdk-key", config); + try { + Class.forName("testapp.TestAppGsonTests"); // see TestAppGsonTests for why we're loading it in this way + } catch (NoClassDefFoundError e) { + log("skipping LDGson tests because Gson is not in the classpath"); + } catch (RuntimeException e) { + addError("unexpected error in LDGson tests", e); + } - // 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"); + if (errors.isEmpty()) { + log("PASS"); + } else { + for (String err: errors) { + log("ERROR: " + err); + } + log("FAIL"); + System.exit(1); + } + } - // Also do a flag evaluation, to ensure that it calls NewRelicReflector.annotateTransaction() - client.boolVariation("flag-key", new LDUser("user-key"), false); + public static void addError(String message, Throwable e) { + if (e != null) { + errors.add(message + ": " + e); + e.printStackTrace(); + } else { + errors.add(message); + } + } - System.out.println("@@@ successfully created LD client @@@"); + public static void log(String message) { + System.out.println("TestApp: " + message); } } \ No newline at end of file diff --git a/packaging-test/test-app/src/main/java/testapp/TestAppGsonTests.java b/packaging-test/test-app/src/main/java/testapp/TestAppGsonTests.java new file mode 100644 index 000000000..1ea44e03c --- /dev/null +++ b/packaging-test/test-app/src/main/java/testapp/TestAppGsonTests.java @@ -0,0 +1,42 @@ +package testapp; + +import com.google.gson.*; +import com.launchdarkly.sdk.*; +import com.launchdarkly.sdk.json.*; + +// This code is in its own class that is loaded dynamically because some of our test scenarios +// involve running TestApp without having Gson in the classpath, to make sure the SDK does not +// *require* the presence of an external Gson even though it can interoperate with one. + +public class TestAppGsonTests { + // Use static block so simply loading this class causes the tests to execute + static { + // First try referencing Gson, so we fail right away if it's not on the classpath + Class c = Gson.class; + try { + runGsonTests(); + } catch (NoClassDefFoundError e) { + // If we've even gotten to this static block, then Gson itself *is* on the application's + // classpath, so this must be some other kind of classloading error that we do want to + // report. For instance, a NoClassDefFound error for Gson at this point, if we're in + // OSGi, would mean that the SDK bundle is unable to see the external Gson classes. + TestApp.addError("unexpected error in LDGson tests", e); + } + } + + public static void runGsonTests() { + Gson gson = new GsonBuilder().registerTypeAdapterFactory(LDGson.typeAdapters()).create(); + + boolean ok = true; + for (JsonSerializationTestData.TestItem item: JsonSerializationTestData.TEST_ITEMS) { + String actualJson = gson.toJson(item.objectToSerialize); + if (!JsonSerializationTestData.assertJsonEquals(item.expectedJson, actualJson, item.objectToSerialize)) { + ok = false; + } + } + + if (ok) { + TestApp.log("LDGson tests OK"); + } + } +} \ 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 index ed42ccb1a..65602cd29 100644 --- a/packaging-test/test-app/src/main/java/testapp/TestAppOsgiEntryPoint.java +++ b/packaging-test/test-app/src/main/java/testapp/TestAppOsgiEntryPoint.java @@ -5,7 +5,7 @@ public class TestAppOsgiEntryPoint implements BundleActivator { public void start(BundleContext context) throws Exception { - System.out.println("@@@ starting test bundle @@@"); + System.out.println("TestApp: starting test bundle"); TestApp.main(new String[0]); diff --git a/src/main/java/com/launchdarkly/client/Clause.java b/src/main/java/com/launchdarkly/client/Clause.java deleted file mode 100644 index 8efaefd0d..000000000 --- a/src/main/java/com/launchdarkly/client/Clause.java +++ /dev/null @@ -1,110 +0,0 @@ -package com.launchdarkly.client; - -import com.launchdarkly.client.value.LDValue; -import com.launchdarkly.client.value.LDValueType; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.Collection; -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 boolean negate; - - public Clause() { - } - - public Clause(String attribute, Operator op, List values, boolean negate) { - this.attribute = attribute; - this.op = op; - this.values = values; - this.negate = negate; - } - - 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()) { - return false; - } - - 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(value)) { - return maybeNegate(true); - } - } - return maybeNegate(false); - } else if (userValue.getType() != LDValueType.OBJECT) { - return maybeNegate(matchAny(userValue)); - } - logger.warn("Got unexpected user attribute type \"{}\" for user key \"{}\" and attribute \"{}\"", - userValue.getType(), user.getKey(), attribute); - return false; - } - - 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 (LDValue j: values) { - if (j.isString()) { - Segment segment = store.get(SEGMENTS, j.stringValue()); - if (segment != null) { - if (segment.matchesUser(user)) { - return maybeNegate(true); - } - } - } - } - return maybeNegate(false); - } - - return matchesUserNoSegments(user); - } - - private boolean matchAny(LDValue userValue) { - if (op != null) { - for (LDValue v : values) { - if (op.apply(userValue, v)) { - return true; - } - } - } - return false; - } - - private boolean maybeNegate(boolean b) { - if (negate) - return !b; - else - return b; - } -} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/Components.java b/src/main/java/com/launchdarkly/client/Components.java deleted file mode 100644 index f446eb8e6..000000000 --- a/src/main/java/com/launchdarkly/client/Components.java +++ /dev/null @@ -1,757 +0,0 @@ -package com.launchdarkly.client; - -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. - *

    - * 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 - */ -@SuppressWarnings("deprecation") -public abstract class Components { - private static final FeatureStoreFactory inMemoryFeatureStoreFactory = new InMemoryFeatureStoreFactory(); - private static final EventProcessorFactory defaultEventProcessorFactory = new DefaultEventProcessorFactory(); - private static final EventProcessorFactory nullEventProcessorFactory = new NullEventProcessorFactory(); - private static final UpdateProcessorFactory defaultUpdateProcessorFactory = new DefaultUpdateProcessorFactory(); - private static final NullUpdateProcessorFactory nullUpdateProcessorFactory = new NullUpdateProcessorFactory(); - - /** - * 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.12.0 - */ - public static FeatureStoreFactory inMemoryDataStore() { - return inMemoryFeatureStoreFactory; - } - - /** - * 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 - * 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()
    -   *         .dataStore(
    -   *             Components.persistentDataStore(
    -   *                 Redis.dataStore().url("redis://my-redis-host")
    -   *             ).cacheSeconds(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} - * @see LDConfig.Builder#dataStore(FeatureStoreFactory) - * @see com.launchdarkly.client.integrations.Redis - * @since 4.12.0 - */ - public static PersistentDataStoreBuilder persistentDataStore(PersistentDataStoreFactory storeFactory) { - return new PersistentDataStoreBuilderImpl(storeFactory); - } - - /** - * Deprecated name for {@link #inMemoryDataStore()}. - * @return a factory object - * @deprecated Use {@link #inMemoryDataStore()}. - */ - @Deprecated - public static FeatureStoreFactory inMemoryFeatureStore() { - return inMemoryFeatureStoreFactory; - } - - /** - * Deprecated name for {@link com.launchdarkly.client.integrations.Redis#dataStore()}. - * @return a factory/builder object - * @deprecated Use {@link #persistentDataStore(PersistentDataStoreFactory)} with - * {@link com.launchdarkly.client.integrations.Redis#dataStore()}. - */ - @Deprecated - public static RedisFeatureStoreBuilder redisFeatureStore() { - return new RedisFeatureStoreBuilder(); - } - - /** - * 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 #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) { - return new RedisFeatureStoreBuilder(redisUri); - } - - /** - * 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()
    -   *         .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 - * @see #noEvents() - * @see LDConfig.Builder#events - * @since 4.12.0 - */ - public static EventProcessorBuilder sendEvents() { - return new EventProcessorBuilderImpl(); - } - - /** - * 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 - * @deprecated Use {@link #sendEvents()} or {@link #noEvents}. - */ - @Deprecated - public static EventProcessorFactory defaultEventProcessor() { - return defaultEventProcessorFactory; - } - - /** - * 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()
    -   *         .events(Components.noEvents())
    -   *         .build();
    -   * 
    - * - * @return a factory object - * @see #sendEvents() - * @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; - } - - /** - * 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 - * 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 - * @see LDConfig.Builder#dataSource(UpdateProcessorFactory) - * @since 4.12.0 - */ - public static StreamingDataSourceBuilder streamingDataSource() { - return new StreamingDataSourceBuilderImpl(); - } - - /** - * 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}.) - * - * @return a builder for setting polling properties - * @see LDConfig.Builder#dataSource(UpdateProcessorFactory) - * @since 4.12.0 - */ - public static PollingDataSourceBuilder pollingDataSource() { - return new PollingDataSourceBuilderImpl(); - } - - /** - * 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 #streamingDataSource()}, {@link #pollingDataSource()}, or {@link #externalUpdatesOnly()}. - */ - @Deprecated - public static UpdateProcessorFactory defaultUpdateProcessor() { - return defaultUpdateProcessorFactory; - } - - /** - * 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. - * 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.12.0 - * @see LDConfig.Builder#dataSource(UpdateProcessorFactory) - */ - public static UpdateProcessorFactory externalUpdatesOnly() { - return nullUpdateProcessorFactory; - } - - /** - * Deprecated name for {@link #externalUpdatesOnly()}. - * @return a factory object - * @deprecated Use {@link #externalUpdatesOnly()}. - */ - @Deprecated - 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() { - return new InMemoryFeatureStore(); - } - - @Override - public LDValue describeConfiguration(LDConfig config) { - return LDValue.of("memory"); - } - } - - // 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) { - return createEventProcessor(sdkKey, config, null); - } - - public EventProcessor createEventProcessor(String sdkKey, LDConfig config, - DiagnosticAccumulator diagnosticAccumulator) { - 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 - 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 { - public EventProcessor createEventProcessor(String sdkKey, LDConfig config) { - 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 UpdateProcessorFactoryWithDiagnostics, - DiagnosticDescription { - @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) { - return Components.externalUpdatesOnly().createUpdateProcessor(sdkKey, config, 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.deprecatedStream) { - StreamingDataSourceBuilderImpl builder = (StreamingDataSourceBuilderImpl)streamingDataSource() - .baseURI(config.deprecatedStreamURI) - .pollingBaseURI(config.deprecatedBaseURI) - .initialReconnectDelayMillis(config.deprecatedReconnectTimeMs); - return builder.createUpdateProcessor(sdkKey, config, featureStore, diagnosticAccumulator); - } else { - return pollingDataSource() - .baseURI(config.deprecatedBaseURI) - .pollIntervalMillis(config.deprecatedPollingIntervalMillis) - .createUpdateProcessor(sdkKey, config, featureStore); - } - } - - @Override - public LDValue describeConfiguration(LDConfig config) { - if (config.offline) { - return nullUpdateProcessorFactory.describeConfiguration(config); - } - if (config.deprecatedStream) { - return LDValue.buildObject() - .put(ConfigProperty.STREAMING_DISABLED.name, false) - .put(ConfigProperty.CUSTOM_BASE_URI.name, - config.deprecatedBaseURI != null && !config.deprecatedBaseURI.equals(LDConfig.DEFAULT_BASE_URI)) - .put(ConfigProperty.CUSTOM_STREAM_URI.name, - config.deprecatedStreamURI != null && !config.deprecatedStreamURI.equals(LDConfig.DEFAULT_STREAM_URI)) - .put(ConfigProperty.RECONNECT_TIME_MILLIS.name, config.deprecatedReconnectTimeMs) - .put(ConfigProperty.USING_RELAY_DAEMON.name, false) - .build(); - } else { - return LDValue.buildObject() - .put(ConfigProperty.STREAMING_DISABLED.name, true) - .put(ConfigProperty.CUSTOM_BASE_URI.name, - config.deprecatedBaseURI != null && !config.deprecatedBaseURI.equals(LDConfig.DEFAULT_BASE_URI)) - .put(ConfigProperty.CUSTOM_STREAM_URI.name, false) - .put(ConfigProperty.POLLING_INTERVAL_MILLIS.name, config.deprecatedPollingIntervalMillis) - .put(ConfigProperty.USING_RELAY_DAEMON.name, false) - .build(); - } - } - - } - - private static final class NullUpdateProcessorFactory implements UpdateProcessorFactory, DiagnosticDescription { - @Override - public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, FeatureStore featureStore) { - 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(); - } - - @Override - public LDValue describeConfiguration(LDConfig config) { - // We can assume that if they don't have a data source, and they *do* have a persistent data store, then - // they're using Relay in daemon mode. - return LDValue.buildObject() - .put(ConfigProperty.CUSTOM_BASE_URI.name, false) - .put(ConfigProperty.CUSTOM_STREAM_URI.name, false) - .put(ConfigProperty.STREAMING_DISABLED.name, false) - .put(ConfigProperty.USING_RELAY_DAEMON.name, - config.dataStoreFactory != null && config.dataStoreFactory != Components.inMemoryDataStore()) - .build(); - } - } - - // Package-private for visibility in tests - 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 {} - } - - private static final class StreamingDataSourceBuilderImpl extends StreamingDataSourceBuilder - implements UpdateProcessorFactoryWithDiagnostics, DiagnosticDescription { - @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) { - // Note, we log startup messages under the LDClient class to keep logs more readable - - if (config.offline) { - 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.httpConfig, - pollUri, - false - ); - - return new StreamProcessor( - sdkKey, - config.httpConfig, - requestor, - featureStore, - null, - diagnosticAccumulator, - streamUri, - initialReconnectDelayMillis - ); - } - - @Override - public LDValue describeConfiguration(LDConfig config) { - if (config.offline) { - return nullUpdateProcessorFactory.describeConfiguration(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))) - .put(ConfigProperty.CUSTOM_STREAM_URI.name, - baseURI != null && !baseURI.equals(LDConfig.DEFAULT_STREAM_URI)) - .put(ConfigProperty.RECONNECT_TIME_MILLIS.name, initialReconnectDelayMillis) - .put(ConfigProperty.USING_RELAY_DAEMON.name, false) - .build(); - } - } - - private static final class PollingDataSourceBuilderImpl extends PollingDataSourceBuilder implements DiagnosticDescription { - @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) { - 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.httpConfig, - baseURI == null ? LDConfig.DEFAULT_BASE_URI : baseURI, - true - ); - return new PollingProcessor(requestor, featureStore, pollIntervalMillis); - } - - @Override - public LDValue describeConfiguration(LDConfig config) { - if (config.offline) { - return nullUpdateProcessorFactory.describeConfiguration(config); - } - return LDValue.buildObject() - .put(ConfigProperty.STREAMING_DISABLED.name, true) - .put(ConfigProperty.CUSTOM_BASE_URI.name, - 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) - .build(); - } - } - - private static final class EventProcessorBuilderImpl extends EventProcessorBuilder - implements EventProcessorFactoryWithDiagnostics, DiagnosticDescription { - @Override - public EventProcessor createEventProcessor(String sdkKey, LDConfig config) { - return createEventProcessor(sdkKey, config, null); - } - - @Override - public EventProcessor createEventProcessor(String sdkKey, LDConfig config, DiagnosticAccumulator diagnosticAccumulator) { - if (config.offline) { - return new NullEventProcessor(); - } - return new DefaultEventProcessor(sdkKey, - config, - new EventsConfiguration( - allAttributesPrivate, - capacity, - baseURI == null ? LDConfig.DEFAULT_EVENTS_URI : baseURI, - flushIntervalSeconds, - inlineUsersInEvents, - privateAttrNames, - 0, // deprecated samplingInterval isn't supported in new builder - userKeysCapacity, - 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(); - } - } - - 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); - } - - @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/EvaluationDetail.java b/src/main/java/com/launchdarkly/client/EvaluationDetail.java deleted file mode 100644 index f9eaf2a65..000000000 --- a/src/main/java/com/launchdarkly/client/EvaluationDetail.java +++ /dev/null @@ -1,103 +0,0 @@ -package com.launchdarkly.client; - -import com.google.common.base.Objects; -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 { - - private final EvaluationReason reason; - private final Integer variationIndex; - private final T value; - - /** - * 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 - */ - public EvaluationDetail(EvaluationReason reason, Integer variationIndex, T value) { - this.value = value; - this.variationIndex = variationIndex; - this.reason = reason; - } - - /** - * 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) - * @return an {@link EvaluationDetail} - * @since 4.8.0 - */ - public static EvaluationDetail fromValue(T value, Integer variationIndex, EvaluationReason reason) { - return new EvaluationDetail(reason, variationIndex, value); - } - - static EvaluationDetail error(EvaluationReason.ErrorKind errorKind, LDValue defaultValue) { - return new EvaluationDetail(EvaluationReason.error(errorKind), null, LDValue.normalize(defaultValue)); - } - - /** - * An object describing the main factor that influenced the flag evaluation value. - * @return an {@link EvaluationReason} - */ - public EvaluationReason 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; - } - - /** - * 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) { - @SuppressWarnings("unchecked") - EvaluationDetail o = (EvaluationDetail)other; - 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); - } - - @Override - public String toString() { - return "{" + reason + "," + variationIndex + "," + value + "}"; - } -} diff --git a/src/main/java/com/launchdarkly/client/EvaluationException.java b/src/main/java/com/launchdarkly/client/EvaluationException.java deleted file mode 100644 index 174a2417e..000000000 --- a/src/main/java/com/launchdarkly/client/EvaluationException.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.launchdarkly.client; - -/** - * 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/EvaluationReason.java b/src/main/java/com/launchdarkly/client/EvaluationReason.java deleted file mode 100644 index 1feedecb5..000000000 --- a/src/main/java/com/launchdarkly/client/EvaluationReason.java +++ /dev/null @@ -1,445 +0,0 @@ -package com.launchdarkly.client; - -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#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. 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 - */ -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. - */ - 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. - */ - 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. - */ - PREREQUISITE_FAILED, - /** - * 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; - } - - /** - * 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 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. - */ - 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. An error message will always be logged - * in this case, and the exception should be available via {@link EvaluationReason.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, 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; - - /** - * Returns an enum indicating the general category of the reason. - * @return a {@link Kind} value - */ - 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() { - return getKind().name(); - } - - protected EvaluationReason(Kind kind) - { - this.kind = kind; - } - - /** - * Returns an instance whose {@code kind} is {@link Kind#OFF}. - * @return a reason object - */ - public static Off off() { - return Off.instance; - } - - /** - * Returns an instance whose {@code kind} is {@link Kind#TARGET_MATCH}. - * @return a reason object - */ - public static TargetMatch targetMatch() { - return TargetMatch.instance; - } - - /** - * 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 - */ - public static RuleMatch ruleMatch(int ruleIndex, String ruleId) { - return new RuleMatch(ruleIndex, ruleId); - } - - /** - * 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 - */ - public static PrerequisiteFailed prerequisiteFailed(String prerequisiteKey) { - return new PrerequisiteFailed(prerequisiteKey); - } - - /** - * Returns an instance whose {@code kind} is {@link Kind#FALLTHROUGH}. - * @return a reason object - */ - public static Fallthrough fallthrough() { - return Fallthrough.instance; - } - - /** - * Returns an instance whose {@code kind} is {@link Kind#ERROR}. - * @param errorKind describes the type of error - * @return a reason object - */ - public static Error error(ErrorKind 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, 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 - * 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); - } - - private static final Off instance = new 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() - { - super(Kind.TARGET_MATCH); - } - - 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 - * @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; - - private RuleMatch(int ruleIndex, String ruleId) { - super(Kind.RULE_MATCH); - this.ruleIndex = ruleIndex; - this.ruleId = 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; - } - - /** - * 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; - } - - @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() + "(" + ruleIndex + (ruleId == null ? "" : ("," + 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. - * @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; - - private PrerequisiteFailed(String prerequisiteKey) { - super(Kind.PREREQUISITE_FAILED); - this.prerequisiteKey = checkNotNull(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; - } - - @Override - public boolean equals(Object other) { - return (other instanceof PrerequisiteFailed) && - ((PrerequisiteFailed)other).prerequisiteKey.equals(prerequisiteKey); - } - - @Override - public int hashCode() { - return prerequisiteKey.hashCode(); - } - - @Override - public String toString() { - return getKind().name() + "(" + prerequisiteKey + ")"; - } - } - - /** - * 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() - { - super(Kind.FALLTHROUGH); - } - - private static final Fallthrough instance = new 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; - // 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); - checkNotNull(errorKind); - this.errorKind = errorKind; - this.exception = exception; - } - - /** - * An enumeration value indicating the general category of error. - * @return the error kind - */ - @Override - 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 && Objects.equals(exception, ((Error) other).exception); - } - - @Override - public int hashCode() { - return Objects.hash(errorKind, exception); - } - - @Override - public String toString() { - return getKind().name() + "(" + errorKind.name() + (exception == null ? "" : ("," + exception)) + ")"; - } - } -} diff --git a/src/main/java/com/launchdarkly/client/Event.java b/src/main/java/com/launchdarkly/client/Event.java deleted file mode 100644 index 40ff0053c..000000000 --- a/src/main/java/com/launchdarkly/client/Event.java +++ /dev/null @@ -1,181 +0,0 @@ -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. - */ -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) { - super(timestamp, user); - this.key = key; - this.data = data == null ? LDValue.ofNull() : data; - 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; - final LDValue value; - final LDValue defaultVal; - final Integer version; - final String prereqOf; - final boolean trackEvents; - final Long debugEventsUntilDate; - final EvaluationReason reason; - 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, - LDValue defaultVal, EvaluationReason reason, String prereqOf, boolean trackEvents, Long debugEventsUntilDate, boolean debug) { - super(timestamp, user); - this.key = key; - this.version = version; - this.variation = variation; - this.value = value; - this.defaultVal = defaultVal; - this.prereqOf = prereqOf; - this.trackEvents = trackEvents; - this.debugEventsUntilDate = debugEventsUntilDate; - this.reason = reason; - 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) { - this(timestamp, key, user, version, variation, LDValue.unsafeFromJsonElement(value), LDValue.unsafeFromJsonElement(defaultVal), - 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) { - 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/EventProcessorFactoryWithDiagnostics.java b/src/main/java/com/launchdarkly/client/EventProcessorFactoryWithDiagnostics.java deleted file mode 100644 index fac2c631a..000000000 --- a/src/main/java/com/launchdarkly/client/EventProcessorFactoryWithDiagnostics.java +++ /dev/null @@ -1,6 +0,0 @@ -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/EventsConfiguration.java b/src/main/java/com/launchdarkly/client/EventsConfiguration.java deleted file mode 100644 index 10f132130..000000000 --- a/src/main/java/com/launchdarkly/client/EventsConfiguration.java +++ /dev/null @@ -1,36 +0,0 @@ -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; - 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(); - this.allAttributesPrivate = allAttributesPrivate; - this.capacity = capacity; - this.eventsUri = eventsUri == null ? LDConfig.DEFAULT_EVENTS_URI : 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; - this.diagnosticRecordingIntervalSeconds = diagnosticRecordingIntervalSeconds; - } -} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/FeatureFlag.java b/src/main/java/com/launchdarkly/client/FeatureFlag.java deleted file mode 100644 index 2abe060a5..000000000 --- a/src/main/java/com/launchdarkly/client/FeatureFlag.java +++ /dev/null @@ -1,257 +0,0 @@ -package com.launchdarkly.client; - -import com.google.gson.annotations.JsonAdapter; -import com.launchdarkly.client.value.LDValue; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.ArrayList; -import java.util.List; - -import static com.google.common.base.Preconditions.checkNotNull; -import static com.launchdarkly.client.VersionedDataKind.FEATURES; - -@JsonAdapter(JsonHelpers.PostProcessingDeserializableTypeAdapterFactory.class) -class FeatureFlag implements VersionedData, JsonHelpers.PostProcessingDeserializable { - private final static Logger logger = LoggerFactory.getLogger(FeatureFlag.class); - - private String key; - private int version; - private boolean on; - private List prerequisites; - private String salt; - private List targets; - private List rules; - private VariationOrRollout fallthrough; - private Integer offVariation; //optional - private List variations; - private boolean clientSide; - private boolean trackEvents; - private boolean trackEventsFallthrough; - private Long debugEventsUntilDate; - private boolean deleted; - - // We need this so Gson doesn't complain in certain java environments that restrict unsafe allocation - FeatureFlag() {} - - 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) { - this.key = key; - this.version = version; - this.on = on; - this.prerequisites = prerequisites; - this.salt = salt; - this.targets = targets; - this.rules = rules; - this.fallthrough = fallthrough; - this.offVariation = offVariation; - this.variations = variations; - this.clientSide = clientSide; - this.trackEvents = trackEvents; - this.trackEventsFallthrough = trackEventsFallthrough; - this.debugEventsUntilDate = debugEventsUntilDate; - this.deleted = deleted; - } - - EvalResult evaluate(LDUser user, FeatureStore featureStore, EventFactory eventFactory) { - List prereqEvents = new ArrayList<>(); - - 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, LDValue.ofNull()), 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); - } - - // Check to see if targets match - if (targets != null) { - for (Target target: targets) { - if (target.getValues().contains(user.getKey().stringValue())) { - return getVariation(target.getVariation(), EvaluationReason.targetMatch()); - } - } - } - // Now walk through the rules and see if any match - if (rules != null) { - for (int i = 0; i < rules.size(); i++) { - Rule rule = rules.get(i); - if (rule.matchesUser(featureStore, user)) { - EvaluationReason.RuleMatch precomputedReason = rule.getRuleMatchReason(); - EvaluationReason.RuleMatch reason = precomputedReason != null ? precomputedReason : EvaluationReason.ruleMatch(i, rule.getId()); - return getValueForVariationOrRollout(rule, user, reason); - } - } - } - // Walk through the fallthrough and see if it matches - 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) { - if (prerequisites == null) { - return null; - } - for (int i = 0; i < prerequisites.size(); i++) { - boolean prereqOk = true; - Prerequisite prereq = prerequisites.get(i); - FeatureFlag prereqFeatureFlag = featureStore.get(FEATURES, prereq.getKey()); - if (prereqFeatureFlag == null) { - logger.error("Could not retrieve prerequisite flag \"{}\" when evaluating \"{}\"", prereq.getKey(), key); - prereqOk = false; - } 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; - } - events.add(eventFactory.newPrerequisiteFeatureRequestEvent(prereqFeatureFlag, user, prereqEvalResult, this)); - } - if (!prereqOk) { - EvaluationReason.PrerequisiteFailed precomputedReason = prereq.getPrerequisiteFailedReason(); - return precomputedReason != null ? precomputedReason : EvaluationReason.prerequisiteFailed(prereq.getKey()); - } - } - 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, LDValue.ofNull()); - } - LDValue value = LDValue.normalize(variations.get(variation)); - // normalize() ensures that nulls become LDValue.ofNull() - Gson may give us nulls - return EvaluationDetail.fromValue(value, variation, reason); - } - - private EvaluationDetail getOffValue(EvaluationReason reason) { - if (offVariation == null) { // off variation unspecified - return default value - return EvaluationDetail.fromValue(LDValue.ofNull(), null, reason); - } - return getVariation(offVariation, 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, LDValue.ofNull()); - } - return getVariation(index, reason); - } - - public int getVersion() { - return version; - } - - public String getKey() { - return key; - } - - public boolean isTrackEvents() { - return trackEvents; - } - - public boolean isTrackEventsFallthrough() { - return trackEventsFallthrough; - } - - public Long getDebugEventsUntilDate() { - return debugEventsUntilDate; - } - - public boolean isDeleted() { - return deleted; - } - - boolean isOn() { - return on; - } - - List getPrerequisites() { - return prerequisites; - } - - String getSalt() { - return salt; - } - - List getTargets() { - return targets; - } - - List getRules() { - return rules; - } - - VariationOrRollout getFallthrough() { - return fallthrough; - } - - List getVariations() { - return variations; - } - - Integer getOffVariation() { - return offVariation; - } - - 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; - - private EvalResult(EvaluationDetail details, List prerequisiteEvents) { - checkNotNull(details); - checkNotNull(prerequisiteEvents); - this.details = details; - this.prerequisiteEvents = prerequisiteEvents; - } - - EvaluationDetail getDetails() { - return details; - } - - List getPrerequisiteEvents() { - return prerequisiteEvents; - } - } -} diff --git a/src/main/java/com/launchdarkly/client/FeatureFlagBuilder.java b/src/main/java/com/launchdarkly/client/FeatureFlagBuilder.java deleted file mode 100644 index 52c1ba4c8..000000000 --- a/src/main/java/com/launchdarkly/client/FeatureFlagBuilder.java +++ /dev/null @@ -1,130 +0,0 @@ -package com.launchdarkly.client; - -import com.launchdarkly.client.value.LDValue; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - -class FeatureFlagBuilder { - private String key; - private int version; - private boolean on; - private List prerequisites = new ArrayList<>(); - private String salt; - private List targets = new ArrayList<>(); - private List rules = new ArrayList<>(); - private VariationOrRollout fallthrough; - private Integer offVariation; - private List variations = new ArrayList<>(); - private boolean clientSide; - private boolean trackEvents; - private boolean trackEventsFallthrough; - private Long debugEventsUntilDate; - private boolean deleted; - - FeatureFlagBuilder(String key) { - this.key = key; - } - - FeatureFlagBuilder(FeatureFlag f) { - if (f != null) { - this.key = f.getKey(); - this.version = f.getVersion(); - this.on = f.isOn(); - this.prerequisites = f.getPrerequisites(); - this.salt = f.getSalt(); - this.targets = f.getTargets(); - this.rules = f.getRules(); - this.fallthrough = f.getFallthrough(); - this.offVariation = f.getOffVariation(); - this.variations = f.getVariations(); - this.clientSide = f.isClientSide(); - this.trackEvents = f.isTrackEvents(); - this.trackEventsFallthrough = f.isTrackEventsFallthrough(); - this.debugEventsUntilDate = f.getDebugEventsUntilDate(); - this.deleted = f.isDeleted(); - } - } - - FeatureFlagBuilder version(int version) { - this.version = version; - return this; - } - - FeatureFlagBuilder on(boolean on) { - this.on = on; - return this; - } - - FeatureFlagBuilder prerequisites(List prerequisites) { - this.prerequisites = prerequisites; - return this; - } - - FeatureFlagBuilder salt(String salt) { - this.salt = salt; - return this; - } - - FeatureFlagBuilder targets(List targets) { - this.targets = targets; - return this; - } - - FeatureFlagBuilder rules(List rules) { - this.rules = rules; - return this; - } - - FeatureFlagBuilder fallthrough(VariationOrRollout fallthrough) { - this.fallthrough = fallthrough; - return this; - } - - FeatureFlagBuilder offVariation(Integer offVariation) { - this.offVariation = offVariation; - return this; - } - - FeatureFlagBuilder variations(List variations) { - this.variations = variations; - return this; - } - - FeatureFlagBuilder variations(LDValue... variations) { - return variations(Arrays.asList(variations)); - } - - FeatureFlagBuilder clientSide(boolean clientSide) { - this.clientSide = clientSide; - return this; - } - - 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; - return this; - } - - FeatureFlagBuilder deleted(boolean deleted) { - this.deleted = deleted; - return this; - } - - FeatureFlag build() { - 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/FeatureRequestor.java b/src/main/java/com/launchdarkly/client/FeatureRequestor.java deleted file mode 100644 index 6c23d0407..000000000 --- a/src/main/java/com/launchdarkly/client/FeatureRequestor.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.launchdarkly.client; - -import java.io.Closeable; -import java.io.IOException; -import java.util.Map; - -interface FeatureRequestor extends Closeable { - FeatureFlag getFlag(String featureKey) throws IOException, HttpErrorException; - - Segment getSegment(String segmentKey) throws IOException, HttpErrorException; - - AllData getAllData() throws IOException, HttpErrorException; - - static class AllData { - final Map flags; - final Map segments; - - AllData(Map flags, Map segments) { - this.flags = flags; - this.segments = segments; - } - } -} diff --git a/src/main/java/com/launchdarkly/client/FeatureStore.java b/src/main/java/com/launchdarkly/client/FeatureStore.java deleted file mode 100644 index 0ea551299..000000000 --- a/src/main/java/com/launchdarkly/client/FeatureStore.java +++ /dev/null @@ -1,87 +0,0 @@ -package com.launchdarkly.client; - -import java.io.Closeable; -import java.util.Map; - -/** - * A thread-safe, versioned store for feature flags and related objects received from the - * streaming API. Implementations should permit concurrent access and updates. - *

    - * Delete and upsert requests are versioned-- if the version number in the request is less than - * the currently stored version of the object, the request should be ignored. - *

    - * These semantics support the primary use case for the store, which synchronizes a collection - * of objects based on update messages that may be received out-of-order. - * @since 3.0.0 - */ -public interface FeatureStore extends Closeable { - /** - * Returns the object to which the specified key is mapped, or - * null if the key is not associated or the associated object has - * been deleted. - * - * @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 if the key is not associated or the associated object has - * been deleted. - */ - T get(VersionedDataKind kind, String key); - - /** - * Returns a {@link java.util.Map} of all associated objects of a given kind. - * - * @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 all(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. - *

    - * 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); - - /** - * Deletes the object associated with the specified key, if it exists and its version - * is less than or equal to the specified version. - * - * @param class of the object to be deleted - * @param kind the kind of object to delete - * @param key the key of the object to be deleted - * @param version the version for the delete operation - */ - void delete(VersionedDataKind kind, String key, int version); - - /** - * Update or insert the object associated with the specified key, if its version - * is less than or equal to the version specified in the argument object. - * - * @param class of the object to be updated - * @param kind the kind of object to update - * @param item the object to update or insert - */ - void upsert(VersionedDataKind kind, T item); - - /** - * Returns true if this store has been initialized. - * - * @return true if this store has been initialized - */ - boolean initialized(); - -} diff --git a/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java b/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java deleted file mode 100644 index 4c73d2f53..000000000 --- a/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java +++ /dev/null @@ -1,289 +0,0 @@ -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; - -/** - * 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 - * to set other properties: - * - *

    
    - *     Components.redisFeatureStore()
    - *         .caching(
    - *             FeatureStoreCacheConfig.enabled()
    - *                 .ttlSeconds(30)
    - *                 .staleValuesPolicy(FeatureStoreCacheConfig.StaleValuesPolicy.REFRESH)
    - *         )
    - * 
    - * - * @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}. - */ - 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 FeatureStoreCacheConfig DEFAULT = - new FeatureStoreCacheConfig(DEFAULT_TIME_SECONDS, TimeUnit.SECONDS, 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 FeatureStoreCacheConfig#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 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; - - /** - * Used internally for backward compatibility. - * @return the equivalent enum value - * @since 4.12.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.12.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; - } - } - }; - - /** - * Returns a parameter object indicating that caching should be disabled. Specifying any additional - * properties on this object will have no effect. - * @return a {@link FeatureStoreCacheConfig} instance - */ - public static FeatureStoreCacheConfig disabled() { - return 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 FeatureStoreCacheConfig} instance - */ - public static FeatureStoreCacheConfig enabled() { - return DEFAULT; - } - - private FeatureStoreCacheConfig(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 nonzero - */ - public boolean isEnabled() { - 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. - * - * @return the cache TTL in whatever units were specified - * @see #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. - *

    - * 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() { - 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. - * 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 - * @return an updated parameters object - */ - public FeatureStoreCacheConfig ttl(long cacheTime, TimeUnit timeUnit) { - return new FeatureStoreCacheConfig(cacheTime, timeUnit, staleValuesPolicy); - } - - /** - * Shortcut for calling {@link #ttl(long, TimeUnit)} with {@link TimeUnit#MILLISECONDS}. - * - * @param millis the cache TTL in milliseconds - * @return an updated parameters object - */ - public FeatureStoreCacheConfig 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 FeatureStoreCacheConfig 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 FeatureStoreCacheConfig staleValuesPolicy(StaleValuesPolicy policy) { - return new FeatureStoreCacheConfig(cacheTime, cacheTimeUnit, policy); - } - - @Override - public boolean equals(Object other) { - if (other instanceof FeatureStoreCacheConfig) { - FeatureStoreCacheConfig o = (FeatureStoreCacheConfig) 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/FeatureStoreClientWrapper.java b/src/main/java/com/launchdarkly/client/FeatureStoreClientWrapper.java deleted file mode 100644 index 5c2bba097..000000000 --- a/src/main/java/com/launchdarkly/client/FeatureStoreClientWrapper.java +++ /dev/null @@ -1,54 +0,0 @@ -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 deleted file mode 100644 index d2ae25fc3..000000000 --- a/src/main/java/com/launchdarkly/client/FeatureStoreDataSetSorter.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.launchdarkly.client; - -import com.google.common.collect.ImmutableMap; -import com.google.common.collect.ImmutableSortedMap; - -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 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 - */ - 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) { - if (!kind.isDependencyOrdered() || 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(kind, entry.getValue(), remainingItems, builder); - break; - } - } - - return builder.build(); - } - - private static void addWithDependenciesFirst(VersionedDataKind kind, - VersionedData item, - Map remainingItems, - ImmutableMap.Builder builder) { - remainingItems.remove(item.getKey()); // we won't need to visit this item again - for (String prereqKey: kind.getDependencyKeys(item)) { - VersionedData prereqItem = remainingItems.get(prereqKey); - if (prereqItem != null) { - addWithDependenciesFirst(kind, prereqItem, remainingItems, builder); - } - } - 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/FeatureStoreFactory.java b/src/main/java/com/launchdarkly/client/FeatureStoreFactory.java deleted file mode 100644 index c019de9c9..000000000 --- a/src/main/java/com/launchdarkly/client/FeatureStoreFactory.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.launchdarkly.client; - -/** - * Interface for a factory that creates some implementation of {@link FeatureStore}. - * @see Components - * @since 4.0.0 - */ -public interface FeatureStoreFactory { - /** - * Creates an implementation instance. - * @return a {@link FeatureStore} - */ - FeatureStore createFeatureStore(); -} diff --git a/src/main/java/com/launchdarkly/client/InMemoryFeatureStore.java b/src/main/java/com/launchdarkly/client/InMemoryFeatureStore.java deleted file mode 100644 index d2937cc79..000000000 --- a/src/main/java/com/launchdarkly/client/InMemoryFeatureStore.java +++ /dev/null @@ -1,143 +0,0 @@ -package com.launchdarkly.client; - -import com.google.common.collect.ImmutableMap; -import com.launchdarkly.client.interfaces.DiagnosticDescription; -import com.launchdarkly.client.value.LDValue; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.IOException; -import java.util.HashMap; -import java.util.Map; - -/** - * 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, DiagnosticDescription { - private static final Logger logger = LoggerFactory.getLogger(InMemoryFeatureStore.class); - - 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) { - 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) { - 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; - } - - @Override - public void init(Map, Map> allData) { - 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. - newData.put(entry.getKey(), ImmutableMap.copyOf(entry.getValue())); - } - this.allData = newData.build(); // replaces the entire map atomically - this.initialized = true; - } - } - - @Override - public void delete(VersionedDataKind kind, String key, int version) { - upsert(kind, kind.makeDeletedItem(key, version)); - } - - @Override - public void upsert(VersionedDataKind kind, T item) { - String key = item.getKey(); - synchronized (writeLock) { - Map existingItems = this.allData.get(kind); - VersionedData oldItem = null; - if (existingItems != null) { - oldItem = existingItems.get(key); - if (oldItem != null && oldItem.getVersion() >= item.getVersion()) { - return; - } - } - // 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()); - } - this.allData = newData.build(); // replaces the entire map atomically - } - } - - @Override - public boolean initialized() { - return initialized; - } - - /** - * Does nothing; this class does not have any resources to release - * - * @throws IOException will never happen - */ - @Override - public void close() throws IOException { - return; - } - - @Override - public LDValue describeConfiguration(LDConfig config) { - return LDValue.of("memory"); - } -} diff --git a/src/main/java/com/launchdarkly/client/LDConfig.java b/src/main/java/com/launchdarkly/client/LDConfig.java deleted file mode 100644 index 2a1be1b35..000000000 --- a/src/main/java/com/launchdarkly/client/LDConfig.java +++ /dev/null @@ -1,757 +0,0 @@ -package com.launchdarkly.client; - -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 java.net.URI; - -import javax.net.ssl.SSLSocketFactory; -import javax.net.ssl.X509TrustManager; - -/** - * 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 { - 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_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; - 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 = StreamingDataSourceBuilder.DEFAULT_INITIAL_RECONNECT_DELAY_MILLIS; - - protected static final LDConfig DEFAULT = new Builder().build(); - - final UpdateProcessorFactory dataSourceFactory; - final FeatureStoreFactory dataStoreFactory; - final boolean diagnosticOptOut; - final EventProcessorFactory eventProcessorFactory; - final HttpConfiguration httpConfig; - final boolean offline; - final long startWaitMillis; - - 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 deprecatedUserKeysCapacity; - final int deprecatedUserKeysFlushInterval; - final boolean deprecatedInlineUsersInEvents; - - 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; - - 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.deprecatedAllAttributesPrivate = builder.allAttributesPrivate; - this.deprecatedBaseURI = builder.baseURI; - this.deprecatedCapacity = builder.capacity; - this.deprecatedEventsURI = builder.eventsURI; - this.deprecatedFeatureStore = builder.featureStore; - this.deprecatedFlushInterval = builder.flushIntervalSeconds; - this.deprecatedInlineUsersInEvents = builder.inlineUsersInEvents; - if (builder.pollingIntervalMillis < MIN_POLLING_INTERVAL_MILLIS) { - this.deprecatedPollingIntervalMillis = MIN_POLLING_INTERVAL_MILLIS; - } else { - this.deprecatedPollingIntervalMillis = builder.pollingIntervalMillis; - } - this.deprecatedPrivateAttrNames = builder.privateAttrNames; - this.deprecatedSendEvents = builder.sendEvents; - this.deprecatedStream = builder.stream; - this.deprecatedStreamURI = builder.streamURI; - this.deprecatedSamplingInterval = builder.samplingInterval; - this.deprecatedReconnectTimeMs = builder.reconnectTimeMillis; - this.deprecatedUserKeysCapacity = builder.userKeysCapacity; - this.deprecatedUserKeysFlushInterval = builder.userKeysFlushInterval; - } - - LDConfig(LDConfig config) { - this.dataSourceFactory = config.dataSourceFactory; - this.dataStoreFactory = config.dataStoreFactory; - this.diagnosticOptOut = config.diagnosticOptOut; - this.eventProcessorFactory = config.eventProcessorFactory; - this.httpConfig = config.httpConfig; - this.offline = config.offline; - this.startWaitMillis = config.startWaitMillis; - - this.deprecatedAllAttributesPrivate = config.deprecatedAllAttributesPrivate; - this.deprecatedBaseURI = config.deprecatedBaseURI; - this.deprecatedCapacity = config.deprecatedCapacity; - this.deprecatedEventsURI = config.deprecatedEventsURI; - this.deprecatedFeatureStore = config.deprecatedFeatureStore; - this.deprecatedFlushInterval = config.deprecatedFlushInterval; - this.deprecatedInlineUsersInEvents = config.deprecatedInlineUsersInEvents; - 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; - } - - /** - * A builder that helps construct - * {@link com.launchdarkly.client.LDConfig} objects. Builder calls can be chained, enabling the - * following pattern: - *

    -   * LDConfig config = new LDConfig.Builder()
    -   *      .connectTimeoutMillis(3)
    -   *      .socketTimeoutMillis(3)
    -   *      .build()
    -   * 
    - */ - public static class Builder { - private URI baseURI = DEFAULT_BASE_URI; - private URI eventsURI = DEFAULT_EVENTS_URI; - private URI streamURI = DEFAULT_STREAM_URI; - 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; - private String proxyHost = "localhost"; - private int proxyPort = -1; - private String proxyUsername = null; - private String proxyPassword = null; - private boolean stream = true; - 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 = 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; - 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; - private SSLSocketFactory sslSocketFactory = null; - private X509TrustManager trustManager = null; - private String wrapperName = null; - private String wrapperVersion = null; - - /** - * Creates a builder with all configuration parameters set to the default - */ - public Builder() { - } - - /** - * 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; - } - - /** - * 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; - } - - /** - * 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; - } - - /** - * 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()}; 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}. - * - * @param factory the factory object - * @return the builder - * @since 4.12.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 - * you may use {@link RedisFeatureStore} or a custom implementation. - * @param store the feature store implementation - * @return the builder - * @deprecated Please use {@link #featureStoreFactory(FeatureStoreFactory)}. - */ - @Deprecated - public Builder featureStore(FeatureStore store) { - this.featureStore = store; - return this; - } - - /** - * 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.dataStoreFactory = factory; - return this; - } - - /** - * 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 events(EventProcessorFactory factory) { - this.eventProcessorFactory = factory; - return this; - } - - /** - * Deprecated name for {@link #events(EventProcessorFactory)}. - * @param factory the factory object - * @return the builder - * @since 4.0.0 - * @deprecated Use {@link #events(EventProcessorFactory)}. - */ - @Deprecated - public Builder eventProcessorFactory(EventProcessorFactory factory) { - this.eventProcessorFactory = factory; - return this; - } - - /** - * Sets the implementation of the component that receives feature flag data from LaunchDarkly, - * 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.12.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; - return this; - } - - /** - * 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; - } - - /** - * 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)}. - */ - @Deprecated - public Builder connectTimeout(int connectTimeout) { - return connectTimeoutMillis(connectTimeout * 1000); - } - - /** - * 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)}. - */ - @Deprecated - public Builder socketTimeout(int socketTimeout) { - return socketTimeoutMillis(socketTimeout * 1000); - } - - /** - * 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)}. - */ - @Deprecated - public Builder connectTimeoutMillis(int connectTimeoutMillis) { - this.connectTimeoutMillis = connectTimeoutMillis; - return this; - } - - /** - * 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)}. - */ - @Deprecated - public Builder socketTimeoutMillis(int socketTimeoutMillis) { - this.socketTimeoutMillis = socketTimeoutMillis; - return this; - } - - /** - * 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; - } - - /** - * 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; - } - - /** - * 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. - *

    - * - * @param host the proxy hostname - * @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; - } - - /** - * 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)}. - */ - @Deprecated - public Builder proxyPort(int port) { - this.proxyPort = port; - return this; - } - - /** - * 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)}. - */ - @Deprecated - public Builder proxyUsername(String username) { - this.proxyUsername = username; - return this; - } - - /** - * 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)}. - */ - @Deprecated - public Builder proxyPassword(String password) { - this.proxyPassword = password; - return this; - } - - /** - * 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)}. - */ - @Deprecated - public Builder sslSocketFactory(SSLSocketFactory sslSocketFactory, X509TrustManager trustManager) { - this.sslSocketFactory = sslSocketFactory; - this.trustManager = trustManager; - return this; - } - - /** - * 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) { - 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 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 - */ - public Builder offline(boolean offline) { - this.offline = offline; - return this; - } - - /** - * 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; - } - - /** - * 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; - } - - /** - * 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; - } - - /** - * Set how long the constructor will block awaiting a successful connection to LaunchDarkly. - * Setting this to 0 will not block and cause the constructor to return immediately. - * Default value: 5000 - * - * @param startWaitMillis milliseconds to wait - * @return the builder - */ - public Builder startWaitMillis(long startWaitMillis) { - this.startWaitMillis = startWaitMillis; - return this; - } - - /** - * Enable event sampling. When set to the default of zero, sampling is disabled and all events - * are sent back to LaunchDarkly. When set to greater than zero, there is a 1 in - * samplingInterval chance events will be will be sent. - *

    Example: if you want 5% sampling rate, set samplingInterval to 20. - * - * @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; - } - - /** - * 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; - } - - /** - * 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 = ImmutableSet.copyOf(names); - return this; - } - - /** - * 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; - } - - /** - * 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; - } - - /** - * 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; - } - - /** - * Set to true to opt out of sending diagnostics data. - *

    - * 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 - * @since 4.12.0 - */ - public Builder diagnosticOptOut(boolean diagnosticOptOut) { - this.diagnosticOptOut = diagnosticOptOut; - return this; - } - - /** - * 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)}. - */ - @Deprecated - public Builder wrapperName(String wrapperName) { - this.wrapperName = wrapperName; - return this; - } - - /** - * Deprecated method of specifing a wrapper library identifier. - * - * @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)}. - */ - @Deprecated - public Builder wrapperVersion(String wrapperVersion) { - this.wrapperVersion = wrapperVersion; - return this; - } - - /** - * Builds the configured {@link com.launchdarkly.client.LDConfig} object. - * - * @return the {@link com.launchdarkly.client.LDConfig} configured by this builder - */ - public LDConfig build() { - return new LDConfig(this); - } - } -} diff --git a/src/main/java/com/launchdarkly/client/LDCountryCode.java b/src/main/java/com/launchdarkly/client/LDCountryCode.java deleted file mode 100644 index ad344e4dc..000000000 --- a/src/main/java/com/launchdarkly/client/LDCountryCode.java +++ /dev/null @@ -1,2658 +0,0 @@ -/* - * Copyright (C) 2012-2014 Neo Visionaries Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * Taken verbatim from https://github.com/TakahikoKawasaki/nv-i18n and moved to - * the com.launchdarkly.client package to avoid class loading issues. - */ -package com.launchdarkly.client; - - -import java.util.ArrayList; -import java.util.Currency; -import java.util.HashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.regex.Pattern; - - -/** - * ISO 3166-1 country code. - * - *

    - * Enum names of this enum themselves are represented by - * ISO 3166-1 alpha-2 - * code (2-letter upper-case alphabets). There are instance methods to get the - * country name ({@link #getName()}), the - * ISO 3166-1 alpha-3 - * code ({@link #getAlpha3()}) and the - * ISO 3166-1 numeric - * code ({@link #getNumeric()}). - * In addition, there are static methods to get a {@code CountryCode} instance that - * corresponds to a given alpha-2/alpha-3/numeric code ({@link #getByCode(String)}, - * {@link #getByCode(int)}). - *

    - * - *
    - * // List all the country codes.
    - * for (CountryCode code : CountryCode.values())
    - * {
    - *     // For example, "[US] United States" is printed.
    - *     System.out.format("[%s] %s\n", code, code.{@link #getName()});
    - * }
    - *
    - * // Get a CountryCode instance by ISO 3166-1 code.
    - * CountryCode code = CountryCode.{@link #getByCode(String) getByCode}("JP");
    - *
    - * // Print all the information. Output will be:
    - * //
    - * //     Country name            = Japan
    - * //     ISO 3166-1 alpha-2 code = JP
    - * //     ISO 3166-1 alpha-3 code = JPN
    - * //     ISO 3166-1 numeric code = 392
    - * //     Assignment state        = OFFICIALLY_ASSIGNED
    - * //
    - * System.out.println("Country name            = " + code.{@link #getName()});
    - * System.out.println("ISO 3166-1 alpha-2 code = " + code.{@link #getAlpha2()});
    - * System.out.println("ISO 3166-1 alpha-3 code = " + code.{@link #getAlpha3()});
    - * System.out.println("ISO 3166-1 numeric code = " + code.{@link #getNumeric()});
    - * System.out.println("Assignment state        = " + code.{@link #getAssignment()});
    - *
    - * // Convert to a Locale instance.
    - * {@link Locale} locale = code.{@link #toLocale()};
    - *
    - * // Get a CountryCode by a Locale instance.
    - * code = CountryCode.{@link #getByLocale(Locale) getByLocale}(locale);
    - *
    - * // Get the currency of the country.
    - * {@link Currency} currency = code.{@link #getCurrency()};
    - *
    - * // Get a list by a regular expression for names.
    - * //
    - * // The list will contain:
    - * //
    - * //     CountryCode.AE : United Arab Emirates
    - * //     CountryCode.GB : United Kingdom
    - * //     CountryCode.TZ : Tanzania, United Republic of
    - * //     CountryCode.UK : United Kingdom
    - * //     CountryCode.UM : United States Minor Outlying Islands
    - * //     CountryCode.US : United States
    - * //
    - * List<CountryCode> list = CountryCode.{@link #findByName(String) findByName}(".*United.*");
    - * 
    - * - * @author Takahiko Kawasaki - */ -@SuppressWarnings({"deprecation", "DeprecatedIsStillUsed"}) -@Deprecated -public enum LDCountryCode -{ - /** - * Ascension Island - * [AC, ASC, -1, - * Exceptionally reserved] - */ - AC("Ascension Island", "ASC", -1, Assignment.EXCEPTIONALLY_RESERVED), - - /** - * Andorra - * [AD, AND, 16, - * Officially assigned] - */ - AD("Andorra", "AND", 20, Assignment.OFFICIALLY_ASSIGNED), - - /** - * United Arab Emirates - * [AE, AE, 784, - * Officially assigned] - */ - AE("United Arab Emirates", "ARE", 784, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Afghanistan - * [AF, AFG, 4, - * Officially assigned] - */ - AF("Afghanistan", "AFG", 4, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Antigua and Barbuda - * [AG, ATG, 28, - * Officially assigned] - */ - AG("Antigua and Barbuda", "ATG", 28, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Anguilla - * [AI, AIA, 660, - * Officially assigned] - */ - AI("Anguilla", "AIA", 660, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Albania - * [AL, ALB, 8, - * Officially assigned] - */ - AL("Albania", "ALB", 8, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Armenia - * [AM, ARM, 51, - * Officially assigned] - */ - AM("Armenia", "ARM", 51, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Netherlands Antilles - * [AN, ANHH, 530, - * Traditionally reserved] - */ - AN("Netherlands Antilles", "ANHH", 530, Assignment.TRANSITIONALLY_RESERVED), - - /** - * Angola - * [AO, AGO, 24, - * Officially assigned] - */ - AO("Angola", "AGO", 24, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Antarctica - * [AQ, ATA, 10, - * Officially assigned] - */ - AQ("Antarctica", "ATA", 10, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Argentina - * [AR, ARG, 32, - * Officially assigned] - */ - AR("Argentina", "ARG", 32, Assignment.OFFICIALLY_ASSIGNED), - - /** - * American Samoa - * [AS, ASM, 16, - * Officially assigned] - */ - AS("American Samoa", "ASM", 16, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Austria - * [AT, AUT, 40, - * Officially assigned] - */ - AT("Austria", "AUT", 40, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Australia - * [AU, AUS, 36, - * Officially assigned] - */ - AU("Australia", "AUS", 36, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Aruba - * [AW, ABW, 533, - * Officially assigned] - */ - AW("Aruba", "ABW", 533, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Åland Islands - * [AX, ALA, 248, - * Officially assigned] - */ - AX("\u212Bland Islands", "ALA", 248, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Azerbaijan - * [AZ, AZE, 31, - * Officially assigned] - */ - AZ("Azerbaijan", "AZE", 31, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Bosnia and Herzegovina - * [BA, BIH, 70, - * Officially assigned] - */ - BA("Bosnia and Herzegovina", "BIH", 70, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Barbados - * [BB, BRB, 52, - * Officially assigned] - */ - BB("Barbados", "BRB", 52, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Bangladesh - * [BD, BGD, 50, - * Officially assigned] - */ - BD("Bangladesh", "BGD", 50, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Belgium - * [BE, BEL, 56, - * Officially assigned] - */ - BE("Belgium", "BEL", 56, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Burkina Faso - * [BF, BFA, 854, - * Officially assigned] - */ - BF("Burkina Faso", "BFA", 854, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Bulgaria - * [BG, BGR, 100, - * Officially assigned] - */ - BG("Bulgaria", "BGR", 100, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Bahrain - * [BH, BHR, 48, - * Officially assigned] - */ - BH("Bahrain", "BHR", 48, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Burundi - * [BI, BDI, 108, - * Officially assigned] - */ - BI("Burundi", "BDI", 108, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Benin - * [BJ, BEN, 204, - * Officially assigned] - */ - BJ("Benin", "BEN", 204, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Saint Barthélemy - * [BL, BLM, 652, - * Officially assigned] - */ - BL("Saint Barth\u00E9lemy", "BLM", 652, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Bermuda - * [BM, BMU, 60, - * Officially assigned] - */ - BM("Bermuda", "BMU", 60, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Brunei Darussalam - * [BN, BRN, 96, - * Officially assigned] - */ - BN("Brunei Darussalam", "BRN", 96, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Bolivia, Plurinational State of - * [BO, BOL, 68, - * Officially assigned] - */ - BO("Bolivia, Plurinational State of", "BOL", 68, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Bonaire, Sint Eustatius and Saba - * [BQ, BES, 535, - * Officially assigned] - */ - BQ("Bonaire, Sint Eustatius and Saba", "BES", 535, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Brazil - * [BR, BRA, 76, - * Officially assigned] - */ - BR("Brazil", "BRA", 76, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Bahamas - * [BS, BHS, 44, - * Officially assigned] - */ - BS("Bahamas", "BHS", 44, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Bhutan - * [BT, BTN, 64, - * Officially assigned] - */ - BT("Bhutan", "BTN", 64, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Burma - * [BU, BUMM, 104, - * Officially assigned] - * - * @see #MM - */ - BU("Burma", "BUMM", 104, Assignment.TRANSITIONALLY_RESERVED), - - /** - * Bouvet Island - * [BV, BVT, 74, - * Officially assigned] - */ - BV("Bouvet Island", "BVT", 74, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Botswana - * [BW, BWA, 72, - * Officially assigned] - */ - BW("Botswana", "BWA", 72, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Belarus - * [BY, BLR, 112, - * Officially assigned] - */ - BY("Belarus", "BLR", 112, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Belize - * [BZ, BLZ, 84, - * Officially assigned] - */ - BZ("Belize", "BLZ", 84, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Canada - * [CA, CAN, 124, - * Officially assigned] - */ - CA("Canada", "CAN", 124, Assignment.OFFICIALLY_ASSIGNED) - { - @Override - public Locale toLocale() - { - return Locale.CANADA; - } - }, - - /** - * Cocos (Keeling) Islands - * [CC, CCK, 166, - * Officially assigned] - */ - CC("Cocos (Keeling) Islands", "CCK", 166, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Congo, the Democratic Republic of the - * [CD, COD, 180, - * Officially assigned] - */ - CD("Congo, the Democratic Republic of the", "COD", 180, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Central African Republic - * [CF, CAF, 140, - * Officially assigned] - */ - CF("Central African Republic", "CAF", 140, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Congo - * [CG, COG, 178, - * Officially assigned] - */ - CG("Congo", "COG", 178, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Switzerland - * [CH, CHE, 756, - * Officially assigned] - */ - CH("Switzerland", "CHE", 756, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Côte d'Ivoire - * [CI, CIV, 384, - * Officially assigned] - */ - CI("C\u00F4te d'Ivoire", "CIV", 384, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Cook Islands - * [CK, COK, 184, - * Officially assigned] - */ - CK("Cook Islands", "COK", 184, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Chile - * [CL, CHL, 152, - * Officially assigned] - */ - CL("Chile", "CHL", 152, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Cameroon - * [CM, CMR, 120, - * Officially assigned] - */ - CM("Cameroon", "CMR", 120, Assignment.OFFICIALLY_ASSIGNED), - - /** - * China - * [CN, CHN, 156, - * Officially assigned] - */ - CN("China", "CHN", 156, Assignment.OFFICIALLY_ASSIGNED) - { - @Override - public Locale toLocale() - { - return Locale.CHINA; - } - }, - - /** - * Colombia - * [CO, COL, 170, - * Officially assigned] - */ - CO("Colombia", "COL", 170, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Clipperton Island - * [CP, CPT, -1, - * Exceptionally reserved] - */ - CP("Clipperton Island", "CPT", -1, Assignment.EXCEPTIONALLY_RESERVED), - - /** - * Costa Rica - * [CR, CRI, 188, - * Officially assigned] - */ - CR("Costa Rica", "CRI", 188, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Serbia and Montenegro - * [CS, CSXX, 891, - * Traditionally reserved] - */ - CS("Serbia and Montenegro", "CSXX", 891, Assignment.TRANSITIONALLY_RESERVED), - - /** - * Cuba - * [CU, CUB, 192, - * Officially assigned] - */ - CU("Cuba", "CUB", 192, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Cape Verde - * [CV, CPV, 132, - * Officially assigned] - */ - CV("Cape Verde", "CPV", 132, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Curaçao - * [CW, CUW, 531, - * Officially assigned] - */ - CW("Cura\u00E7ao", "CUW", 531, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Christmas Island - * [CX, CXR, 162, - * Officially assigned] - */ - CX("Christmas Island", "CXR", 162, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Cyprus - * [CY, CYP, 196, - * Officially assigned] - */ - CY("Cyprus", "CYP", 196, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Czech Republic - * [CZ, CZE, 203, - * Officially assigned] - */ - CZ("Czech Republic", "CZE", 203, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Germany - * [DE, DEU, 276, - * Officially assigned] - */ - DE("Germany", "DEU", 276, Assignment.OFFICIALLY_ASSIGNED) - { - @Override - public Locale toLocale() - { - return Locale.GERMANY; - } - }, - - /** - * Diego Garcia - * [DG, DGA, -1, - * Exceptionally reserved] - */ - DG("Diego Garcia", "DGA", -1, Assignment.EXCEPTIONALLY_RESERVED), - - /** - * Djibouti - * [DJ, DJI, 262, - * Officially assigned] - */ - DJ("Djibouti", "DJI", 262, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Denmark - * [DK, DNK, 208, - * Officially assigned] - */ - DK("Denmark", "DNK", 208, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Dominica - * [DM, DMA, 212, - * Officially assigned] - */ - DM("Dominica", "DMA", 212, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Dominican Republic - * [DO, DOM, 214, - * Officially assigned] - */ - DO("Dominican Republic", "DOM", 214, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Algeria - * [DZ, DZA, 12, - * Officially assigned] - */ - DZ("Algeria", "DZA", 12, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Ceuta, - * Melilla - * [EA, null, -1, - * Exceptionally reserved] - */ - EA("Ceuta, Melilla", null, -1, Assignment.EXCEPTIONALLY_RESERVED), - - /** - * Ecuador - * [EC, ECU, 218, - * Officially assigned] - */ - EC("Ecuador", "ECU", 218, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Estonia - * [EE, EST, 233, - * Officially assigned] - */ - EE("Estonia", "EST", 233, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Egypt - * [EG, EGY, 818, - * Officially assigned] - */ - EG("Egypt", "EGY", 818, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Western Sahara - * [EH, ESH, 732, - * Officially assigned] - */ - EH("Western Sahara", "ESH", 732, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Eritrea - * [ER, ERI, 232, - * Officially assigned] - */ - ER("Eritrea", "ERI", 232, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Spain - * [ES, ESP, 724, - * Officially assigned] - */ - ES("Spain", "ESP", 724, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Ethiopia - * [ET, ETH, 231, - * Officially assigned] - */ - ET("Ethiopia", "ETH", 231, Assignment.OFFICIALLY_ASSIGNED), - - /** - * European Union - * [EU, null, -1, - * Exceptionally reserved] - */ - EU("European Union", null, -1, Assignment.EXCEPTIONALLY_RESERVED), - - /** - * Finland - * [FI, FIN, 246, - * Officially assigned] - * - * @see #SF - */ - FI("Finland", "FIN", 246, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Fiji - * [FJ, FJI, 242, - * Officially assigned] - */ - FJ("Fiji", "FJI", 242, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Falkland Islands (Malvinas) - * [FK, FLK, 238, - * Officially assigned] - */ - FK("Falkland Islands (Malvinas)", "FLK", 238, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Micronesia, Federated States of - * [FM, FSM, 583, - * Officially assigned] - */ - FM("Micronesia, Federated States of", "FSM", 583, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Faroe Islands - * [FO, FRO, 234, - * Officially assigned] - */ - FO("Faroe Islands", "FRO", 234, Assignment.OFFICIALLY_ASSIGNED), - - /** - * France - * [FR, FRA, 250, - * Officially assigned] - */ - FR("France", "FRA", 250, Assignment.OFFICIALLY_ASSIGNED) - { - @Override - public Locale toLocale() - { - return Locale.FRANCE; - } - }, - - /** - * France, Metropolitan - * [FX, FXX, -1, - * Exceptionally reserved] - */ - FX("France, Metropolitan", "FXX", -1, Assignment.EXCEPTIONALLY_RESERVED), - - /** - * Gabon - * [GA, GAB, 266, - * Officially assigned] - */ - GA("Gabon", "GAB", 266, Assignment.OFFICIALLY_ASSIGNED), - - /** - * United Kingdom - * [GB, GBR, 826, - * Officially assigned] - */ - GB("United Kingdom", "GBR", 826, Assignment.OFFICIALLY_ASSIGNED) - { - @Override - public Locale toLocale() - { - return Locale.UK; - } - }, - - /** - * Grenada - * [GD, GRD, 308, - * Officially assigned] - */ - GD("Grenada", "GRD", 308, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Georgia - * [GE, GEO, 268, - * Officially assigned] - */ - GE("Georgia", "GEO", 268, Assignment.OFFICIALLY_ASSIGNED), - - /** - * French Guiana - * [GF, GUF, 254, - * Officially assigned] - */ - GF("French Guiana", "GUF", 254, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Guernsey - * [GG, GGY, 831, - * Officially assigned] - */ - GG("Guernsey", "GGY", 831, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Ghana - * [GH, GHA, 288, - * Officially assigned] - */ - GH("Ghana", "GHA", 288, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Gibraltar - * [GI, GIB, 292, - * Officially assigned] - */ - GI("Gibraltar", "GIB", 292, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Greenland - * [GL, GRL, 304, - * Officially assigned] - */ - GL("Greenland", "GRL", 304, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Gambia - * [GM, GMB, 270, - * Officially assigned] - */ - GM("Gambia", "GMB", 270, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Guinea - * [GN, GIN, 324, - * Officially assigned] - */ - GN("Guinea", "GIN", 324, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Guadeloupe - * [GP, GLP, 312, - * Officially assigned] - */ - GP("Guadeloupe", "GLP", 312, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Equatorial Guinea - * [GQ, GNQ, 226, - * Officially assigned] - */ - GQ("Equatorial Guinea", "GNQ", 226, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Greece - * [GR, GRC, 300, - * Officially assigned] - */ - GR("Greece", "GRC", 300, Assignment.OFFICIALLY_ASSIGNED), - - /** - * South Georgia and the South Sandwich Islands - * [GS, SGS, 239, - * Officially assigned] - */ - GS("South Georgia and the South Sandwich Islands", "SGS", 239, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Guatemala - * [GT, GTM, 320, - * Officially assigned] - */ - GT("Guatemala", "GTM", 320, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Guam - * [GU, GUM, 316, - * Officially assigned] - */ - GU("Guam", "GUM", 316, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Guinea-Bissau - * [GW, GNB, 624, - * Officially assigned] - */ - GW("Guinea-Bissau", "GNB", 624, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Guyana - * [GY, GUY, 328, - * Officially assigned] - */ - GY("Guyana", "GUY", 328, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Hong Kong - * [HK, HKG, 344, - * Officially assigned] - */ - HK("Hong Kong", "HKG", 344, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Heard Island and McDonald Islands - * [HM, HMD, 334, - * Officially assigned] - */ - HM("Heard Island and McDonald Islands", "HMD", 334, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Honduras - * [HN, HND, 340, - * Officially assigned] - */ - HN("Honduras", "HND", 340, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Croatia - * [HR, HRV, 191, - * Officially assigned] - */ - HR("Croatia", "HRV", 191, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Haiti - * [HT, HTI, 332, - * Officially assigned] - */ - HT("Haiti", "HTI", 332, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Hungary - * [HU, HUN, 348, - * Officially assigned] - */ - HU("Hungary", "HUN", 348, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Canary Islands - * [IC, null, -1, - * Exceptionally reserved] - */ - IC("Canary Islands", null, -1, Assignment.EXCEPTIONALLY_RESERVED), - - /** - * Indonesia - * [ID, IDN, 360, - * Officially assigned] - */ - ID("Indonesia", "IDN", 360, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Ireland - * [IE, IRL, 372, - * Officially assigned] - */ - IE("Ireland", "IRL", 372, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Israel - * [IL, ISR, 376, - * Officially assigned] - */ - IL("Israel", "ISR", 376, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Isle of Man - * [IM, IMN, 833, - * Officially assigned] - */ - IM("Isle of Man", "IMN", 833, Assignment.OFFICIALLY_ASSIGNED), - - /** - * India - * [IN, IND, 356, - * Officially assigned] - */ - IN("India", "IND", 356, Assignment.OFFICIALLY_ASSIGNED), - - /** - * British Indian Ocean Territory - * [IO, IOT, 86, - * Officially assigned] - */ - IO("British Indian Ocean Territory", "IOT", 86, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Iraq - * [IQ, IRQ, 368, - * Officially assigned] - */ - IQ("Iraq", "IRQ", 368, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Iran, Islamic Republic of - * [IR, IRN, 364, - * Officially assigned] - */ - IR("Iran, Islamic Republic of", "IRN", 364, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Iceland - * [IS, ISL, 352, - * Officially assigned] - */ - IS("Iceland", "ISL", 352, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Italy - * [IT, ITA, 380, - * Officially assigned] - */ - IT("Italy", "ITA", 380, Assignment.OFFICIALLY_ASSIGNED) - { - @Override - public Locale toLocale() - { - return Locale.ITALY; - } - }, - - /** - * Jersey - * [JE, JEY, 832, - * Officially assigned] - */ - JE("Jersey", "JEY", 832, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Jamaica - * [JM, JAM, 388, - * Officially assigned] - */ - JM("Jamaica", "JAM", 388, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Jordan - * [JO, JOR, 400, - * Officially assigned] - */ - JO("Jordan", "JOR", 400, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Japan - * [JP, JPN, 392, - * Officially assigned] - */ - JP("Japan", "JPN", 392, Assignment.OFFICIALLY_ASSIGNED) - { - @Override - public Locale toLocale() - { - return Locale.JAPAN; - } - }, - - /** - * Kenya - * [KE, KEN, 404, - * Officially assigned] - */ - KE("Kenya", "KEN", 404, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Kyrgyzstan - * [KG, KGZ, 417, - * Officially assigned] - */ - KG("Kyrgyzstan", "KGZ", 417, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Cambodia - * [KH, KHM, 116, - * Officially assigned] - */ - KH("Cambodia", "KHM", 116, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Kiribati - * [KI, KIR, 296, - * Officially assigned] - */ - KI("Kiribati", "KIR", 296, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Comoros - * [KM, COM, 174, - * Officially assigned] - */ - KM("Comoros", "COM", 174, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Saint Kitts and Nevis - * [KN, KNA, 659, - * Officially assigned] - */ - KN("Saint Kitts and Nevis", "KNA", 659, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Korea, Democratic People's Republic of - * [KP, PRK, 408, - * Officially assigned] - */ - KP("Korea, Democratic People's Republic of", "PRK", 408, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Korea, Republic of - * [KR, KOR, 410, - * Officially assigned] - */ - KR("Korea, Republic of", "KOR", 410, Assignment.OFFICIALLY_ASSIGNED) - { - @Override - public Locale toLocale() - { - return Locale.KOREA; - } - }, - - /** - * Kuwait - * [KW, KWT, 414, - * Officially assigned] - */ - KW("Kuwait", "KWT", 414, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Cayman Islands - * [KY, CYM, 136, - * Officially assigned] - */ - KY("Cayman Islands", "CYM", 136, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Kazakhstan - * [KZ, KAZ, 398, - * Officially assigned] - */ - KZ("Kazakhstan", "KAZ", 398, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Lao People's Democratic Republic - * [LA, LAO, 418, - * Officially assigned] - */ - LA("Lao People's Democratic Republic", "LAO", 418, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Lebanon - * [LB, LBN, 422, - * Officially assigned] - */ - LB("Lebanon", "LBN", 422, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Saint Lucia - * [LC, LCA, 662, - * Officially assigned] - */ - LC("Saint Lucia", "LCA", 662, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Liechtenstein - * [LI, LIE, 438, - * Officially assigned] - */ - LI("Liechtenstein", "LIE", 438, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Sri Lanka - * [LK, LKA, 144, - * Officially assigned] - */ - LK("Sri Lanka", "LKA", 144, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Liberia - * [LR, LBR, 430, - * Officially assigned] - */ - LR("Liberia", "LBR", 430, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Lesotho - * [LS, LSO, 426, - * Officially assigned] - */ - LS("Lesotho", "LSO", 426, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Lithuania - * [LT, LTU, 440, - * Officially assigned] - */ - LT("Lithuania", "LTU", 440, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Luxembourg - * [LU, LUX, 442, - * Officially assigned] - */ - LU("Luxembourg", "LUX", 442, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Latvia - * [LV, LVA, 428, - * Officially assigned] - */ - LV("Latvia", "LVA", 428, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Libya - * [LY, LBY, 434, - * Officially assigned] - */ - LY("Libya", "LBY", 434, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Morocco - * [MA, MAR, 504, - * Officially assigned] - */ - MA("Morocco", "MAR", 504, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Monaco - * [MC, MCO, 492, - * Officially assigned] - */ - MC("Monaco", "MCO", 492, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Moldova, Republic of - * [MD, MDA, 498, - * Officially assigned] - */ - MD("Moldova, Republic of", "MDA", 498, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Montenegro - * [ME, MNE, 499, - * Officially assigned] - */ - ME("Montenegro", "MNE", 499, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Saint Martin (French part) - * [MF, MAF, 663, - * Officially assigned] - */ - MF("Saint Martin (French part)", "MAF", 663, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Madagascar - * [MG, MDG, 450, - * Officially assigned] - */ - MG("Madagascar", "MDG", 450, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Marshall Islands - * [MH, MHL, 584, - * Officially assigned] - */ - MH("Marshall Islands", "MHL", 584, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Macedonia, the former Yugoslav Republic of - * [MK, MKD, 807, - * Officially assigned] - */ - MK("Macedonia, the former Yugoslav Republic of", "MKD", 807, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Mali - * [ML, MLI, 466, - * Officially assigned] - */ - ML("Mali", "MLI", 466, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Myanmar - * [MM, MMR, 104, - * Officially assigned] - * - * @see #BU - */ - MM("Myanmar", "MMR", 104, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Mongolia - * [MN, MNG, 496, - * Officially assigned] - */ - MN("Mongolia", "MNG", 496, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Macao - * [MO, MCO, 492, - * Officially assigned] - */ - MO("Macao", "MAC", 446, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Northern Mariana Islands - * [MP, MNP, 580, - * Officially assigned] - */ - MP("Northern Mariana Islands", "MNP", 580, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Martinique - * [MQ, MTQ, 474, - * Officially assigned] - */ - MQ("Martinique", "MTQ", 474, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Mauritania - * [MR, MRT, 478, - * Officially assigned] - */ - MR("Mauritania", "MRT", 478, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Montserrat - * [MS, MSR, 500, - * Officially assigned] - */ - MS("Montserrat", "MSR", 500, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Malta - * [MT, MLT, 470, - * Officially assigned] - */ - MT("Malta", "MLT", 470, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Mauritius - * [MU, MUS, 480, - * Officially assigned]] - */ - MU("Mauritius", "MUS", 480, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Maldives - * [MV, MDV, 462, - * Officially assigned] - */ - MV("Maldives", "MDV", 462, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Malawi - * [MW, MWI, 454, - * Officially assigned] - */ - MW("Malawi", "MWI", 454, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Mexico - * [MX, MEX, 484, - * Officially assigned] - */ - MX("Mexico", "MEX", 484, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Malaysia - * [MY, MYS, 458, - * Officially assigned] - */ - MY("Malaysia", "MYS", 458, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Mozambique - * [MZ, MOZ, 508, - * Officially assigned] - */ - MZ("Mozambique", "MOZ", 508, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Namibia - * [NA, NAM, 516, - * Officially assigned] - */ - NA("Namibia", "NAM", 516, Assignment.OFFICIALLY_ASSIGNED), - - /** - * New Caledonia - * [NC, NCL, 540, - * Officially assigned] - */ - NC("New Caledonia", "NCL", 540, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Niger - * [NE, NER, 562, - * Officially assigned] - */ - NE("Niger", "NER", 562, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Norfolk Island - * [NF, NFK, 574, - * Officially assigned] - */ - NF("Norfolk Island", "NFK", 574, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Nigeria - * [NG, NGA, 566, - * Officially assigned] - */ - NG("Nigeria","NGA", 566, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Nicaragua - * [NI, NIC, 558, - * Officially assigned] - */ - NI("Nicaragua", "NIC", 558, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Netherlands - * [NL, NLD, 528, - * Officially assigned] - */ - NL("Netherlands", "NLD", 528, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Norway - * [NO, NOR, 578, - * Officially assigned] - */ - NO("Norway", "NOR", 578, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Nepal - * [NP, NPL, 524, - * Officially assigned] - */ - NP("Nepal", "NPL", 524, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Nauru - * [NR, NRU, 520, - * Officially assigned] - */ - NR("Nauru", "NRU", 520, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Neutral Zone - * [NT, NTHH, 536, - * Traditionally reserved] - */ - NT("Neutral Zone", "NTHH", 536, Assignment.TRANSITIONALLY_RESERVED), - - /** - * Niue - * [NU, NIU, 570, - * Officially assigned] - */ - NU("Niue", "NIU", 570, Assignment.OFFICIALLY_ASSIGNED), - - /** - * New Zealand - * [NZ, NZL, 554, - * Officially assigned] - */ - NZ("New Zealand", "NZL", 554, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Oman - * [OM, OMN, 512, - * Officially assigned] - */ - OM("Oman", "OMN", 512, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Panama - * [PA, PAN, 591, - * Officially assigned] - */ - PA("Panama", "PAN", 591, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Peru - * [PE, PER, 604, - * Officially assigned] - */ - PE("Peru", "PER", 604, Assignment.OFFICIALLY_ASSIGNED), - - /** - * French Polynesia - * [PF, PYF, 258, - * Officially assigned] - */ - PF("French Polynesia", "PYF", 258, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Papua New Guinea - * [PG, PNG, 598, - * Officially assigned] - */ - PG("Papua New Guinea", "PNG", 598, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Philippines - * [PH, PHL, 608, - * Officially assigned] - */ - PH("Philippines", "PHL", 608, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Pakistan - * [PK, PAK, 586, - * Officially assigned] - */ - PK("Pakistan", "PAK", 586, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Poland - * [PL, POL, 616, - * Officially assigned] - */ - PL("Poland", "POL", 616, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Saint Pierre and Miquelon - * [PM, SPM, 666, - * Officially assigned] - */ - PM("Saint Pierre and Miquelon", "SPM", 666, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Pitcairn - * [PN, PCN, 612, - * Officially assigned] - */ - PN("Pitcairn", "PCN", 612, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Puerto Rico - * [PR, PRI, 630, - * Officially assigned] - */ - PR("Puerto Rico", "PRI", 630, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Palestine, State of - * [PS, PSE, 275, - * Officially assigned] - */ - PS("Palestine, State of", "PSE", 275, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Portugal - * [PT, PRT, 620, - * Officially assigned] - */ - PT("Portugal", "PRT", 620, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Palau - * [PW, PLW, 585, - * Officially assigned] - */ - PW("Palau", "PLW", 585, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Paraguay - * [PY, PRY, 600, - * Officially assigned] - */ - PY("Paraguay", "PRY", 600, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Qatar - * [QA, QAT, 634, - * Officially assigned] - */ - QA("Qatar", "QAT", 634, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Réunion - * [RE, REU, 638, - * Officially assigned] - */ - RE("R\u00E9union", "REU", 638, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Romania - * [RO, ROU, 642, - * Officially assigned] - */ - RO("Romania", "ROU", 642, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Serbia - * [RS, SRB, 688, - * Officially assigned] - */ - RS("Serbia", "SRB", 688, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Russian Federation - * [RU, RUS, 643, - * Officially assigned] - */ - RU("Russian Federation", "RUS", 643, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Rwanda - * [RW, RWA, 646, - * Officially assigned] - */ - RW("Rwanda", "RWA", 646, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Saudi Arabia - * [SA, SAU, 682, - * Officially assigned] - */ - SA("Saudi Arabia", "SAU", 682, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Solomon Islands - * [SB, SLB, 90, - * Officially assigned] - */ - SB("Solomon Islands", "SLB", 90, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Seychelles - * [SC, SYC, 690, - * Officially assigned] - */ - SC("Seychelles", "SYC", 690, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Sudan - * [SD, SDN, 729, - * Officially assigned] - */ - SD("Sudan", "SDN", 729, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Sweden - * [SE, SWE, 752, - * Officially assigned] - */ - SE("Sweden", "SWE", 752, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Finland - * [SF, FIN, 246, - * Traditionally reserved] - * - * @see #FI - */ - SF("Finland", "FIN", 246, Assignment.TRANSITIONALLY_RESERVED), - - /** - * Singapore - * [SG, SGP, 702, - * Officially assigned] - */ - SG("Singapore", "SGP", 702, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Saint Helena, Ascension and Tristan da Cunha - * [SH, SHN, 654, - * Officially assigned] - */ - SH("Saint Helena, Ascension and Tristan da Cunha", "SHN", 654, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Slovenia - * [SI, SVN, 705, - * Officially assigned] - */ - SI("Slovenia", "SVN", 705, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Svalbard and Jan Mayen - * [SJ, SJM, 744, - * Officially assigned] - */ - SJ("Svalbard and Jan Mayen", "SJM", 744, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Slovakia - * [SK, SVK, 703, - * Officially assigned] - */ - SK("Slovakia", "SVK", 703, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Sierra Leone - * [SL, SLE, 694, - * Officially assigned] - */ - SL("Sierra Leone", "SLE", 694, Assignment.OFFICIALLY_ASSIGNED), - - /** - * San Marino - * [SM, SMR, 674, - * Officially assigned] - */ - SM("San Marino", "SMR", 674, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Senegal - * [SN, SEN, 686, - * Officially assigned] - */ - SN("Senegal", "SEN", 686, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Somalia - * [SO, SOM, 706, - * Officially assigned] - */ - SO("Somalia", "SOM", 706, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Suriname - * [SR, SUR, 740, - * Officially assigned] - */ - SR("Suriname", "SUR", 740, Assignment.OFFICIALLY_ASSIGNED), - - /** - * South Sudan - * [SS, SSD, 728, - * Officially assigned] - */ - SS("South Sudan", "SSD", 728, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Sao Tome and Principe - * [ST, STP, 678, - * Officially assigned] - */ - ST("Sao Tome and Principe", "STP", 678, Assignment.OFFICIALLY_ASSIGNED), - - /** - * USSR - * [SU, SUN, -1, - * Exceptionally reserved] - */ - SU("USSR", "SUN", -1, Assignment.EXCEPTIONALLY_RESERVED), - - /** - * El Salvador - * [SV, SLV, 222, - * Officially assigned] - */ - SV("El Salvador", "SLV", 222, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Sint Maarten (Dutch part) - * [SX, SXM, 534, - * Officially assigned] - */ - SX("Sint Maarten (Dutch part)", "SXM", 534, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Syrian Arab Republic - * [SY, SYR, 760, - * Officially assigned] - */ - SY("Syrian Arab Republic", "SYR", 760, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Swaziland - * [SZ, SWZ, 748, - * Officially assigned] - */ - SZ("Swaziland", "SWZ", 748, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Tristan da Cunha - * [TA, TAA, -1, - * Exceptionally reserved. - */ - TA("Tristan da Cunha", "TAA", -1, Assignment.EXCEPTIONALLY_RESERVED), - - /** - * Turks and Caicos Islands - * [TC, TCA, 796, - * Officially assigned] - */ - TC("Turks and Caicos Islands", "TCA", 796, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Chad - * [TD, TCD, 148, - * Officially assigned] - */ - TD("Chad", "TCD", 148, Assignment.OFFICIALLY_ASSIGNED), - - /** - * French Southern Territories - * [TF, ATF, 260, - * Officially assigned] - */ - TF("French Southern Territories", "ATF", 260, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Togo - * [TG, TGO, 768, - * Officially assigned] - */ - TG("Togo", "TGO", 768, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Thailand - * [TH, THA, 764, - * Officially assigned] - */ - TH("Thailand", "THA", 764, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Tajikistan - * [TJ, TJK, 762, - * Officially assigned] - */ - TJ("Tajikistan", "TJK", 762, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Tokelau - * [TK, TKL, 772, - * Officially assigned] - */ - TK("Tokelau", "TKL", 772, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Timor-Leste - * [TL, TLS, 626, - * Officially assigned] - */ - TL("Timor-Leste", "TLS", 626, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Turkmenistan - * [TM, TKM, 795, - * Officially assigned] - */ - TM("Turkmenistan", "TKM", 795, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Tunisia - * [TN, TUN, 788, - * Officially assigned] - */ - TN("Tunisia", "TUN", 788, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Tonga - * [TO, TON, 776, - * Officially assigned] - */ - TO("Tonga", "TON", 776, Assignment.OFFICIALLY_ASSIGNED), - - /** - * East Timor - * [TP, TPTL, 0, - * Traditionally reserved] - * - *

    - * ISO 3166-1 numeric code is unknown. - *

    - */ - TP("East Timor", "TPTL", 0, Assignment.TRANSITIONALLY_RESERVED), - - /** - * Turkey - * [TR, TUR, 792, - * Officially assigned] - */ - TR("Turkey", "TUR", 792, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Trinidad and Tobago - * [TT, TTO, 780, - * Officially assigned] - */ - TT("Trinidad and Tobago", "TTO", 780, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Tuvalu - * [TV, TUV, 798, - * Officially assigned] - */ - TV("Tuvalu", "TUV", 798, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Taiwan, Province of China - * [TW, TWN, 158, - * Officially assigned] - */ - TW("Taiwan, Province of China", "TWN", 158, Assignment.OFFICIALLY_ASSIGNED) - { - @Override - public Locale toLocale() - { - return Locale.TAIWAN; - } - }, - - /** - * Tanzania, United Republic of - * [TZ, TZA, 834, - * Officially assigned] - */ - TZ("Tanzania, United Republic of", "TZA", 834, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Ukraine - * [UA, UKR, 804, - * Officially assigned] - */ - UA("Ukraine", "UKR", 804, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Uganda - * [UG, UGA, 800, - * Officially assigned] - */ - UG("Uganda", "UGA", 800, Assignment.OFFICIALLY_ASSIGNED), - - /** - * United Kingdom - * [UK, null, -1, - * Exceptionally reserved] - */ - UK("United Kingdom", null, -1, Assignment.EXCEPTIONALLY_RESERVED) - { - @Override - public Locale toLocale() - { - return Locale.UK; - } - }, - - /** - * United States Minor Outlying Islands - * [UM, UMI, 581, - * Officially assigned] - */ - UM("United States Minor Outlying Islands", "UMI", 581, Assignment.OFFICIALLY_ASSIGNED), - - /** - * United States - * [US, USA, 840, - * Officially assigned] - */ - US("United States", "USA", 840, Assignment.OFFICIALLY_ASSIGNED) - { - @Override - public Locale toLocale() - { - return Locale.US; - } - }, - - /** - * Uruguay - * [UY, URY, 858, - * Officially assigned] - */ - UY("Uruguay", "URY", 858, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Uzbekistan - * [UZ, UZB, 860, - * Officially assigned] - */ - UZ("Uzbekistan", "UZB", 860, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Holy See (Vatican City State) - * [VA, VAT, 336, - * Officially assigned] - */ - VA("Holy See (Vatican City State)", "VAT", 336, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Saint Vincent and the Grenadines - * [VC, VCT, 670, - * Officially assigned] - */ - VC("Saint Vincent and the Grenadines", "VCT", 670, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Venezuela, Bolivarian Republic of - * [VE, VEN, 862, - * Officially assigned] - */ - VE("Venezuela, Bolivarian Republic of", "VEN", 862, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Virgin Islands, British - * [VG, VGB, 92, - * Officially assigned] - */ - VG("Virgin Islands, British", "VGB", 92, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Virgin Islands, U.S. - * [VI, VIR, 850, - * Officially assigned] - */ - VI("Virgin Islands, U.S.", "VIR", 850, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Viet Nam - * [VN, VNM, 704, - * Officially assigned] - */ - VN("Viet Nam", "VNM", 704, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Vanuatu - * [VU, VUT, 548, - * Officially assigned] - */ - VU("Vanuatu", "VUT", 548, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Wallis and Futuna - * [WF, WLF, 876, - * Officially assigned] - */ - WF("Wallis and Futuna", "WLF", 876, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Samoa - * [WS, WSM, 882, - * Officially assigned] - */ - WS("Samoa", "WSM", 882, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Kosovo, Republic of - * [XK, XXK, -1, - * User assigned] - */ - XK("Kosovo, Republic of", "XXK", -1, Assignment.USER_ASSIGNED), - - /** - * Yemen - * [YE, YEM, 887, - * Officially assigned] - */ - YE("Yemen", "YEM", 887, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Mayotte - * [YT, MYT, 175, - * Officially assigned] - */ - YT("Mayotte", "MYT", 175, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Yugoslavia - * [YU, YUCS, 890, - * Traditionally reserved] - */ - YU("Yugoslavia", "YUCS", 890, Assignment.TRANSITIONALLY_RESERVED), - - /** - * South Africa - * [ZA, ZAF, 710, - * Officially assigned] - */ - ZA("South Africa", "ZAF", 710, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Zambia - * [ZM, ZMB, 894, - * Officially assigned] - */ - ZM("Zambia", "ZMB", 894, Assignment.OFFICIALLY_ASSIGNED), - - /** - * Zaire - * [ZR, ZRCD, 0, - * Traditionally reserved] - * - *

    - * ISO 3166-1 numeric code is unknown. - *

    - */ - ZR("Zaire", "ZRCD", 0, Assignment.TRANSITIONALLY_RESERVED), - - /** - * Zimbabwe - * [ZW, ZWE, 716, - * Officially assigned] - */ - ZW("Zimbabwe", "ZWE", 716, Assignment.OFFICIALLY_ASSIGNED), - ; - - - /** - * Code assignment state in ISO 3166-1. - * - * @see Decoding table of ISO 3166-1 alpha-2 codes - */ - enum Assignment - { - /** - * Officially assigned. - * - * Assigned to a country, territory, or area of geographical interest. - */ - OFFICIALLY_ASSIGNED, - - /** - * User assigned. - * - * Free for assignment at the disposal of users. - */ - USER_ASSIGNED, - - /** - * Exceptionally reserved. - * - * Reserved on request for restricted use. - */ - EXCEPTIONALLY_RESERVED, - - /** - * Transitionally reserved. - * - * Deleted from ISO 3166-1 but reserved transitionally. - */ - TRANSITIONALLY_RESERVED, - - /** - * Indeterminately reserved. - * - * Used in coding systems associated with ISO 3166-1. - */ - INDETERMINATELY_RESERVED, - - /** - * Not used. - * - * Not used in ISO 3166-1 in deference to international property - * organization names. - */ - NOT_USED - } - - - private static final Map alpha3Map = new HashMap<>(); - private static final Map numericMap = new HashMap<>(); - - - static - { - for (LDCountryCode cc : values()) - { - if (cc.getAlpha3() != null) - { - alpha3Map.put(cc.getAlpha3(), cc); - } - - if (cc.getNumeric() != -1) - { - numericMap.put(cc.getNumeric(), cc); - } - } - } - - - private final String name; - private final String alpha3; - private final int numeric; - private final Assignment assignment; - - - private LDCountryCode(String name, String alpha3, int numeric, Assignment assignment) - { - this.name = name; - this.alpha3 = alpha3; - this.numeric = numeric; - this.assignment = assignment; - } - - - /** - * Get the country name. - * - * @return - * The country name. - */ - public String getName() - { - return name; - } - - - /** - * Get the ISO 3166-1 alpha-2 code. - * - * @return - * The ISO 3166-1 alpha-2 code. - */ - public String getAlpha2() - { - return name(); - } - - - /** - * Get the ISO 3166-1 alpha-3 code. - * - * @return - * The ISO 3166-1 alpha-3 code. - * Some country codes reserved exceptionally (such as {@link #EU}) - * returns {@code null}. - */ - public String getAlpha3() - { - return alpha3; - } - - - /** - * Get the ISO 3166-1 numeric code. - * - * @return - * The ISO 3166-1 numeric code. - * Country codes reserved exceptionally (such as {@link #EU}) - * returns {@code -1}. - */ - public int getNumeric() - { - return numeric; - } - - - /** - * Get the assignment state of this country code in ISO 3166-1. - * - * @return - * The assignment state. - * - * @see Decoding table of ISO 3166-1 alpha-2 codes - */ - public Assignment getAssignment() - { - return assignment; - } - - - /** - * Convert this {@code CountryCode} instance to a {@link Locale} instance. - * - *

    - * In most cases, this method creates a new {@code Locale} instance - * every time it is called, but some {@code CountryCode} instances return - * their corresponding entries in {@code Locale} class. For example, - * {@link #CA CountryCode.CA} always returns {@link Locale#CANADA}. - *

    - * - *

    - * The table below lists {@code CountryCode} entries whose {@code toLocale()} - * do not create new Locale instances but return entries in - * {@code Locale} class. - *

    - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - *
    CountryCodeLocale
    {@link LDCountryCode#CA CountryCode.CA}{@link Locale#CANADA}
    {@link LDCountryCode#CN CountryCode.CN}{@link Locale#CHINA}
    {@link LDCountryCode#DE CountryCode.DE}{@link Locale#GERMANY}
    {@link LDCountryCode#FR CountryCode.FR}{@link Locale#FRANCE}
    {@link LDCountryCode#GB CountryCode.GB}{@link Locale#UK}
    {@link LDCountryCode#IT CountryCode.IT}{@link Locale#ITALY}
    {@link LDCountryCode#JP CountryCode.JP}{@link Locale#JAPAN}
    {@link LDCountryCode#KR CountryCode.KR}{@link Locale#KOREA}
    {@link LDCountryCode#TW CountryCode.TW}{@link Locale#TAIWAN}
    {@link LDCountryCode#US CountryCode.US}{@link Locale#US}
    - * - * @return - * A {@code Locale} instance that matches this {@code CountryCode}. - */ - public Locale toLocale() - { - return new Locale("", name()); - } - - - /** - * Get the currency. - * - *

    - * This method is an alias of {@link Currency}{@code .}{@link - * Currency#getInstance(Locale) getInstance}{@code (}{@link - * #toLocale()}{@code )}. The only difference is that this method - * returns {@code null} when {@code Currency.getInstance(Locale)} - * throws {@code IllegalArgumentException}. - *

    - * - *

    - * This method returns {@code null} when the territory represented by - * this {@code CountryCode} instance does not have a currency. - * {@link #AQ} (Antarctica) is one example. - *

    - * - *

    - * In addition, this method returns {@code null} also when the ISO 3166 - * code represented by this {@code CountryCode} instance is not - * supported by the implementation of {@link - * Currency#getInstance(Locale)}. At the time of this writing, - * {@link #SS} (South Sudan) is one example. - *

    - * - * @return - * A {@code Currency} instance. In some cases, null - * is returned. - * - * @since 1.4 - * - * @see Currency#getInstance(Locale) - */ - public Currency getCurrency() - { - try - { - return Currency.getInstance(toLocale()); - } - catch (IllegalArgumentException e) - { - // Currency.getInstance(Locale) throws IllegalArgumentException - // when the given ISO 3166 code is not supported. - return null; - } - } - - - /** - * Get a {@code CountryCode} that corresponds to the given ISO 3166-1 - * alpha-2 or - * alpha-3 code. - * - *

    - * This method calls {@link #getByCode(String, boolean) getByCode}{@code (code, true)}. - * Note that the behavior has changed since the version 1.13. In the older versions, - * this method was an alias of {@code getByCode(code, false)}. - *

    - * - * @param code - * An ISO 3166-1 alpha-2 or alpha-3 code. - * - * @return - * A {@code CountryCode} instance, or {@code null} if not found. - * - * @see #getByCode(String, boolean) - */ - public static LDCountryCode getByCode(String code) - { - return getByCode(code, true); - } - - - /** - * Get a {@code CountryCode} that corresponds to the given ISO 3166-1 - * alpha-2 or - * alpha-3 code. - * - *

    - * This method calls {@link #getByCode(String, boolean) getByCode}{@code (code, false)}. - *

    - * - * @param code - * An ISO 3166-1 alpha-2 or alpha-3 code. - * - * @return - * A {@code CountryCode} instance, or {@code null} if not found. - * - * @since 1.13 - * - * @see #getByCode(String, boolean) - */ - public static LDCountryCode getByCodeIgnoreCase(String code) - { - return getByCode(code, false); - } - - - /** - * Get a {@code CountryCode} that corresponds to the given ISO 3166-1 - * alpha-2 or - * alpha-3 code. - * - * @param code - * An ISO 3166-1 alpha-2 or alpha-3 code. - * - * @param caseSensitive - * If {@code true}, the given code should consist of upper-case letters only. - * If {@code false}, this method internally canonicalizes the given code by - * {@link String#toUpperCase()} and then performs search. For example, - * {@code getByCode("jp", true)} returns {@code null}, but on the other hand, - * {@code getByCode("jp", false)} returns {@link #JP CountryCode.JP}. - * - * @return - * A {@code CountryCode} instance, or {@code null} if not found. - */ - public static LDCountryCode getByCode(String code, boolean caseSensitive) - { - if (code == null) - { - return null; - } - - switch (code.length()) - { - case 2: - code = canonicalize(code, caseSensitive); - return getByAlpha2Code(code); - - case 3: - code = canonicalize(code, caseSensitive); - return getByAlpha3Code(code); - - default: - return null; - } - } - - - /** - * Get a {@code CountryCode} that corresponds to the country code of - * the given {@link Locale} instance. - * - * @param locale - * A {@code Locale} instance. - * - * @return - * A {@code CountryCode} instance, or {@code null} if not found. - * - * @see Locale#getCountry() - */ - public static LDCountryCode getByLocale(Locale locale) - { - if (locale == null) - { - return null; - } - - // Locale.getCountry() returns either an empty string or - // an uppercase ISO 3166 2-letter code. - return getByCode(locale.getCountry(), true); - } - - - /** - * Canonicalize the given country code. - * - * @param code - * ISO 3166-1 alpha-2 or alpha-3 country code. - * - * @param caseSensitive - * {@code true} if the code should be handled case-sensitively. - * - * @return - * If {@code code} is {@code null} or an empty string, - * {@code null} is returned. - * Otherwise, if {@code caseSensitive} is {@code true}, - * {@code code} is returned as is. - * Otherwise, {@code code.toUpperCase()} is returned. - */ - static String canonicalize(String code, boolean caseSensitive) - { - if (code == null || code.length() == 0) - { - return null; - } - - if (caseSensitive) - { - return code; - } - else - { - return code.toUpperCase(); - } - } - - - private static LDCountryCode getByAlpha2Code(String code) - { - try - { - return Enum.valueOf(LDCountryCode.class, code); - } - catch (IllegalArgumentException e) - { - return null; - } - } - - - private static LDCountryCode getByAlpha3Code(String code) - { - return alpha3Map.get(code); - } - - - /** - * Get a {@code CountryCode} that corresponds to the given - * ISO 3166-1 - * numeric code. - * - * @param code - * An ISO 3166-1 numeric code. - * - * @return - * A {@code CountryCode} instance, or {@code null} if not found. - * If 0 or a negative value is given, {@code null} is returned. - */ - public static LDCountryCode getByCode(int code) - { - if (code <= 0) - { - return null; - } - - return numericMap.get(code); - } - - - /** - * Get a list of {@code CountryCode} by a name regular expression. - * - *

    - * This method is almost equivalent to {@link #findByName(Pattern) - * findByName}{@code (Pattern.compile(regex))}. - *

    - * - * @param regex - * Regular expression for names. - * - * @return - * List of {@code CountryCode}. If nothing has matched, - * an empty list is returned. - * - * @throws IllegalArgumentException - * {@code regex} is {@code null}. - * - * @throws java.util.regex.PatternSyntaxException - * {@code regex} failed to be compiled. - * - * @since 1.11 - */ - public static List findByName(String regex) - { - if (regex == null) - { - throw new IllegalArgumentException("regex is null."); - } - - // Compile the regular expression. This may throw - // java.util.regex.PatternSyntaxException. - Pattern pattern = Pattern.compile(regex); - - return findByName(pattern); - } - - - /** - * Get a list of {@code CountryCode} by a name pattern. - * - *

    - * For example, the list obtained by the code snippet below: - *

    - * - *
    -   * Pattern pattern = Pattern.compile(".*United.*");
    -   * List<CountryCode> list = CountryCode.findByName(pattern);
    - * - *

    - * contains 6 {@code CountryCode}s as listed below. - *

    - * - *
      - *
    1. {@link #AE} : United Arab Emirates - *
    2. {@link #GB} : United Kingdom - *
    3. {@link #TZ} : Tanzania, United Republic of - *
    4. {@link #UK} : United Kingdom - *
    5. {@link #UM} : United States Minor Outlying Islands - *
    6. {@link #US} : United States - *
    - * - * @param pattern - * Pattern to match names. - * - * @return - * List of {@code CountryCode}. If nothing has matched, - * an empty list is returned. - * - * @throws IllegalArgumentException - * {@code pattern} is {@code null}. - * - * @since 1.11 - */ - public static List findByName(Pattern pattern) - { - if (pattern == null) - { - throw new IllegalArgumentException("pattern is null."); - } - - List list = new ArrayList<>(); - - for (LDCountryCode entry : values()) - { - // If the name matches the given pattern. - if (pattern.matcher(entry.getName()).matches()) - { - list.add(entry); - } - } - - return list; - } -} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/LDUser.java b/src/main/java/com/launchdarkly/client/LDUser.java deleted file mode 100644 index 47d938b1d..000000000 --- a/src/main/java/com/launchdarkly/client/LDUser.java +++ /dev/null @@ -1,886 +0,0 @@ -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; -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; - -import java.io.IOException; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Set; -import java.util.TreeSet; -import java.util.regex.Pattern; - -/** - * A {@code LDUser} object contains specific attributes of a user browsing your site. The only mandatory property property is the {@code key}, - * which must uniquely identify each user. For authenticated users, this may be a username or e-mail address. For anonymous users, - * this could be an IP address or session ID. - *

    - * Besides the mandatory {@code key}, {@code LDUser} supports two kinds of optional attributes: interpreted attributes (e.g. {@code ip} and {@code country}) - * and custom attributes. LaunchDarkly can parse interpreted attributes and attach meaning to them. For example, from an {@code ip} address, LaunchDarkly can - * do a geo IP lookup and determine the user's country. - *

    - * 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 {@code Gson.toJson()} or - * {@code 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 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 = LDValue.of(builder.key); - this.ip = LDValue.of(builder.ip); - this.country = LDValue.of(builder.country); - 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); - } - - /** - * Create a user with the given key - * - * @param key a {@code String} that uniquely identifies a user - */ - 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; - } - - 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)) { - return builtIn.get(this); - } - } - return getCustom(attribute); - } - - LDValue getKey() { - return key; - } - - 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; - } - - LDValue getCountry() { - return country; - } - - LDValue getSecondary() { - return secondary; - } - - LDValue getName() { - return name; - } - - LDValue getFirstName() { - return firstName; - } - - LDValue getLastName() { - return lastName; - } - - LDValue getEmail() { - return email; - } - - LDValue getAvatar() { - return avatar; - } - - LDValue getAnonymous() { - return anonymous; - } - - LDValue getCustom(String key) { - if (custom != null) { - return LDValue.normalize(custom.get(key)); - } - return LDValue.ofNull(); - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - 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) && - 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); - } - - // Used internally when including users in analytics events, to ensure that private attributes are stripped out. - static class UserAdapterWithPrivateAttributeBehavior extends TypeAdapter { - private final EventsConfiguration config; - - public UserAdapterWithPrivateAttributeBehavior(EventsConfiguration config) { - this.config = config; - } - - @Override - public void write(JsonWriter out, LDUser user) throws IOException { - if (user == null) { - out.value((String)null); - return; - } - - // 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 - out.name("key").value(user.getKeyAsString()); - - if (!user.getSecondary().isNull()) { - if (!checkAndAddPrivate("secondary", user, privateAttributeNames)) { - out.name("secondary").value(user.getSecondary().stringValue()); - } - } - if (!user.getIp().isNull()) { - if (!checkAndAddPrivate("ip", user, privateAttributeNames)) { - out.name("ip").value(user.getIp().stringValue()); - } - } - if (!user.getEmail().isNull()) { - if (!checkAndAddPrivate("email", user, privateAttributeNames)) { - out.name("email").value(user.getEmail().stringValue()); - } - } - if (!user.getName().isNull()) { - if (!checkAndAddPrivate("name", user, privateAttributeNames)) { - out.name("name").value(user.getName().stringValue()); - } - } - if (!user.getAvatar().isNull()) { - if (!checkAndAddPrivate("avatar", user, privateAttributeNames)) { - out.name("avatar").value(user.getAvatar().stringValue()); - } - } - if (!user.getFirstName().isNull()) { - if (!checkAndAddPrivate("firstName", user, privateAttributeNames)) { - out.name("firstName").value(user.getFirstName().stringValue()); - } - } - if (!user.getLastName().isNull()) { - if (!checkAndAddPrivate("lastName", user, privateAttributeNames)) { - out.name("lastName").value(user.getLastName().stringValue()); - } - } - if (!user.getAnonymous().isNull()) { - out.name("anonymous").value(user.getAnonymous().booleanValue()); - } - if (!user.getCountry().isNull()) { - if (!checkAndAddPrivate("country", user, privateAttributeNames)) { - out.name("country").value(user.getCountry().stringValue()); - } - } - writeCustomAttrs(out, user, privateAttributeNames); - writePrivateAttrNames(out, privateAttributeNames); - - out.endObject(); - } - - private void writePrivateAttrNames(JsonWriter out, Set names) throws IOException { - if (names.isEmpty()) { - return; - } - out.name("privateAttrs"); - out.beginArray(); - for (String name : names) { - out.value(name); - } - out.endArray(); - } - - private boolean checkAndAddPrivate(String key, LDUser user, Set privateAttrs) { - boolean result = config.allAttributesPrivate || config.privateAttrNames.contains(key) || (user.privateAttributeNames != null && user.privateAttributeNames.contains(key)); - if (result) { - privateAttrs.add(key); - } - return result; - } - - private void writeCustomAttrs(JsonWriter out, LDUser user, Set privateAttributeNames) throws IOException { - boolean beganObject = false; - if (user.custom == null) { - return; - } - for (Map.Entry entry : user.custom.entrySet()) { - if (!checkAndAddPrivate(entry.getKey(), user, privateAttributeNames)) { - if (!beganObject) { - out.name("custom"); - out.beginObject(); - beganObject = true; - } - out.name(entry.getKey()); - JsonHelpers.gsonInstance().toJson(entry.getValue(), LDValue.class, out); - } - } - if (beganObject) { - out.endObject(); - } - } - - @Override - public LDUser read(JsonReader in) throws IOException { - // We never need to unmarshal user objects, so there's no need to implement this - return null; - } - } - - /** - * A builder that helps construct {@link LDUser} objects. Builder - * calls can be chained, enabling the following pattern: - *

    -   * LDUser user = new LDUser.Builder("key")
    -   *      .country("US")
    -   *      .ip("192.168.0.1")
    -   *      .build()
    -   * 
    - */ - public static class Builder { - private String key; - private String secondary; - private String ip; - private String firstName; - private String lastName; - private String email; - private String name; - private String avatar; - private Boolean anonymous; - private String country; - private Map custom; - private Set privateAttrNames; - - /** - * Creates a builder with the specified key. - * - * @param key the unique key for this user - */ - public Builder(String key) { - this.key = key; - } - - /** - * Creates a builder based on an existing user. - * - * @param user an existing {@code LDUser} - */ - public Builder(LDUser user) { - 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().stringValue(); - this.custom = user.custom == null ? null : new HashMap<>(user.custom); - this.privateAttrNames = user.privateAttributeNames == null ? null : new HashSet<>(user.privateAttributeNames); - } - - /** - * Sets the IP for a user. - * - * @param s the IP address for the user - * @return the builder - */ - public Builder ip(String s) { - this.ip = s; - return this; - } - - /** - * Sets the IP for a user, and ensures that the IP attribute is not sent back to LaunchDarkly. - * - * @param s the IP address for the user - * @return the builder - */ - public Builder privateIp(String s) { - addPrivate("ip"); - return ip(s); - } - - /** - * Sets the secondary key for a user. This affects - * feature flag targeting - * as follows: if you have chosen to bucket users by a specific attribute, the secondary key (if set) - * is used to further distinguish between users who are otherwise identical according to that attribute. - * @param s the secondary key for the user - * @return the builder - */ - public Builder secondary(String s) { - this.secondary = s; - return this; - } - - /** - * Sets the secondary key for a user, and ensures that the secondary key attribute is not sent back to - * LaunchDarkly. - * @param s the secondary key for the user - * @return the builder - */ - public Builder privateSecondary(String s) { - addPrivate("secondary"); - return secondary(s); - } - - /** - * Set the country for a user. - *

    - * 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. 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) { - LDCountryCode countryCode = LDCountryCode.getByCode(s, false); - - if (countryCode == null) { - List codes = LDCountryCode.findByName("^" + Pattern.quote(s) + ".*"); - - if (codes.isEmpty()) { - logger.warn("Invalid country. Expected valid ISO-3166-1 code: " + s); - } else if (codes.size() > 1) { - // See if any of the codes is an exact match - for (LDCountryCode c : codes) { - if (c.getName().equals(s)) { - country = c.getAlpha2(); - return this; - } - } - logger.warn("Ambiguous country. Provided code matches multiple countries: " + s); - country = codes.get(0).getAlpha2(); - } else { - 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. - *

    - * 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. 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 - */ - public Builder privateCountry(String s) { - addPrivate("country"); - return country(s); - } - - /** - * Set the country for a user. - * - * @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. - */ - @Deprecated - public Builder country(LDCountryCode country) { - this.country = country == null ? null : country.getAlpha2(); - return this; - } - - /** - * Set the country for a user, and ensures that the country attribute will not be sent back to LaunchDarkly. - * - * @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. - */ - @Deprecated - public Builder privateCountry(LDCountryCode country) { - addPrivate("country"); - return country(country); - } - - /** - * Sets the user's first name - * - * @param firstName the user's first name - * @return the builder - */ - public Builder firstName(String firstName) { - this.firstName = firstName; - return this; - } - - - /** - * Sets the user's first name, and ensures that the first name attribute will not be sent back to LaunchDarkly. - * - * @param firstName the user's first name - * @return the builder - */ - public Builder privateFirstName(String firstName) { - addPrivate("firstName"); - return firstName(firstName); - } - - - /** - * Sets whether this user is anonymous. - * - * @param anonymous whether the user is anonymous - * @return the builder - */ - public Builder anonymous(boolean anonymous) { - this.anonymous = anonymous; - return this; - } - - /** - * Sets the user's last name. - * - * @param lastName the user's last name - * @return the builder - */ - public Builder lastName(String lastName) { - this.lastName = lastName; - return this; - } - - /** - * Sets the user's last name, and ensures that the last name attribute will not be sent back to LaunchDarkly. - * - * @param lastName the user's last name - * @return the builder - */ - public Builder privateLastName(String lastName) { - addPrivate("lastName"); - return lastName(lastName); - } - - - /** - * Sets the user's full name. - * - * @param name the user's full name - * @return the builder - */ - public Builder name(String name) { - this.name = name; - return this; - } - - /** - * Sets the user's full name, and ensures that the name attribute will not be sent back to LaunchDarkly. - * - * @param name the user's full name - * @return the builder - */ - public Builder privateName(String name) { - addPrivate("name"); - return name(name); - } - - /** - * Sets the user's avatar. - * - * @param avatar the user's avatar - * @return the builder - */ - public Builder avatar(String avatar) { - this.avatar = avatar; - return this; - } - - /** - * Sets the user's avatar, and ensures that the avatar attribute will not be sent back to LaunchDarkly. - * - * @param avatar the user's avatar - * @return the builder - */ - public Builder privateAvatar(String avatar) { - addPrivate("avatar"); - return avatar(avatar); - } - - - /** - * Sets the user's e-mail address. - * - * @param email the e-mail address - * @return the builder - */ - public Builder email(String email) { - this.email = email; - return this; - } - - /** - * Sets the user's e-mail address, and ensures that the e-mail address attribute will not be sent back to LaunchDarkly. - * - * @param email the e-mail address - * @return the builder - */ - public Builder privateEmail(String email) { - addPrivate("email"); - return email(email); - } - - /** - * Adds a {@link java.lang.String}-valued custom attribute. 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 - */ - public Builder custom(String k, String v) { - return custom(k, v == null ? null : new JsonPrimitive(v)); - } - - /** - * Adds a {@link java.lang.Number}-valued custom attribute. 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 n the value for the custom attribute - * @return the builder - */ - public Builder custom(String k, Number n) { - return custom(k, n == null ? null : new JsonPrimitive(n)); - } - - /** - * Add a {@link java.lang.Boolean}-valued custom attribute. 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 b the value for the custom attribute - * @return the builder - */ - public Builder custom(String k, Boolean b) { - return custom(k, b == null ? null : new JsonPrimitive(b)); - } - - /** - * 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, LDValue v) { - checkCustomAttribute(k); - if (k != null && v != null) { - if (custom == null) { - custom = new HashMap<>(); - } - custom.put(k, 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 - * user attribute keys, this custom attribute will be ignored. - * - * @param k the key for the list - * @param vs the values for the attribute - * @return the builder - */ - public Builder customString(String k, List vs) { - JsonArray array = new JsonArray(); - for (String v : vs) { - if (v != null) { - array.add(new JsonPrimitive(v)); - } - } - return custom(k, array); - } - - /** - * Add a list of {@link java.lang.Number}-valued custom attributes. When set to one of the - * built-in - * user attribute keys, this custom attribute will be ignored. - * - * @param k the key for the list - * @param vs the values for the attribute - * @return the builder - */ - public Builder customNumber(String k, List vs) { - JsonArray array = new JsonArray(); - for (Number v : vs) { - if (v != null) { - array.add(new JsonPrimitive(v)); - } - } - return custom(k, array); - } - - /** - * Add a custom attribute with a list of arbitrary JSON values. When set to one of the - * built-in - * user attribute keys, this custom attribute will be ignored. - * - * @param k the key for the list - * @param vs the values for the attribute - * @return the builder - */ - public Builder customValues(String k, List vs) { - JsonArray array = new JsonArray(); - for (JsonElement v : vs) { - if (v != null) { - array.add(v); - } - } - return custom(k, array); - } - - /** - * Add a {@link java.lang.String}-valued custom attribute 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 - */ - public Builder privateCustom(String k, String v) { - addPrivate(k); - return custom(k, v); - } - - /** - * Add a {@link java.lang.Number}-valued custom attribute 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 n the value for the custom attribute - * @return the builder - */ - public Builder privateCustom(String k, Number n) { - addPrivate(k); - return custom(k, n); - } - - /** - * Add a {@link java.lang.Boolean}-valued custom attribute 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 b the value for the custom attribute - * @return the builder - */ - public Builder privateCustom(String k, Boolean b) { - addPrivate(k); - return custom(k, b); - } - - /** - * 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 - * @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); - } - - /** - * Add a list of {@link java.lang.String}-valued custom attributes. When set to one of the - * - * built-in user attribute keys, this custom attribute will be ignored. The custom attribute value will not be sent - * back to LaunchDarkly in analytics events. - * - * @param k the key for the list. When set to one of the built-in user attribute keys, this custom attribute will be ignored. - * @param vs the values for the attribute - * @return the builder - */ - public Builder privateCustomString(String k, List vs) { - addPrivate(k); - return customString(k, vs); - } - - /** - * Add a list of {@link java.lang.Integer}-valued custom attributes. When set to one of the - * - * built-in user attribute keys, this custom attribute will be ignored. The custom attribute value will not be sent - * back to LaunchDarkly in analytics events. - * - * @param k the key for the list. When set to one of the built-in user attribute keys, this custom attribute will be ignored. - * @param vs the values for the attribute - * @return the builder - */ - public Builder privateCustomNumber(String k, List vs) { - addPrivate(k); - return customNumber(k, vs); - } - - /** - * Add a custom attribute with a list of arbitrary JSON values. When set to one of the - * - * built-in user attribute keys, this custom attribute will be ignored. The custom attribute value will not be sent - * back to LaunchDarkly in analytics events. - * - * @param k the key for the list - * @param vs the values for the attribute - * @return the builder - */ - public Builder privateCustomValues(String k, List vs) { - addPrivate(k); - return customValues(k, vs); - } - - private void checkCustomAttribute(String key) { - for (UserAttribute a : UserAttribute.values()) { - if (a.name().equals(key)) { - logger.warn("Built-in attribute key: " + key + " added as custom attribute! This custom attribute will be ignored during Feature Flag evaluation"); - return; - } - } - } - - private void addPrivate(String key) { - if (privateAttrNames == null) { - privateAttrNames = new HashSet<>(); - } - privateAttrNames.add(key); - } - - /** - * Builds the configured {@link com.launchdarkly.client.LDUser} object. - * - * @return the {@link com.launchdarkly.client.LDUser} configured by this builder - */ - public LDUser build() { - return new LDUser(this); - } - } -} diff --git a/src/main/java/com/launchdarkly/client/NewRelicReflector.java b/src/main/java/com/launchdarkly/client/NewRelicReflector.java deleted file mode 100644 index 91a09c52c..000000000 --- a/src/main/java/com/launchdarkly/client/NewRelicReflector.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.launchdarkly.client; - -import com.google.common.base.Joiner; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.lang.reflect.Method; - -final class NewRelicReflector { - - private static Class newRelic = null; - - private static Method addCustomParameter = null; - - private static final Logger logger = LoggerFactory.getLogger(NewRelicReflector.class); - - static { - try { - newRelic = Class.forName(getNewRelicClassName()); - addCustomParameter = newRelic.getDeclaredMethod("addCustomParameter", String.class, String.class); - } catch (ClassNotFoundException | NoSuchMethodException e) { - logger.info("No NewRelic agent detected"); - } - } - - 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); - } catch (Exception e) { - logger.error("Unexpected error in LaunchDarkly NewRelic integration: {}", e.toString()); - logger.debug(e.toString(), e); - } - } - } -} diff --git a/src/main/java/com/launchdarkly/client/OperandType.java b/src/main/java/com/launchdarkly/client/OperandType.java deleted file mode 100644 index 1892c8357..000000000 --- a/src/main/java/com/launchdarkly/client/OperandType.java +++ /dev/null @@ -1,42 +0,0 @@ -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 - * will always yield false. This enum can be directly deserialized from JSON, avoiding the need for a mapping - * of strings to operators. - */ -enum OperandType { - string, - number, - date, - semVer; - - public static OperandType bestGuess(LDValue value) { - return value.isNumber() ? number : string; - } - - public Object getValueAsType(LDValue value) { - switch (this) { - case string: - return value.stringValue(); - case number: - return value.isNumber() ? Double.valueOf(value.doubleValue()) : null; - case date: - return Util.jsonPrimitiveToDateTime(value); - case semVer: - if (!value.isString()) { - return null; - } - try { - return SemanticVersion.parse(value.stringValue(), true); - } catch (SemanticVersion.InvalidVersionException e) { - return null; - } - default: - return null; - } - } -} diff --git a/src/main/java/com/launchdarkly/client/Operator.java b/src/main/java/com/launchdarkly/client/Operator.java deleted file mode 100644 index e87c92090..000000000 --- a/src/main/java/com/launchdarkly/client/Operator.java +++ /dev/null @@ -1,133 +0,0 @@ -package com.launchdarkly.client; - -import com.google.gson.JsonPrimitive; -import com.launchdarkly.client.value.LDValue; - -import java.util.regex.Pattern; - -/** - * Operator value that can be applied to {@link JsonPrimitive} objects. Incompatible types or other errors - * will always yield false. This enum can be directly deserialized from JSON, avoiding the need for a mapping - * of strings to operators. - */ -enum Operator { - in { - @Override - public boolean apply(LDValue uValue, LDValue cValue) { - if (uValue.equals(cValue)) { - return true; - } - OperandType type = OperandType.bestGuess(uValue); - if (type == OperandType.bestGuess(cValue)) { - return compareValues(ComparisonOp.EQ, uValue, cValue, type); - } - return false; - } - }, - endsWith { - @Override - public boolean apply(LDValue uValue, LDValue cValue) { - return uValue.isString() && cValue.isString() && uValue.stringValue().endsWith(cValue.stringValue()); - } - }, - startsWith { - @Override - public boolean apply(LDValue uValue, LDValue cValue) { - return uValue.isString() && cValue.isString() && uValue.stringValue().startsWith(cValue.stringValue()); - } - }, - matches { - public boolean apply(LDValue uValue, LDValue cValue) { - return uValue.isString() && cValue.isString() && - Pattern.compile(cValue.stringValue()).matcher(uValue.stringValue()).find(); - } - }, - contains { - public boolean apply(LDValue uValue, LDValue cValue) { - return uValue.isString() && cValue.isString() && uValue.stringValue().contains(cValue.stringValue()); - } - }, - lessThan { - public boolean apply(LDValue uValue, LDValue cValue) { - return compareValues(ComparisonOp.LT, uValue, cValue, OperandType.number); - } - }, - lessThanOrEqual { - public boolean apply(LDValue uValue, LDValue cValue) { - return compareValues(ComparisonOp.LTE, uValue, cValue, OperandType.number); - } - }, - greaterThan { - public boolean apply(LDValue uValue, LDValue cValue) { - return compareValues(ComparisonOp.GT, uValue, cValue, OperandType.number); - } - }, - greaterThanOrEqual { - public boolean apply(LDValue uValue, LDValue cValue) { - return compareValues(ComparisonOp.GTE, uValue, cValue, OperandType.number); - } - }, - before { - public boolean apply(LDValue uValue, LDValue cValue) { - return compareValues(ComparisonOp.LT, uValue, cValue, OperandType.date); - } - }, - after { - public boolean apply(LDValue uValue, LDValue cValue) { - return compareValues(ComparisonOp.GT, uValue, cValue, OperandType.date); - } - }, - semVerEqual { - public boolean apply(LDValue uValue, LDValue cValue) { - return compareValues(ComparisonOp.EQ, uValue, cValue, OperandType.semVer); - } - }, - semVerLessThan { - public boolean apply(LDValue uValue, LDValue cValue) { - return compareValues(ComparisonOp.LT, uValue, cValue, OperandType.semVer); - } - }, - semVerGreaterThan { - public boolean apply(LDValue uValue, LDValue cValue) { - return compareValues(ComparisonOp.GT, uValue, cValue, OperandType.semVer); - } - }, - segmentMatch { - 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(LDValue uValue, LDValue cValue); - - 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); - } - - private static enum ComparisonOp { - EQ, - LT, - LTE, - GT, - GTE; - - @SuppressWarnings("unchecked") - public boolean apply(Object a, Object b) { - if (a instanceof Comparable && a.getClass() == b.getClass()) { - int n = ((Comparable)a).compareTo(b); - switch (this) { - case EQ: return (n == 0); - case LT: return (n < 0); - case LTE: return (n <= 0); - case GT: return (n > 0); - case GTE: return (n >= 0); - } - } - return false; - } - } -} diff --git a/src/main/java/com/launchdarkly/client/PollingProcessor.java b/src/main/java/com/launchdarkly/client/PollingProcessor.java deleted file mode 100644 index df3bf609a..000000000 --- a/src/main/java/com/launchdarkly/client/PollingProcessor.java +++ /dev/null @@ -1,88 +0,0 @@ -package com.launchdarkly.client; - -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; - -import java.io.IOException; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ThreadFactory; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; - -import static com.launchdarkly.client.Util.httpErrorMessage; -import static com.launchdarkly.client.Util.isHttpErrorRecoverable; - -final class PollingProcessor implements UpdateProcessor { - private static final Logger logger = LoggerFactory.getLogger(PollingProcessor.class); - - @VisibleForTesting final FeatureRequestor requestor; - private final FeatureStore store; - @VisibleForTesting final long pollIntervalMillis; - private AtomicBoolean initialized = new AtomicBoolean(false); - private ScheduledExecutorService scheduler = null; - - 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 - public boolean initialized() { - return initialized.get(); - } - - @Override - public void close() throws IOException { - logger.info("Closing LaunchDarkly PollingProcessor"); - if (scheduler != null) { - scheduler.shutdown(); - } - requestor.close(); - } - - @Override - public Future start() { - logger.info("Starting LaunchDarkly polling client with interval: " - + pollIntervalMillis + " milliseconds"); - final SettableFuture initFuture = SettableFuture.create(); - ThreadFactory threadFactory = new ThreadFactoryBuilder() - .setNameFormat("LaunchDarkly-PollingProcessor-%d") - .build(); - scheduler = Executors.newScheduledThreadPool(1, threadFactory); - - scheduler.scheduleAtFixedRate(new Runnable() { - @Override - public void run() { - try { - FeatureRequestor.AllData allData = requestor.getAllData(); - store.init(DefaultFeatureRequestor.toVersionedDataMap(allData)); - if (!initialized.getAndSet(true)) { - logger.info("Initialized LaunchDarkly client."); - initFuture.set(null); - } - } catch (HttpErrorException e) { - logger.error(httpErrorMessage(e.getStatus(), "polling request", "will retry")); - if (!isHttpErrorRecoverable(e.getStatus())) { - scheduler.shutdown(); - 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.toString()); - logger.debug(e.toString(), e); - } catch (SerializationException e) { - logger.error("Polling request received malformed data: {}", e.toString()); - } - } - }, 0L, pollIntervalMillis, TimeUnit.MILLISECONDS); - - return initFuture; - } -} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/Prerequisite.java b/src/main/java/com/launchdarkly/client/Prerequisite.java deleted file mode 100644 index 7901444e5..000000000 --- a/src/main/java/com/launchdarkly/client/Prerequisite.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.launchdarkly.client; - -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() {} - - Prerequisite(String key, int variation) { - this.key = key; - this.variation = variation; - } - - String getKey() { - return key; - } - - 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/RedisFeatureStore.java b/src/main/java/com/launchdarkly/client/RedisFeatureStore.java deleted file mode 100644 index ebd36913c..000000000 --- a/src/main/java/com/launchdarkly/client/RedisFeatureStore.java +++ /dev/null @@ -1,89 +0,0 @@ -package com.launchdarkly.client; - -import com.google.common.cache.CacheStats; -import com.launchdarkly.client.utils.CachingStoreWrapper; - -import java.io.IOException; -import java.util.Map; - -/** - * 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()} - */ -@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; - - @Override - public void init(Map, Map> allData) { - wrappedStore.init(allData); - } - - @Override - public T get(VersionedDataKind kind, String key) { - return wrappedStore.get(kind, key); - } - - @Override - public Map all(VersionedDataKind kind) { - return wrappedStore.all(kind); - } - - @Override - public void upsert(VersionedDataKind kind, T item) { - wrappedStore.upsert(kind, item); - } - - @Override - public void delete(VersionedDataKind kind, String key, int version) { - wrappedStore.delete(kind, key, version); - } - - @Override - public boolean initialized() { - return wrappedStore.initialized(); - } - - @Override - public void close() throws IOException { - wrappedStore.close(); - } - - /** - * 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. - */ - public CacheStats getCacheStats() { - return ((CachingStoreWrapper)wrappedStore).getCacheStats(); - } - - /** - * Creates a new store instance that connects to Redis based on the provided {@link RedisFeatureStoreBuilder}. - *

    - * See the {@link RedisFeatureStoreBuilder} for information on available configuration options and what they do. - * - * @param builder the configured builder to construct the store with. - */ - protected RedisFeatureStore(RedisFeatureStoreBuilder builder) { - wrappedStore = builder.wrappedOuterBuilder.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())); - } -} diff --git a/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java b/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java deleted file mode 100644 index 60a45f94e..000000000 --- a/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java +++ /dev/null @@ -1,291 +0,0 @@ -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; -import com.launchdarkly.client.interfaces.DiagnosticDescription; -import com.launchdarkly.client.value.LDValue; - -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()} - */ -@Deprecated -public final class RedisFeatureStoreBuilder implements FeatureStoreFactory, DiagnosticDescription { - /** - * The default value for the Redis URI: {@code redis://localhost:6379} - * @since 4.0.0 - */ - 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 = RedisDataStoreBuilder.DEFAULT_PREFIX; - - /** - * The default value for {@link #cacheTime(long, TimeUnit)} (in seconds). - * @deprecated Use {@link FeatureStoreCacheConfig#DEFAULT}. - * @since 4.0.0 - */ - 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 - 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. - wrappedOuterBuilder.cacheMonitor(new CacheMonitor()); - } - - RedisFeatureStoreBuilder(URI uri) { - this(); - wrappedBuilder.uri(uri); - } - - /** - * The constructor accepts the mandatory fields that must be specified at a minimum to construct a {@link com.launchdarkly.client.RedisFeatureStore}. - * - * @param uri the uri of the Redis resource to connect to. - * @param cacheTimeSecs the cache time in seconds. See {@link RedisFeatureStoreBuilder#cacheTime(long, TimeUnit)} for more information. - * @deprecated Please use {@link Components#redisFeatureStore(java.net.URI)}. - */ - public RedisFeatureStoreBuilder(URI uri, long cacheTimeSecs) { - this(); - wrappedBuilder.uri(uri); - wrappedOuterBuilder.cacheSeconds(cacheTimeSecs); - } - - /** - * The constructor accepts the mandatory fields that must be specified at a minimum to construct a {@link com.launchdarkly.client.RedisFeatureStore}. - * - * @param scheme the URI scheme to use - * @param host the hostname to connect to - * @param port the port to connect to - * @param cacheTimeSecs the cache time in seconds. See {@link RedisFeatureStoreBuilder#cacheTime(long, TimeUnit)} for more information. - * @throws URISyntaxException if the URI is not valid - * @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)); - wrappedOuterBuilder.cacheSeconds(cacheTimeSecs); - } - - /** - * 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 - * - * @since 4.7.0 - */ - public RedisFeatureStoreBuilder database(Integer database) { - wrappedBuilder.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) { - wrappedBuilder.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) { - wrappedBuilder.tls(tls); - 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 - * - * @since 4.6.0 - */ - public RedisFeatureStoreBuilder caching(FeatureStoreCacheConfig caching) { - wrappedOuterBuilder.cacheTime(caching.getCacheTime(), caching.getCacheTimeUnit()); - wrappedOuterBuilder.staleValuesPolicy(caching.getStaleValuesPolicy().toNewEnum()); - return this; - } - - /** - * 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(FeatureStoreCacheConfig)} and - * {@link FeatureStoreCacheConfig#staleValuesPolicy(com.launchdarkly.client.FeatureStoreCacheConfig.StaleValuesPolicy)}. - */ - public RedisFeatureStoreBuilder refreshStaleValues(boolean enabled) { - this.refreshStaleValues = enabled; - updateCachingStaleValuesPolicy(); - return this; - } - - /** - * 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(FeatureStoreCacheConfig)} and - * {@link FeatureStoreCacheConfig#staleValuesPolicy(com.launchdarkly.client.FeatureStoreCacheConfig.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 (refreshStaleValues) { - wrappedOuterBuilder.staleValuesPolicy(this.asyncRefresh ? - PersistentDataStoreBuilder.StaleValuesPolicy.REFRESH_ASYNC : - PersistentDataStoreBuilder.StaleValuesPolicy.REFRESH); - } else { - wrappedOuterBuilder.staleValuesPolicy(PersistentDataStoreBuilder.StaleValuesPolicy.EVICT); - } - } - - /** - * Optionally configures the namespace prefix for all keys stored in Redis. - * - * @param prefix the namespace prefix - * @return the builder - */ - public RedisFeatureStoreBuilder prefix(String prefix) { - wrappedBuilder.prefix(prefix); - return this; - } - - /** - * Deprecated method for enabling local caching and setting the cache TTL. Local caching is enabled - * 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(FeatureStoreCacheConfig)} and {@link FeatureStoreCacheConfig#ttl(long, TimeUnit)}. - */ - public RedisFeatureStoreBuilder cacheTime(long cacheTime, TimeUnit timeUnit) { - wrappedOuterBuilder.cacheTime(cacheTime, timeUnit); - 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 RedisFeatureStoreBuilder poolConfig(JedisPoolConfig poolConfig) { - wrappedBuilder.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 RedisFeatureStoreBuilder connectTimeout(int connectTimeout, TimeUnit timeUnit) { - wrappedBuilder.connectTimeout(connectTimeout, timeUnit); - 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 RedisFeatureStoreBuilder socketTimeout(int socketTimeout, TimeUnit timeUnit) { - wrappedBuilder.socketTimeout(socketTimeout, timeUnit); - return this; - } - - /** - * Build a {@link RedisFeatureStore} based on the currently configured builder object. - * @return the {@link RedisFeatureStore} configured by this builder. - */ - public RedisFeatureStore build() { - return new RedisFeatureStore(this); - } - - /** - * Synonym for {@link #build()}. - * @return the {@link RedisFeatureStore} configured by this builder. - * @since 4.0.0 - */ - public RedisFeatureStore createFeatureStore() { - return build(); - } - - @Override - public LDValue describeConfiguration(LDConfig config) { - return LDValue.of("Redis"); - } -} diff --git a/src/main/java/com/launchdarkly/client/Rule.java b/src/main/java/com/launchdarkly/client/Rule.java deleted file mode 100644 index 49939348d..000000000 --- a/src/main/java/com/launchdarkly/client/Rule.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.launchdarkly.client; - -import java.util.List; - -/** - * Expresses a set of AND-ed matching conditions for a user, along with either the fixed variation or percent rollout - * to serve if the conditions match. - * Invariant: one of the variation or rollout must be non-nil. - */ -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() { - super(); - } - - 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; - } - - List getClauses() { - return clauses; - } - - 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)) { - return false; - } - } - return true; - } -} diff --git a/src/main/java/com/launchdarkly/client/Segment.java b/src/main/java/com/launchdarkly/client/Segment.java deleted file mode 100644 index 872c2ada5..000000000 --- a/src/main/java/com/launchdarkly/client/Segment.java +++ /dev/null @@ -1,136 +0,0 @@ -package com.launchdarkly.client; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.HashSet; -import java.util.List; -import java.util.Set; - -@SuppressWarnings("deprecation") -class Segment implements VersionedData { - private String key; - private Set included; - private Set excluded; - private String salt; - private List rules; - private int version; - private boolean deleted; - - // We need this so Gson doesn't complain in certain java environments that restrict unsafe allocation - Segment() {} - - private Segment(Builder builder) { - this.key = builder.key; - this.included = builder.included; - this.excluded = builder.excluded; - this.salt = builder.salt; - this.rules = builder.rules; - this.version = builder.version; - this.deleted = builder.deleted; - } - - public String getKey() { - return key; - } - - public Iterable getIncluded() { - return included; - } - - public Iterable getExcluded() { - return excluded; - } - - public String getSalt() { - return salt; - } - - public Iterable getRules() { - return rules; - } - - public int getVersion() { - return version; - } - - public boolean isDeleted() { - return deleted; - } - - public boolean matchesUser(LDUser user) { - String key = user.getKeyAsString(); - if (key == null) { - return false; - } - if (included.contains(key)) { - return true; - } - if (excluded.contains(key)) { - return false; - } - for (SegmentRule rule: rules) { - if (rule.matchUser(user, key, salt)) { - return true; - } - } - return false; - } - - public static class Builder { - private String key; - private Set included = new HashSet<>(); - private Set excluded = new HashSet<>(); - private String salt = ""; - private List rules = new ArrayList<>(); - private int version = 0; - private boolean deleted; - - public Builder(String key) { - this.key = key; - } - - public Builder(Segment from) { - this.key = from.key; - 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; - this.deleted = from.deleted; - } - - public Segment build() { - return new Segment(this); - } - - public Builder included(Collection included) { - this.included = new HashSet<>(included); - return this; - } - - public Builder excluded(Collection excluded) { - this.excluded = new HashSet<>(excluded); - return this; - } - - public Builder salt(String salt) { - this.salt = salt; - return this; - } - - public Builder rules(Collection rules) { - this.rules = new ArrayList<>(rules); - return this; - } - - public Builder version(int version) { - this.version = version; - return this; - } - - public Builder deleted(boolean deleted) { - this.deleted = deleted; - return this; - } - } -} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/SegmentRule.java b/src/main/java/com/launchdarkly/client/SegmentRule.java deleted file mode 100644 index 79b3df68f..000000000 --- a/src/main/java/com/launchdarkly/client/SegmentRule.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.launchdarkly.client; - -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)) { - return false; - } - } - - // If the Weight is absent, this rule matches - if (weight == null) { - return true; - } - - // All of the clauses are met. See if the user buckets in - String by = (bucketBy == null) ? "key" : bucketBy; - double bucket = VariationOrRollout.bucketUser(user, segmentKey, by, salt); - double weight = (double)this.weight / 100000.0; - return bucket < weight; - } -} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/StreamProcessor.java b/src/main/java/com/launchdarkly/client/StreamProcessor.java deleted file mode 100644 index 9da59b497..000000000 --- a/src/main/java/com/launchdarkly/client/StreamProcessor.java +++ /dev/null @@ -1,402 +0,0 @@ -package com.launchdarkly.client; - -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; -import com.launchdarkly.eventsource.EventSource; -import com.launchdarkly.eventsource.MessageEvent; -import com.launchdarkly.eventsource.UnsuccessfulResponseException; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -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; - -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.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"; - private static final String DELETE = "delete"; - private static final String INDIRECT_PUT = "indirect/put"; - private static final String INDIRECT_PATCH = "indirect/patch"; - private static final Logger logger = LoggerFactory.getLogger(StreamProcessor.class); - private static final int DEAD_CONNECTION_INTERVAL_MS = 300 * 1000; - - private final FeatureStore store; - private final HttpConfiguration httpConfig; - private final Headers headers; - @VisibleForTesting final URI streamUri; - @VisibleForTesting final long initialReconnectDelayMillis; - @VisibleForTesting final FeatureRequestor requestor; - private final DiagnosticAccumulator diagnosticAccumulator; - private final EventSourceCreator eventSourceCreator; - 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 - - public static interface EventSourceCreator { - EventSource createEventSource(EventHandler handler, URI streamUri, long initialReconnectDelayMillis, - ConnectionErrorHandler errorHandler, Headers headers, HttpConfiguration httpConfig); - } - - StreamProcessor( - String sdkKey, - HttpConfiguration httpConfig, - FeatureRequestor requestor, - FeatureStore featureStore, - EventSourceCreator eventSourceCreator, - DiagnosticAccumulator diagnosticAccumulator, - URI streamUri, - long initialReconnectDelayMillis - ) { - this.store = featureStore; - this.httpConfig = httpConfig; - this.requestor = requestor; - this.diagnosticAccumulator = diagnosticAccumulator; - this.eventSourceCreator = eventSourceCreator != null ? eventSourceCreator : new DefaultEventSourceCreator(); - this.streamUri = streamUri; - this.initialReconnectDelayMillis = initialReconnectDelayMillis; - - this.headers = getHeadersBuilderFor(sdkKey, httpConfig) - .add("Accept", "text/event-stream") - .build(); - } - - 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")); - if (!isHttpErrorRecoverable(status)) { - return Action.SHUTDOWN; - } - } - esStarted = System.currentTimeMillis(); - return Action.PROCEED; - } - }; - } - - @Override - public Future start() { - final SettableFuture initFuture = SettableFuture.create(); - - ConnectionErrorHandler wrappedConnectionErrorHandler = new ConnectionErrorHandler() { - @Override - public Action onConnectionError(Throwable t) { - 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 result; - } - }; - - EventHandler handler = new EventHandler() { - - @Override - public void onOpen() throws Exception { - } - - @Override - public void onClosed() throws Exception { - } - - @Override - 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; - } - 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; - } - 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; - } - 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."); - } - break; - case INDIRECT_PATCH: - String path = event.getData(); - Map.Entry, String> kindAndKey = getKindAndKeyFromStreamApiPath(path); - if (kindAndKey == null) { - 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); - } - } - - @Override - public void onComment(String comment) { - logger.debug("Received a heartbeat"); - } - - @Override - public void onError(Throwable throwable) { - logger.warn("Encountered EventSource error: {}", throwable.toString()); - logger.debug(throwable.toString(), throwable); - } - }; - - es = eventSourceCreator.createEventSource(handler, - URI.create(streamUri.toASCIIString() + "/all"), - initialReconnectDelayMillis, - wrappedConnectionErrorHandler, - headers, - httpConfig); - 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"); - if (es != null) { - es.close(); - } - if (store != null) { - store.close(); - } - requestor.close(); - } - - @Override - 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; - - @SuppressWarnings("unused") // used by Gson - public PutData() { } - } - - private static final class PatchData { - String path; - JsonElement data; - - @SuppressWarnings("unused") // used by Gson - public PatchData() { } - } - - private static final class DeleteData { - String path; - int version; - - @SuppressWarnings("unused") // used by Gson - public DeleteData() { } - } - - private class DefaultEventSourceCreator implements EventSourceCreator { - 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(httpConfig, builder); - } - }) - .connectionErrorHandler(errorHandler) - .headers(headers) - .reconnectTimeMs(initialReconnectDelayMillis) - .readTimeoutMs(DEAD_CONNECTION_INTERVAL_MS) - .connectTimeoutMs(EventSource.DEFAULT_CONNECT_TIMEOUT_MS) - .writeTimeoutMs(EventSource.DEFAULT_WRITE_TIMEOUT_MS); - // Note that this is not the same read timeout that can be set in LDConfig. We default to a smaller one - // there because we don't expect long delays within any *non*-streaming response that the LD client gets. - // A read timeout on the stream will result in the connection being cycled, so we set this to be slightly - // more than the expected interval between heartbeat signals. - - return builder.build(); - } - } -} diff --git a/src/main/java/com/launchdarkly/client/Target.java b/src/main/java/com/launchdarkly/client/Target.java deleted file mode 100644 index 54eb154da..000000000 --- a/src/main/java/com/launchdarkly/client/Target.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.launchdarkly.client; - -import java.util.Set; - -class Target { - private Set values; - private int variation; - - // We need this so Gson doesn't complain in certain java environments that restrict unsafe allocation - Target() {} - - Target(Set values, int variation) { - this.values = values; - this.variation = variation; - } - - Set getValues() { - return values; - } - - int getVariation() { - return variation; - } -} diff --git a/src/main/java/com/launchdarkly/client/TestFeatureStore.java b/src/main/java/com/launchdarkly/client/TestFeatureStore.java deleted file mode 100644 index e2725147d..000000000 --- a/src/main/java/com/launchdarkly/client/TestFeatureStore.java +++ /dev/null @@ -1,133 +0,0 @@ -package com.launchdarkly.client; - -import static com.launchdarkly.client.VersionedDataKind.FEATURES; - -import java.util.Arrays; -import java.util.List; -import java.util.Map; -import java.util.concurrent.atomic.AtomicInteger; - -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. - *

    - * Using this store is useful for testing purposes when you want to have runtime support for turning specific features on or off. - * - * @deprecated Will be replaced by a file-based test fixture. - */ -@Deprecated -public class TestFeatureStore extends InMemoryFeatureStore { - static List TRUE_FALSE_VARIATIONS = Arrays.asList(LDValue.of(true), LDValue.of(false)); - - private AtomicInteger version = new AtomicInteger(0); - private volatile boolean initializedForTests = false; - - /** - * Sets the value of a boolean feature flag for all users. - * - * @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) - .on(false) - .offVariation(value ? 0 : 1) - .variations(TRUE_FALSE_VARIATIONS) - .version(version.incrementAndGet()) - .build(); - upsert(FEATURES, newFeature); - return newFeature; - } - - /** - * Turns a feature, identified by key, to evaluate to true for every user. If the feature rules already exist in the store then it will override it to be true for every {@link LDUser}. - * 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); - } - - /** - * Turns a feature, identified by key, to evaluate to false for every user. If the feature rules already exist in the store then it will override it to be false for every {@link LDUser}. - * 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); - } - - /** - * 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)); - } - - /** - * 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)); - } - - /** - * 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)); - } - - /** - * 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) - .on(false) - .offVariation(0) - .variations(Arrays.asList(LDValue.fromJsonElement(value))) - .version(version.incrementAndGet()) - .build(); - upsert(FEATURES, newFeature); - return newFeature; - } - - @Override - public void init(Map, Map> allData) { - super.init(allData); - initializedForTests = true; - } - - @Override - 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 deleted file mode 100644 index 52bd712ce..000000000 --- a/src/main/java/com/launchdarkly/client/UpdateProcessor.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.launchdarkly.client; - -import java.io.Closeable; -import java.io.IOException; -import java.util.concurrent.Future; - -import static com.google.common.util.concurrent.Futures.immediateFuture; - -/** - * Interface for an object that receives updates to feature flags, user segments, and anything - * else that might come from LaunchDarkly, and passes them to a {@link FeatureStore}. - * @since 4.0.0 - */ -public interface UpdateProcessor extends Closeable { - /** - * Starts the client. - * @return {@link Future}'s completion status indicates the client has been initialized. - */ - Future start(); - - /** - * Returns true once the client has been initialized and will never return false again. - * @return true if the client has been initialized - */ - 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#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 - public Future start() { - return immediateFuture(null); - } - - @Override - public boolean initialized() { - return true; - } - - @Override - public void close() throws IOException {} - } -} diff --git a/src/main/java/com/launchdarkly/client/UpdateProcessorFactory.java b/src/main/java/com/launchdarkly/client/UpdateProcessorFactory.java deleted file mode 100644 index 1b3a73e8d..000000000 --- a/src/main/java/com/launchdarkly/client/UpdateProcessorFactory.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.launchdarkly.client; - -/** - * Interface for a factory that creates some implementation of {@link UpdateProcessor}. - * @see Components - * @since 4.0.0 - */ -public interface UpdateProcessorFactory { - /** - * Creates an implementation instance. - * @param sdkKey the SDK key for your LaunchDarkly environment - * @param config the LaunchDarkly configuration - * @param featureStore the {@link FeatureStore} to use for storing the latest flag state - * @return an {@link UpdateProcessor} - */ - public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, FeatureStore featureStore); -} diff --git a/src/main/java/com/launchdarkly/client/UpdateProcessorFactoryWithDiagnostics.java b/src/main/java/com/launchdarkly/client/UpdateProcessorFactoryWithDiagnostics.java deleted file mode 100644 index a7a63bb31..000000000 --- a/src/main/java/com/launchdarkly/client/UpdateProcessorFactoryWithDiagnostics.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.launchdarkly.client; - -interface UpdateProcessorFactoryWithDiagnostics extends UpdateProcessorFactory { - UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, FeatureStore featureStore, - DiagnosticAccumulator diagnosticAccumulator); -} diff --git a/src/main/java/com/launchdarkly/client/UserAttribute.java b/src/main/java/com/launchdarkly/client/UserAttribute.java deleted file mode 100644 index 1da2e02a5..000000000 --- a/src/main/java/com/launchdarkly/client/UserAttribute.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.launchdarkly.client; - -import com.launchdarkly.client.value.LDValue; - -enum UserAttribute { - key { - LDValue get(LDUser user) { - return user.getKey(); - } - }, - secondary { - LDValue get(LDUser user) { - return null; //Not used for evaluation. - } - }, - ip { - LDValue get(LDUser user) { - return user.getIp(); - } - }, - email { - LDValue get(LDUser user) { - return user.getEmail(); - } - }, - avatar { - LDValue get(LDUser user) { - return user.getAvatar(); - } - }, - firstName { - LDValue get(LDUser user) { - return user.getFirstName(); - } - }, - lastName { - LDValue get(LDUser user) { - return user.getLastName(); - } - }, - name { - LDValue get(LDUser user) { - return user.getName(); - } - }, - country { - LDValue get(LDUser user) { - return user.getCountry(); - } - }, - anonymous { - LDValue get(LDUser user) { - return user.getAnonymous(); - } - }; - - /** - * Gets value for Rule evaluation for a user. - * - * @param user - * @return - */ - abstract LDValue get(LDUser user); -} diff --git a/src/main/java/com/launchdarkly/client/VariationOrRollout.java b/src/main/java/com/launchdarkly/client/VariationOrRollout.java deleted file mode 100644 index e3f0ad29b..000000000 --- a/src/main/java/com/launchdarkly/client/VariationOrRollout.java +++ /dev/null @@ -1,127 +0,0 @@ -package com.launchdarkly.client; - - -import com.launchdarkly.client.value.LDValue; - -import org.apache.commons.codec.digest.DigestUtils; - -import java.util.List; - -/** - * Contains either a fixed variation or percent rollout to serve. - * Invariant: one of the variation or rollout must be non-nil. - */ -class VariationOrRollout { - private static final float long_scale = (float) 0xFFFFFFFFFFFFFFFL; - - private Integer variation; - private Rollout rollout; - - // We need this so Gson doesn't complain in certain java environments that restrict unsafe allocation - VariationOrRollout() {} - - VariationOrRollout(Integer variation, Rollout rollout) { - this.variation = variation; - 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) { - if (variation != null) { - return variation; - } 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; - for (WeightedVariation wv : rollout.variations) { - sum += (float) wv.weight / 100000F; - if (bucket < sum) { - 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; - } - - static float bucketUser(LDUser user, String key, String attr, String salt) { - LDValue userValue = user.getValueForEvaluation(attr); - String idHash = getBucketableStringValue(userValue); - if (idHash != null) { - 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); - return (float) longVal / long_scale; - } - return 0F; - } - - 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; - } - } - - static final class Rollout { - private List variations; - private String bucketBy; - - // We need this so Gson doesn't complain in certain java environments that restrict unsafe allocation - Rollout() {} - - Rollout(List variations, String bucketBy) { - this.variations = variations; - this.bucketBy = bucketBy; - } - - List getVariations() { - return variations; - } - - String getBucketBy() { - return bucketBy; - } - } - - static final class WeightedVariation { - private int variation; - private int weight; - - // We need this so Gson doesn't complain in certain java environments that restrict unsafe allocation - WeightedVariation() {} - - WeightedVariation(int variation, int weight) { - this.variation = variation; - this.weight = weight; - } - - int getVariation() { - return variation; - } - - int getWeight() { - return weight; - } - } -} diff --git a/src/main/java/com/launchdarkly/client/VersionedData.java b/src/main/java/com/launchdarkly/client/VersionedData.java deleted file mode 100644 index 98bd19c34..000000000 --- a/src/main/java/com/launchdarkly/client/VersionedData.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.launchdarkly.client; - -/** - * Common interface for string-keyed, versioned objects that can be kept in a {@link FeatureStore}. - * @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/VersionedDataKind.java b/src/main/java/com/launchdarkly/client/VersionedDataKind.java deleted file mode 100644 index 16cf1badc..000000000 --- a/src/main/java/com/launchdarkly/client/VersionedDataKind.java +++ /dev/null @@ -1,168 +0,0 @@ -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} - * or writing your own {@code FeatureStore} implementation. If you are implementing a custom store, for - * 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 { - - /** - * A short string that serves as the unique name for the collection of these objects, e.g. "features". - * @return a namespace string - */ - public abstract String getNamespace(); - - /** - * The Java class for objects of this type. - * @return a Java class - */ - public abstract Class getItemClass(); - - /** - * The path prefix for objects of this type in events received from the streaming API. - * @return the URL path - */ - public abstract String getStreamApiPath(); - - /** - * Return an instance of this type with the specified key and version, and deleted=true. - * @param key the unique key - * @param version the version number - * @return a new instance - */ - 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 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. - * - * @param item the item - * @return keys of dependencies of the item - * @since 4.7.0 - */ - public Iterable getDependencyKeys(VersionedData item) { - return ImmutableList.of(); - } - - @Override - public String toString() { - return "{" + getNamespace() + "}"; - } - - /** - * Used internally to match data URLs in the streaming API. - * @param path path from an API message - * @return the parsed key if the path refers to an object of this kind, otherwise null - */ - String getKeyFromStreamApiPath(String path) { - return path.startsWith(getStreamApiPath()) ? path.substring(getStreamApiPath().length()) : null; - } - - 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 namespace; - } - - public Class getItemClass() { - return itemClass; - } - - public String getStreamApiPath() { - 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 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(); - } - }); - } - }; - - /** - * The {@link VersionedDataKind} instance that describes user segment data. - */ - 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(); - } - }; - - /** - * A list of all existing instances of {@link VersionedDataKind}. - * @since 4.1.0 - */ - public static Iterable> ALL = ImmutableList.of(FEATURES, SEGMENTS); -} diff --git a/src/main/java/com/launchdarkly/client/files/FileComponents.java b/src/main/java/com/launchdarkly/client/files/FileComponents.java deleted file mode 100644 index 63a575555..000000000 --- a/src/main/java/com/launchdarkly/client/files/FileComponents.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.launchdarkly.client.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 - * source. - * @return a {@link FileDataSourceFactory} - */ - public static FileDataSourceFactory fileDataSource() { - return new FileDataSourceFactory(); - } -} diff --git a/src/main/java/com/launchdarkly/client/files/FileDataSourceFactory.java b/src/main/java/com/launchdarkly/client/files/FileDataSourceFactory.java deleted file mode 100644 index ded4a2dd5..000000000 --- a/src/main/java/com/launchdarkly/client/files/FileDataSourceFactory.java +++ /dev/null @@ -1,75 +0,0 @@ -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 com.launchdarkly.client.integrations.FileDataSourceBuilder; -import com.launchdarkly.client.integrations.FileData; - -import java.nio.file.InvalidPathException; -import java.nio.file.Path; - -/** - * 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 FileDataSourceBuilder wrappedBuilder = new FileDataSourceBuilder(); - - /** - * 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 { - wrappedBuilder.filePaths(filePaths); - 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) { - wrappedBuilder.filePaths(filePaths); - 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 FileDataSourceFactory autoUpdate(boolean autoUpdate) { - wrappedBuilder.autoUpdate(autoUpdate); - return this; - } - - /** - * Used internally by the LaunchDarkly client. - */ - @Override - public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, FeatureStore featureStore) { - return wrappedBuilder.createUpdateProcessor(sdkKey, config, featureStore); - } -} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/files/package-info.java b/src/main/java/com/launchdarkly/client/files/package-info.java deleted file mode 100644 index da8abb785..000000000 --- a/src/main/java/com/launchdarkly/client/files/package-info.java +++ /dev/null @@ -1,4 +0,0 @@ -/** - * 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/CacheMonitor.java b/src/main/java/com/launchdarkly/client/integrations/CacheMonitor.java deleted file mode 100644 index 977982d9f..000000000 --- a/src/main/java/com/launchdarkly/client/integrations/CacheMonitor.java +++ /dev/null @@ -1,151 +0,0 @@ -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 PersistentDataStoreBuilder#cacheMonitor(CacheMonitor) - * @since 4.12.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 - * @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; - } - - /** - * 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. - * - * @since 4.12.0 - */ - 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 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) { - 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/Redis.java b/src/main/java/com/launchdarkly/client/integrations/Redis.java deleted file mode 100644 index 90bed1e00..000000000 --- a/src/main/java/com/launchdarkly/client/integrations/Redis.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.launchdarkly.client.integrations; - -/** - * Integration between the LaunchDarkly SDK and Redis. - * - * @since 4.12.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 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 - */ - 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 cf65e012c..000000000 --- a/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java +++ /dev/null @@ -1,185 +0,0 @@ -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; -import com.launchdarkly.client.utils.FeatureStoreCore; -import com.launchdarkly.client.value.LDValue; - -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 #createPersistentDataStore()} 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(
    -   *             Components.persistentDataStore(
    -   *                 Redis.dataStore()
    -   *                     .url("redis://my-redis-host")
    -   *                     .database(1)
    -   *             ).cacheSeconds(15)
    -   *         )
    -   *         .build();
    - * 
    - * - * @since 4.12.0 - */ -public final class RedisDataStoreBuilder implements PersistentDataStoreFactory, DiagnosticDescription { - /** - * The default value for the Redis URI: {@code redis://localhost:6379} - */ - public static final URI DEFAULT_URI = makeDefaultRedisURI(); - - /** - * 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; - 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() { - } - - /** - * 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(redisUri); - 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; - } - - @Override - public FeatureStoreCore createPersistentDataStore() { - return new RedisDataStoreImpl(this); - } - - @Override - public LDValue describeConfiguration(LDConfig config) { - return LDValue.of("Redis"); - } -} 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 c81a02762..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; - -final 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 079858106..000000000 --- a/src/main/java/com/launchdarkly/client/integrations/package-info.java +++ /dev/null @@ -1,13 +0,0 @@ -/** - * 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} - * (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/main/java/com/launchdarkly/client/interfaces/PersistentDataStoreFactory.java b/src/main/java/com/launchdarkly/client/interfaces/PersistentDataStoreFactory.java deleted file mode 100644 index 16a5b5544..000000000 --- a/src/main/java/com/launchdarkly/client/interfaces/PersistentDataStoreFactory.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.launchdarkly.client.interfaces; - -import com.launchdarkly.client.integrations.PersistentDataStoreBuilder; -import com.launchdarkly.client.utils.FeatureStoreCore; - -/** - * Interface for a factory that creates some implementation of a persistent data store. - *

    - * This interface is implemented by database integrations. Usage is described in - * {@link com.launchdarkly.client.Components#persistentDataStore}. - * - * @see com.launchdarkly.client.Components - * @since 4.12.0 - */ -public interface PersistentDataStoreFactory { - /** - * Called internally from {@link PersistentDataStoreBuilder} to create the implementation object - * for the specific type of data store. - * - * @return the implementation object - * @deprecated Do not reference this method directly, as the {@link FeatureStoreCore} interface - * will be replaced in 5.0. - */ - @Deprecated - FeatureStoreCore createPersistentDataStore(); -} diff --git a/src/main/java/com/launchdarkly/client/package-info.java b/src/main/java/com/launchdarkly/client/package-info.java deleted file mode 100644 index 14ba4590e..000000000 --- a/src/main/java/com/launchdarkly/client/package-info.java +++ /dev/null @@ -1,8 +0,0 @@ -/** - * 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/CachingStoreWrapper.java b/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java deleted file mode 100644 index a4c7853ce..000000000 --- a/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java +++ /dev/null @@ -1,391 +0,0 @@ -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.collect.ImmutableMap; -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.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; -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. 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(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. - */ -@Deprecated -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 initCache; - private final AtomicBoolean inited = new AtomicBoolean(false); - private final ListeningExecutorService executorService; - - /** - * Creates a new builder. - * @param core the {@link FeatureStoreCore} instance - * @return the builder - */ - public static CachingStoreWrapper.Builder builder(FeatureStoreCore core) { - return new Builder(core); - } - - protected CachingStoreWrapper(final FeatureStoreCore core, FeatureStoreCacheConfig caching, CacheMonitor cacheMonitor) { - this.core = core; - this.caching = caching; - - if (!caching.isEnabled()) { - 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, ImmutableMap> allLoader = new CacheLoader, ImmutableMap>() { - @Override - public ImmutableMap 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 (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); - - // 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; - } - - 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 { - // 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 - 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) { - return (T)itemOnlyIfNotDeleted(cachedItem.orNull()); - } - } - return (T)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 itemsOnlyIfNotDeleted(core.getAllInternal(kind)); - } - - @SuppressWarnings("unchecked") - @Override - 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; - } - - 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())); - } - } - } - - @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 = 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); - } - } - } - if (failure != null) { - throw failure; - } - } - - @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; - } - - /** - * Return the underlying implementation object. - * - * @return the underlying implementation object - */ - public FeatureStoreCore getCore() { - return core; - } - - private VersionedData itemOnlyIfNotDeleted(VersionedData item) { - return (item != null && item.isDeleted()) ? null : item; - } - - @SuppressWarnings("unchecked") - private ImmutableMap itemsOnlyIfNotDeleted(Map items) { - ImmutableMap.Builder builder = ImmutableMap.builder(); - if (items != null) { - for (Map.Entry item: items.entrySet()) { - if (!item.getValue().isDeleted()) { - builder.put(item.getKey(), (T) item.getValue()); - } - } - } - 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; - - 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 FeatureStoreCacheConfig caching = FeatureStoreCacheConfig.DEFAULT; - private CacheMonitor cacheMonitor = null; - - Builder(FeatureStoreCore core) { - this.core = core; - } - - /** - * Sets the local caching properties. - * @param caching a {@link FeatureStoreCacheConfig} object specifying cache parameters - * @return the builder - */ - public Builder caching(FeatureStoreCacheConfig caching) { - this.caching = 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, cacheMonitor); - } - } -} diff --git a/src/main/java/com/launchdarkly/client/utils/FeatureStoreCore.java b/src/main/java/com/launchdarkly/client/utils/FeatureStoreCore.java deleted file mode 100644 index b4d2e3066..000000000 --- a/src/main/java/com/launchdarkly/client/utils/FeatureStoreCore.java +++ /dev/null @@ -1,86 +0,0 @@ -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. - *

    - * 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 - */ -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 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 - */ - 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 kind the kind of objects to get - * @return a map of all associated objects. - */ - 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 - * 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 - */ - 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 kind the kind of object to update - * @param item the object to update or insert - * @return the state of the object after the update - */ - 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, 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; CachingStoreWrapper 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 deleted file mode 100644 index e49cbb7c7..000000000 --- a/src/main/java/com/launchdarkly/client/utils/FeatureStoreHelpers.java +++ /dev/null @@ -1,61 +0,0 @@ -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; -import com.launchdarkly.client.interfaces.SerializationException; - -/** - * 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(); - - /** - * 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 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 - * @throws UnmarshalException if the JSON string was invalid - */ - public static T unmarshalJson(VersionedDataKind kind, String data) { - try { - return gson.fromJson(data, kind.getItemClass()); - } catch (JsonParseException e) { - throw new UnmarshalException(e); - } - } - - /** - * 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); - } - - /** - * Thrown by {@link FeatureStoreHelpers#unmarshalJson(VersionedDataKind, String)} for a deserialization error. - */ - @SuppressWarnings("serial") - public static class UnmarshalException extends SerializationException { - /** - * Constructs an instance. - * @param cause the underlying exception - */ - public UnmarshalException(Throwable cause) { - super(cause); - } - } -} diff --git a/src/main/java/com/launchdarkly/client/utils/package-info.java b/src/main/java/com/launchdarkly/client/utils/package-info.java deleted file mode 100644 index 5be71fa92..000000000 --- a/src/main/java/com/launchdarkly/client/utils/package-info.java +++ /dev/null @@ -1,4 +0,0 @@ -/** - * Helper classes that may be useful in custom integrations. - */ -package com.launchdarkly.client.utils; diff --git a/src/main/java/com/launchdarkly/client/value/ArrayBuilder.java b/src/main/java/com/launchdarkly/client/value/ArrayBuilder.java deleted file mode 100644 index e68b7a204..000000000 --- a/src/main/java/com/launchdarkly/client/value/ArrayBuilder.java +++ /dev/null @@ -1,86 +0,0 @@ -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; - } - - /** - * 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 - * 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 deleted file mode 100644 index 996e7b41d..000000000 --- a/src/main/java/com/launchdarkly/client/value/LDValue.java +++ /dev/null @@ -1,672 +0,0 @@ -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; -import com.google.gson.stream.JsonWriter; -import com.launchdarkly.client.LDClientInterface; -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. - *

    - * 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. - *

    - * 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. - * - * @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():
    -   * 
    - * 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() { - return new ArrayBuilder(); - } - - /** - * 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() { - 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 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. - * - * @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; - } - - /** - * 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. - * - * @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(); - } - - /** - * 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 the desired type - * @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). - * - * @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()}. - */ - @Deprecated - 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; - } - } - - /** - * 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<Integer> listOfInts = ImmutableList.<Integer>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<String, Integer> mapOfInts = ImmutableMap.<String, Integer>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 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. - */ - 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/main/java/com/launchdarkly/client/value/LDValueArray.java b/src/main/java/com/launchdarkly/client/value/LDValueArray.java deleted file mode 100644 index 250863121..000000000 --- a/src/main/java/com/launchdarkly/client/value/LDValueArray.java +++ /dev/null @@ -1,64 +0,0 @@ -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 deleted file mode 100644 index 321361353..000000000 --- a/src/main/java/com/launchdarkly/client/value/LDValueBool.java +++ /dev/null @@ -1,50 +0,0 @@ -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 deleted file mode 100644 index 34f0cb8bb..000000000 --- a/src/main/java/com/launchdarkly/client/value/LDValueJsonElement.java +++ /dev/null @@ -1,206 +0,0 @@ -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 long longValue() { - return type == LDValueType.NUMBER ? (long)value.getAsDouble() : 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 boolean isString() { - return type == LDValueType.STRING; - } - - @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 deleted file mode 100644 index 00db72c34..000000000 --- a/src/main/java/com/launchdarkly/client/value/LDValueNull.java +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index 6a601c3f0..000000000 --- a/src/main/java/com/launchdarkly/client/value/LDValueNumber.java +++ /dev/null @@ -1,75 +0,0 @@ -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 long longValue() { - return (long)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 deleted file mode 100644 index eaceb5a7a..000000000 --- a/src/main/java/com/launchdarkly/client/value/LDValueObject.java +++ /dev/null @@ -1,69 +0,0 @@ -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 deleted file mode 100644 index b2ad2c789..000000000 --- a/src/main/java/com/launchdarkly/client/value/LDValueString.java +++ /dev/null @@ -1,46 +0,0 @@ -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 boolean isString() { - return true; - } - - @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 deleted file mode 100644 index d7e3ff7f4..000000000 --- a/src/main/java/com/launchdarkly/client/value/LDValueType.java +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index 72c50b960..000000000 --- a/src/main/java/com/launchdarkly/client/value/LDValueTypeAdapter.java +++ /dev/null @@ -1,53 +0,0 @@ -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 deleted file mode 100644 index 1027652d9..000000000 --- a/src/main/java/com/launchdarkly/client/value/ObjectBuilder.java +++ /dev/null @@ -1,104 +0,0 @@ -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; - } - - /** - * 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 - * 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/main/java/com/launchdarkly/client/value/package-info.java b/src/main/java/com/launchdarkly/client/value/package-info.java deleted file mode 100644 index 59e453f22..000000000 --- a/src/main/java/com/launchdarkly/client/value/package-info.java +++ /dev/null @@ -1,4 +0,0 @@ -/** - * Provides the {@link com.launchdarkly.client.value.LDValue} abstraction for supported data types. - */ -package com.launchdarkly.client.value; diff --git a/src/main/java/com/launchdarkly/sdk/json/SdkSerializationExtensions.java b/src/main/java/com/launchdarkly/sdk/json/SdkSerializationExtensions.java new file mode 100644 index 000000000..cf3dac109 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/json/SdkSerializationExtensions.java @@ -0,0 +1,14 @@ +package com.launchdarkly.sdk.json; + +import com.google.common.collect.ImmutableList; +import com.launchdarkly.sdk.server.FeatureFlagsState; + +// See JsonSerialization.getDeserializableClasses in java-sdk-common. + +class SdkSerializationExtensions { + public static Iterable> getDeserializableClasses() { + return ImmutableList.>of( + FeatureFlagsState.class + ); + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java b/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java new file mode 100644 index 000000000..eae023c78 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java @@ -0,0 +1,64 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.interfaces.ClientContext; +import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; + +final class ClientContextImpl implements ClientContext { + private final String sdkKey; + private final HttpConfiguration httpConfiguration; + private final boolean offline; + private final DiagnosticAccumulator diagnosticAccumulator; + private final DiagnosticEvent.Init diagnosticInitEvent; + + ClientContextImpl(String sdkKey, LDConfig configuration, DiagnosticAccumulator diagnosticAccumulator) { + this.sdkKey = sdkKey; + this.httpConfiguration = configuration.httpConfig; + this.offline = configuration.offline; + if (!configuration.diagnosticOptOut && diagnosticAccumulator != null) { + this.diagnosticAccumulator = diagnosticAccumulator; + this.diagnosticInitEvent = new DiagnosticEvent.Init(diagnosticAccumulator.dataSinceDate, diagnosticAccumulator.diagnosticId, configuration); + } else { + this.diagnosticAccumulator = null; + this.diagnosticInitEvent = null; + } + } + + @Override + public String getSdkKey() { + return sdkKey; + } + + @Override + public boolean isOffline() { + return offline; + } + + @Override + public HttpConfiguration getHttpConfiguration() { + return httpConfiguration; + } + + // Note that the following two properties are package-private - they are only used by SDK internal components, + // not any custom components implemented by an application. + DiagnosticAccumulator getDiagnosticAccumulator() { + return diagnosticAccumulator; + } + + DiagnosticEvent.Init getDiagnosticInitEvent() { + return diagnosticInitEvent; + } + + static DiagnosticAccumulator getDiagnosticAccumulator(ClientContext context) { + if (context instanceof ClientContextImpl) { + return ((ClientContextImpl)context).getDiagnosticAccumulator(); + } + return null; + } + + static DiagnosticEvent.Init getDiagnosticInitEvent(ClientContext context) { + if (context instanceof ClientContextImpl) { + return ((ClientContextImpl)context).getDiagnosticInitEvent(); + } + return null; + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/Components.java b/src/main/java/com/launchdarkly/sdk/server/Components.java new file mode 100644 index 000000000..6ffdd37c7 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/Components.java @@ -0,0 +1,555 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.DiagnosticEvent.ConfigProperty; +import com.launchdarkly.sdk.server.integrations.EventProcessorBuilder; +import com.launchdarkly.sdk.server.integrations.HttpConfigurationBuilder; +import com.launchdarkly.sdk.server.integrations.PersistentDataStoreBuilder; +import com.launchdarkly.sdk.server.integrations.PollingDataSourceBuilder; +import com.launchdarkly.sdk.server.integrations.StreamingDataSourceBuilder; +import com.launchdarkly.sdk.server.interfaces.ClientContext; +import com.launchdarkly.sdk.server.interfaces.DataSource; +import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; +import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; +import com.launchdarkly.sdk.server.interfaces.DiagnosticDescription; +import com.launchdarkly.sdk.server.interfaces.Event; +import com.launchdarkly.sdk.server.interfaces.EventProcessor; +import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; +import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; +import com.launchdarkly.sdk.server.interfaces.FlagValueChangeListener; +import com.launchdarkly.sdk.server.interfaces.HttpAuthentication; +import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; +import com.launchdarkly.sdk.server.interfaces.PersistentDataStoreFactory; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.net.URI; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Future; + +import okhttp3.Credentials; + +/** + * 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#initialReconnectDelay(java.time.Duration)}, + * and then use the corresponding method in {@link LDConfig.Builder} (such as {@link LDConfig.Builder#dataSource(DataSourceFactory)}) + * to use that configured component in the SDK. + * + * @since 4.0.0 + */ +public abstract class Components { + /** + * 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. + * + * @return a factory object + * @see LDConfig.Builder#dataStore(DataStoreFactory) + * @since 4.12.0 + */ + public static DataStoreFactory inMemoryDataStore() { + return InMemoryDataStoreFactory.INSTANCE; + } + + /** + * 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 + * 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()
    +   *         .dataStore(
    +   *             Components.persistentDataStore(
    +   *                 Redis.dataStore().url("redis://my-redis-host")
    +   *             ).cacheSeconds(15)
    +   *         )
    +   *         .build();
    +   * 
    + * + * See {@link PersistentDataStoreBuilder} for more on how this method is used. + *

    + * For more information on the available persistent data store implementations, see the reference + * guide on Using a persistent feature store. + * + * @param storeFactory the factory/builder for the specific kind of persistent data store + * @return a {@link PersistentDataStoreBuilder} + * @see LDConfig.Builder#dataStore(DataStoreFactory) + * @since 4.12.0 + */ + public static PersistentDataStoreBuilder persistentDataStore(PersistentDataStoreFactory storeFactory) { + return new PersistentDataStoreBuilderImpl(storeFactory); + } + + /** + * 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()
    +   *         .events(Components.sendEvents().capacity(5000).flushIntervalSeconds(2))
    +   *         .build();
    +   * 
    + * To completely disable sending analytics events, use {@link #noEvents()} instead. + *

    + * Setting {@link LDConfig.Builder#offline(boolean)} to {@code true} will supersede this setting and completely + * disable network requests. + * + * @return a builder for setting streaming connection properties + * @see #noEvents() + * @see LDConfig.Builder#events + * @since 4.12.0 + */ + public static EventProcessorBuilder sendEvents() { + return new EventProcessorBuilderImpl(); + } + + /** + * 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()
    +   *         .events(Components.noEvents())
    +   *         .build();
    +   * 
    + * + * @return a factory object + * @see #sendEvents() + * @see LDConfig.Builder#events(EventProcessorFactory) + * @since 4.12.0 + */ + public static EventProcessorFactory noEvents() { + return NULL_EVENT_PROCESSOR_FACTORY; + } + + /** + * 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 + * 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(DataSourceFactory)}: + *

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

    + * Setting {@link LDConfig.Builder#offline(boolean)} to {@code true} will supersede this setting and completely + * disable network requests. + * + * @return a builder for setting streaming connection properties + * @see LDConfig.Builder#dataSource(DataSourceFactory) + * @since 4.12.0 + */ + public static StreamingDataSourceBuilder streamingDataSource() { + return new StreamingDataSourceBuilderImpl(); + } + + /** + * 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(DataSourceFactory)}: + *

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

    + * Setting {@link LDConfig.Builder#offline(boolean)} to {@code true} will supersede this setting and completely + * disable network requests. + * + * @return a builder for setting polling properties + * @see LDConfig.Builder#dataSource(DataSourceFactory) + * @since 4.12.0 + */ + public static PollingDataSourceBuilder pollingDataSource() { + return new PollingDataSourceBuilderImpl(); + } + + /** + * Returns a configuration object that disables a direct connection with LaunchDarkly for feature flag updates. + *

    + * Passing this to {@link LDConfig.Builder#dataSource(DataSourceFactory)} 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 DataSourceFactory}, but in a future version it + * will be renamed to {@code DataSourceFactory}.) + * + * @return a factory object + * @since 4.12.0 + * @see LDConfig.Builder#dataSource(DataSourceFactory) + */ + public static DataSourceFactory externalUpdatesOnly() { + return NullDataSourceFactory.INSTANCE; + } + + /** + * Returns a configurable factory for the SDK's networking configuration. + *

    + * Passing this to {@link LDConfig.Builder#http(com.launchdarkly.sdk.server.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();
    +   * 
    + * + * @return a factory object + * @since 4.13.0 + * @see LDConfig.Builder#http(com.launchdarkly.sdk.server.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); + } + + /** + * Convenience method for creating a {@link FlagChangeListener} that tracks a flag's value for a specific user. + *

    + * This listener instance should only be used with a single {@link LDClient} instance. When you first + * register it by calling {@link LDClientInterface#registerFlagChangeListener(FlagChangeListener)}, it + * immediately evaluates the flag. It then re-evaluates the flag whenever there is an update, and calls + * your {@link FlagValueChangeListener} if and only if the resulting value has changed. + *

    + * See {@link FlagValueChangeListener} for more information and examples. + * + * @param client the same client instance that you will be registering this listener with + * @param flagKey the flag key to be evaluated + * @param user the user properties for evaluation + * @param valueChangeListener an object that you provide which will be notified of changes + * @return a {@link FlagChangeListener} to be passed to {@link LDClientInterface#registerFlagChangeListener(FlagChangeListener)} + * + * @since 5.0.0 + * @see FlagValueChangeListener + * @see FlagChangeListener + */ + public static FlagChangeListener flagValueMonitoringListener(LDClientInterface client, String flagKey, LDUser user, + FlagValueChangeListener valueChangeListener) { + return new FlagValueMonitoringListener(client, flagKey, user, valueChangeListener); + } + + private static final class InMemoryDataStoreFactory implements DataStoreFactory, DiagnosticDescription { + static final DataStoreFactory INSTANCE = new InMemoryDataStoreFactory(); + @Override + public DataStore createDataStore(ClientContext context) { + return new InMemoryDataStore(); + } + + @Override + public LDValue describeConfiguration(LDConfig config) { + return LDValue.of("memory"); + } + } + + private static final EventProcessorFactory NULL_EVENT_PROCESSOR_FACTORY = context -> NullEventProcessor.INSTANCE; + + /** + * Stub implementation of {@link EventProcessor} for when we don't want to send any events. + */ + static final class NullEventProcessor implements EventProcessor { + static final NullEventProcessor INSTANCE = new NullEventProcessor(); + + private NullEventProcessor() {} + + @Override + public void sendEvent(Event e) { + } + + @Override + public void flush() { + } + + @Override + public void close() { + } + } + + private static final class NullDataSourceFactory implements DataSourceFactory, DiagnosticDescription { + static final NullDataSourceFactory INSTANCE = new NullDataSourceFactory(); + + @Override + public DataSource createDataSource(ClientContext context, DataStoreUpdates dataStoreUpdates) { + if (context.isOffline()) { + // 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 NullDataSource.INSTANCE; + } + + @Override + public LDValue describeConfiguration(LDConfig config) { + // We can assume that if they don't have a data source, and they *do* have a persistent data store, then + // they're using Relay in daemon mode. + return LDValue.buildObject() + .put(ConfigProperty.CUSTOM_BASE_URI.name, false) + .put(ConfigProperty.CUSTOM_STREAM_URI.name, false) + .put(ConfigProperty.STREAMING_DISABLED.name, false) + .put(ConfigProperty.USING_RELAY_DAEMON.name, + config.dataStoreFactory != null && config.dataStoreFactory != Components.inMemoryDataStore()) + .build(); + } + } + + // Package-private for visibility in tests + static final class NullDataSource implements DataSource { + static final DataSource INSTANCE = new NullDataSource(); + @Override + public Future start() { + return CompletableFuture.completedFuture(null); + } + + @Override + public boolean isInitialized() { + return true; + } + + @Override + public void close() throws IOException {} + } + + private static final class StreamingDataSourceBuilderImpl extends StreamingDataSourceBuilder + implements DiagnosticDescription { + @Override + public DataSource createDataSource(ClientContext context, DataStoreUpdates dataStoreUpdates) { + // Note, we log startup messages under the LDClient class to keep logs more readable + + if (context.isOffline()) { + return Components.externalUpdatesOnly().createDataSource(context, dataStoreUpdates); + } + + 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( + context.getSdkKey(), + context.getHttpConfiguration(), + pollUri, + false + ); + + return new StreamProcessor( + context.getSdkKey(), + context.getHttpConfiguration(), + requestor, + dataStoreUpdates, + null, + ClientContextImpl.getDiagnosticAccumulator(context), + streamUri, + initialReconnectDelay + ); + } + + @Override + public LDValue describeConfiguration(LDConfig config) { + if (config.offline) { + return NullDataSourceFactory.INSTANCE.describeConfiguration(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))) + .put(ConfigProperty.CUSTOM_STREAM_URI.name, + baseURI != null && !baseURI.equals(LDConfig.DEFAULT_STREAM_URI)) + .put(ConfigProperty.RECONNECT_TIME_MILLIS.name, initialReconnectDelay.toMillis()) + .put(ConfigProperty.USING_RELAY_DAEMON.name, false) + .build(); + } + } + + private static final class PollingDataSourceBuilderImpl extends PollingDataSourceBuilder implements DiagnosticDescription { + @Override + public DataSource createDataSource(ClientContext context, DataStoreUpdates dataStoreUpdates) { + // Note, we log startup messages under the LDClient class to keep logs more readable + + if (context.isOffline()) { + return Components.externalUpdatesOnly().createDataSource(context, dataStoreUpdates); + } + + 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( + context.getSdkKey(), + context.getHttpConfiguration(), + baseURI == null ? LDConfig.DEFAULT_BASE_URI : baseURI, + true + ); + return new PollingProcessor(requestor, dataStoreUpdates, pollInterval); + } + + @Override + public LDValue describeConfiguration(LDConfig config) { + if (config.offline) { + return NullDataSourceFactory.INSTANCE.describeConfiguration(config); + } + return LDValue.buildObject() + .put(ConfigProperty.STREAMING_DISABLED.name, true) + .put(ConfigProperty.CUSTOM_BASE_URI.name, + baseURI != null && !baseURI.equals(LDConfig.DEFAULT_BASE_URI)) + .put(ConfigProperty.CUSTOM_STREAM_URI.name, false) + .put(ConfigProperty.POLLING_INTERVAL_MILLIS.name, pollInterval.toMillis()) + .put(ConfigProperty.USING_RELAY_DAEMON.name, false) + .build(); + } + } + + private static final class EventProcessorBuilderImpl extends EventProcessorBuilder + implements DiagnosticDescription { + @Override + public EventProcessor createEventProcessor(ClientContext context) { + if (context.isOffline()) { + return new NullEventProcessor(); + } + return new DefaultEventProcessor( + context.getSdkKey(), + new EventsConfiguration( + allAttributesPrivate, + capacity, + baseURI == null ? LDConfig.DEFAULT_EVENTS_URI : baseURI, + flushInterval, + inlineUsersInEvents, + privateAttributes, + 0, // deprecated samplingInterval isn't supported in new builder + userKeysCapacity, + userKeysFlushInterval, + diagnosticRecordingInterval + ), + context.getHttpConfiguration(), + ClientContextImpl.getDiagnosticAccumulator(context), + ClientContextImpl.getDiagnosticInitEvent(context) + ); + } + + @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, diagnosticRecordingInterval.toMillis()) + .put(ConfigProperty.EVENTS_CAPACITY.name, capacity) + .put(ConfigProperty.EVENTS_FLUSH_INTERVAL_MILLIS.name, flushInterval.toMillis()) + .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, userKeysFlushInterval.toMillis()) + .build(); + } + } + + private static final class HttpConfigurationBuilderImpl extends HttpConfigurationBuilder { + @Override + public HttpConfiguration createHttpConfiguration() { + return new HttpConfigurationImpl( + connectTimeout, + proxyHost == null ? null : new Proxy(Proxy.Type.HTTP, new InetSocketAddress(proxyHost, proxyPort)), + proxyAuth, + socketTimeout, + 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); + } + + @Override + public LDValue describeConfiguration(LDConfig config) { + if (persistentDataStoreFactory instanceof DiagnosticDescription) { + return ((DiagnosticDescription)persistentDataStoreFactory).describeConfiguration(config); + } + return LDValue.of("custom"); + } + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/DataModel.java b/src/main/java/com/launchdarkly/sdk/server/DataModel.java new file mode 100644 index 000000000..f2a907ee3 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/DataModel.java @@ -0,0 +1,502 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.collect.ImmutableList; +import com.google.gson.annotations.JsonAdapter; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; + +import java.util.Collection; +import java.util.List; +import java.util.Set; + +import static java.util.Collections.emptyList; + +/** + * Contains information about the internal data model for feature flags and user segments. + *

    + * The details of the data model are not public to application code (although of course developers can easily + * look at the code or the data) so that changes to LaunchDarkly SDK implementation details will not be breaking + * changes to the application. Therefore, most of the members of this class are package-private. The public + * members provide a high-level description of model objects so that custom integration code or test code can + * store or serialize them. + */ +public abstract class DataModel { + /** + * The {@link DataKind} instance that describes feature flag data. + *

    + * Applications should not need to reference this object directly. It is public so that custom integrations + * and test code can serialize or deserialize data or inject it into a data store. + */ + public static DataKind FEATURES = new DataKind("features", + DataModel::serializeItem, + s -> deserializeItem(s, FeatureFlag.class)); + + /** + * The {@link DataKind} instance that describes user segment data. + *

    + * Applications should not need to reference this object directly. It is public so that custom integrations + * and test code can serialize or deserialize data or inject it into a data store. + */ + public static DataKind SEGMENTS = new DataKind("segments", + DataModel::serializeItem, + s -> deserializeItem(s, Segment.class)); + + /** + * An enumeration of all supported {@link DataKind} types. + *

    + * Applications should not need to reference this object directly. It is public so that custom data store + * implementations can determine ahead of time what kinds of model objects may need to be stored, if + * necessary. + */ + public static Iterable ALL_DATA_KINDS = ImmutableList.of(FEATURES, SEGMENTS); + + private static ItemDescriptor deserializeItem(String s, Class itemClass) { + VersionedData o = JsonHelpers.deserialize(s, itemClass); + return o.isDeleted() ? ItemDescriptor.deletedItem(o.getVersion()) : new ItemDescriptor(o.getVersion(), o); + } + + private static String serializeItem(ItemDescriptor item) { + Object o = item.getItem(); + if (o != null) { + return JsonHelpers.serialize(o); + } + return "{\"version\":" + item.getVersion() + ",\"deleted\":true}"; + } + + // All of these inner data model classes should have package-private scope. They should have only property + // accessors; the evaluator logic is in Evaluator, EvaluatorBucketing, and EvaluatorOperators. + + /** + * Common interface for FeatureFlag and Segment, for convenience in accessing their common properties. + * @since 3.0.0 + */ + interface VersionedData { + String getKey(); + int getVersion(); + /** + * True if this is a placeholder for a deleted item. + * @return true if deleted + */ + boolean isDeleted(); + } + + @JsonAdapter(JsonHelpers.PostProcessingDeserializableTypeAdapterFactory.class) + static final class FeatureFlag implements VersionedData, JsonHelpers.PostProcessingDeserializable { + private String key; + private int version; + private boolean on; + private List prerequisites; + private String salt; + private List targets; + private List rules; + private VariationOrRollout fallthrough; + private Integer offVariation; //optional + private List variations; + private boolean clientSide; + private boolean trackEvents; + private boolean trackEventsFallthrough; + private Long debugEventsUntilDate; + private boolean deleted; + + // We need this so Gson doesn't complain in certain java environments that restrict unsafe allocation + FeatureFlag() {} + + 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) { + this.key = key; + this.version = version; + this.on = on; + this.prerequisites = prerequisites; + this.salt = salt; + this.targets = targets; + this.rules = rules; + this.fallthrough = fallthrough; + this.offVariation = offVariation; + this.variations = variations; + this.clientSide = clientSide; + this.trackEvents = trackEvents; + this.trackEventsFallthrough = trackEventsFallthrough; + this.debugEventsUntilDate = debugEventsUntilDate; + this.deleted = deleted; + } + + public int getVersion() { + return version; + } + + public String getKey() { + return key; + } + + boolean isTrackEvents() { + return trackEvents; + } + + boolean isTrackEventsFallthrough() { + return trackEventsFallthrough; + } + + Long getDebugEventsUntilDate() { + return debugEventsUntilDate; + } + + public boolean isDeleted() { + return deleted; + } + + boolean isOn() { + return on; + } + + // Guaranteed non-null + List getPrerequisites() { + return prerequisites == null ? emptyList() : prerequisites; + } + + String getSalt() { + return salt; + } + + // Guaranteed non-null + List getTargets() { + return targets == null ? emptyList() : targets; + } + + // Guaranteed non-null + List getRules() { + return rules == null ? emptyList() : rules; + } + + VariationOrRollout getFallthrough() { + return fallthrough; + } + + // Guaranteed non-null + List getVariations() { + return variations == null ? emptyList() : variations; + } + + Integer getOffVariation() { + return offVariation; + } + + 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 final class Prerequisite { + private String key; + private int variation; + + private transient EvaluationReason prerequisiteFailedReason; + + Prerequisite() {} + + Prerequisite(String key, int variation) { + this.key = key; + this.variation = variation; + } + + String getKey() { + return key; + } + + int getVariation() { + return variation; + } + + // This value is precomputed when we deserialize a FeatureFlag from JSON + EvaluationReason getPrerequisiteFailedReason() { + return prerequisiteFailedReason; + } + + void setPrerequisiteFailedReason(EvaluationReason prerequisiteFailedReason) { + this.prerequisiteFailedReason = prerequisiteFailedReason; + } + } + + static final class Target { + private Set values; + private int variation; + + Target() {} + + Target(Set values, int variation) { + this.values = values; + this.variation = variation; + } + + // Guaranteed non-null + Collection getValues() { + return values == null ? emptyList() : values; + } + + int getVariation() { + return variation; + } + } + + /** + * Expresses a set of AND-ed matching conditions for a user, along with either the fixed variation or percent rollout + * to serve if the conditions match. + * Invariant: one of the variation or rollout must be non-nil. + */ + static final class Rule extends VariationOrRollout { + private String id; + private List clauses; + private boolean trackEvents; + + private transient EvaluationReason ruleMatchReason; + + Rule() { + super(); + } + + Rule(String id, List clauses, Integer variation, Rollout rollout, boolean trackEvents) { + super(variation, rollout); + this.id = id; + this.clauses = clauses; + this.trackEvents = trackEvents; + } + + String getId() { + return id; + } + + // Guaranteed non-null + List getClauses() { + return clauses == null ? emptyList() : clauses; + } + + boolean isTrackEvents() { + return trackEvents; + } + + // This value is precomputed when we deserialize a FeatureFlag from JSON + EvaluationReason getRuleMatchReason() { + return ruleMatchReason; + } + + void setRuleMatchReason(EvaluationReason ruleMatchReason) { + this.ruleMatchReason = ruleMatchReason; + } + } + + static class Clause { + private UserAttribute attribute; + private Operator op; + private List values; //interpreted as an OR of values + private boolean negate; + + Clause() { + } + + Clause(UserAttribute attribute, Operator op, List values, boolean negate) { + this.attribute = attribute; + this.op = op; + this.values = values; + this.negate = negate; + } + + UserAttribute getAttribute() { + return attribute; + } + + Operator getOp() { + return op; + } + + Iterable getValues() { + return values; + } + + boolean isNegate() { + return negate; + } + } + + static final class Rollout { + private List variations; + private UserAttribute bucketBy; + + Rollout() {} + + Rollout(List variations, UserAttribute bucketBy) { + this.variations = variations; + this.bucketBy = bucketBy; + } + + List getVariations() { + return variations; + } + + UserAttribute getBucketBy() { + return bucketBy; + } + } + + /** + * Contains either a fixed variation or percent rollout to serve. + * Invariant: one of the variation or rollout must be non-nil. + */ + static class VariationOrRollout { + private Integer variation; + private Rollout rollout; + + VariationOrRollout() {} + + VariationOrRollout(Integer variation, Rollout rollout) { + this.variation = variation; + this.rollout = rollout; + } + + Integer getVariation() { + return variation; + } + + Rollout getRollout() { + return rollout; + } + } + + static final class WeightedVariation { + private int variation; + private int weight; + + WeightedVariation() {} + + WeightedVariation(int variation, int weight) { + this.variation = variation; + this.weight = weight; + } + + int getVariation() { + return variation; + } + + int getWeight() { + return weight; + } + } + + static final class Segment implements VersionedData { + private String key; + private Set included; + private Set excluded; + private String salt; + private List rules; + private int version; + private boolean deleted; + + Segment() {} + + Segment(String key, Set included, Set excluded, String salt, List rules, int version, boolean deleted) { + this.key = key; + this.included = included; + this.excluded = excluded; + this.salt = salt; + this.rules = rules; + this.version = version; + this.deleted = deleted; + } + + public String getKey() { + return key; + } + + // Guaranteed non-null + Collection getIncluded() { + return included == null ? emptyList() : included; + } + + // Guaranteed non-null + Collection getExcluded() { + return excluded == null ? emptyList() : excluded; + } + + String getSalt() { + return salt; + } + + // Guaranteed non-null + List getRules() { + return rules == null ? emptyList() : rules; + } + + public int getVersion() { + return version; + } + + public boolean isDeleted() { + return deleted; + } + } + + static final class SegmentRule { + private final List clauses; + private final Integer weight; + private final UserAttribute bucketBy; + + SegmentRule(List clauses, Integer weight, UserAttribute bucketBy) { + this.clauses = clauses; + this.weight = weight; + this.bucketBy = bucketBy; + } + + // Guaranteed non-null + List getClauses() { + return clauses == null ? emptyList() : clauses; + } + + Integer getWeight() { + return weight; + } + + UserAttribute getBucketBy() { + return bucketBy; + } + } + + /** + * This enum can be directly deserialized from JSON, avoiding the need for a mapping of strings to + * operators. The implementation of each operator is in EvaluatorOperators. + */ + static enum Operator { + in, + endsWith, + startsWith, + matches, + contains, + lessThan, + lessThanOrEqual, + greaterThan, + greaterThanOrEqual, + before, + after, + semVerEqual, + semVerLessThan, + semVerGreaterThan, + segmentMatch + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/DataModelDependencies.java b/src/main/java/com/launchdarkly/sdk/server/DataModelDependencies.java new file mode 100644 index 000000000..b3a653705 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/DataModelDependencies.java @@ -0,0 +1,250 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.ImmutableSortedMap; +import com.google.common.collect.Iterables; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.DataModel.Operator; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; + +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import static com.google.common.collect.Iterables.concat; +import static com.google.common.collect.Iterables.isEmpty; +import static com.google.common.collect.Iterables.transform; +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; +import static java.util.Collections.emptyList; +import static java.util.Collections.emptySet; + +/** + * Implements a dependency graph ordering for data to be stored in a data store. + *

    + * We use this to order the data that we pass to {@link com.launchdarkly.sdk.server.interfaces.DataStore#init(FullDataSet)}, + * and also to determine which flags are affected by a change if the application is listening for flag change events. + *

    + * Dependencies are defined as follows: there is a dependency from flag F to flag G if F is a prerequisite flag for + * G, or transitively for any of G's prerequisites; there is a dependency from flag F to segment S if F contains a + * rule with a segmentMatch clause that uses S. Therefore, if G or S is modified or deleted then F may be affected, + * and if we must populate the store non-atomically then G and S should be added before F. + * + * @since 4.6.1 + */ +abstract class DataModelDependencies { + static class KindAndKey { + final DataKind kind; + final String key; + + public KindAndKey(DataKind kind, String key) { + this.kind = kind; + this.key = key; + } + + @Override + public boolean equals(Object other) { + if (other instanceof KindAndKey) { + KindAndKey o = (KindAndKey)other; + return kind == o.kind && key.equals(o.key); + } + return false; + } + + @Override + public int hashCode() { + return kind.hashCode() * 31 + key.hashCode(); + } + } + + /** + * Returns the immediate dependencies from the given item. + * + * @param fromKind the item's kind + * @param fromItem the item descriptor + * @return the flags and/or segments that this item depends on + */ + public static Set computeDependenciesFrom(DataKind fromKind, ItemDescriptor fromItem) { + if (fromItem == null || fromItem.getItem() == null) { + return emptySet(); + } + if (fromKind == FEATURES) { + DataModel.FeatureFlag flag = (DataModel.FeatureFlag)fromItem.getItem(); + + Iterable prereqFlagKeys = transform(flag.getPrerequisites(), p -> p.getKey()); + + Iterable segmentKeys = concat( + transform( + flag.getRules(), + rule -> concat( + Iterables.>transform( + rule.getClauses(), + clause -> clause.getOp() == Operator.segmentMatch ? + transform(clause.getValues(), LDValue::stringValue) : + emptyList() + ) + ) + ) + ); + + return ImmutableSet.copyOf( + concat( + transform(prereqFlagKeys, key -> new KindAndKey(FEATURES, key)), + transform(segmentKeys, key -> new KindAndKey(SEGMENTS, key)) + ) + ); + } + return emptySet(); + } + + /** + * Returns a copy of the input data set that guarantees that if you iterate through it the outer list and + * the inner list in the order provided, any object that depends on another object will be updated after it. + * + * @param allData the unordered data set + * @return a map with a defined ordering + */ + public static FullDataSet sortAllCollections(FullDataSet allData) { + ImmutableSortedMap.Builder> builder = + ImmutableSortedMap.orderedBy(dataKindPriorityOrder); + for (Map.Entry> entry: allData.getData()) { + DataKind kind = entry.getKey(); + builder.put(kind, sortCollection(kind, entry.getValue())); + } + return new FullDataSet<>(builder.build().entrySet()); + } + + private static KeyedItems sortCollection(DataKind kind, KeyedItems input) { + if (!isDependencyOrdered(kind) || isEmpty(input.getItems())) { + return input; + } + + Map remainingItems = new HashMap<>(); + for (Map.Entry e: input.getItems()) { + remainingItems.put(e.getKey(), e.getValue()); + } + 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(kind, entry.getKey(), entry.getValue(), remainingItems, builder); + break; + } + } + + return new KeyedItems<>(builder.build().entrySet()); + } + + private static void addWithDependenciesFirst(DataKind kind, + String key, + ItemDescriptor item, + Map remainingItems, + ImmutableMap.Builder builder) { + remainingItems.remove(key); // we won't need to visit this item again + for (KindAndKey dependency: computeDependenciesFrom(kind, item)) { + if (dependency.kind == kind) { + ItemDescriptor prereqItem = remainingItems.get(dependency.key); + if (prereqItem != null) { + addWithDependenciesFirst(kind, dependency.key, prereqItem, remainingItems, builder); + } + } + } + builder.put(key, item); + } + + private static boolean isDependencyOrdered(DataKind kind) { + return kind == FEATURES; + } + + private static int getPriority(DataKind kind) { + if (kind == FEATURES) { + return 1; + } else if (kind == SEGMENTS) { + return 0; + } else { + return kind.getName().length() + 2; + } + } + + private static Comparator dataKindPriorityOrder = new Comparator() { + @Override + public int compare(DataKind o1, DataKind o2) { + return getPriority(o1) - getPriority(o2); + } + }; + + /** + * Maintains a bidirectional dependency graph that can be updated whenever an item has changed. + */ + static final class DependencyTracker { + private final Map> dependenciesFrom = new HashMap<>(); + private final Map> dependenciesTo = new HashMap<>(); + + /** + * Updates the dependency graph when an item has changed. + * + * @param fromKind the changed item's kind + * @param fromKey the changed item's key + * @param fromItem the changed item + */ + public void updateDependenciesFrom(DataKind fromKind, String fromKey, ItemDescriptor fromItem) { + KindAndKey fromWhat = new KindAndKey(fromKind, fromKey); + Set updatedDependencies = computeDependenciesFrom(fromKind, fromItem); + + Set oldDependencySet = dependenciesFrom.get(fromWhat); + if (oldDependencySet != null) { + for (KindAndKey oldDep: oldDependencySet) { + Set depsToThisOldDep = dependenciesTo.get(oldDep); + if (depsToThisOldDep != null) { + depsToThisOldDep.remove(fromWhat); + } + } + } + if (updatedDependencies == null) { + dependenciesFrom.remove(fromWhat); + } else { + dependenciesFrom.put(fromWhat, updatedDependencies); + for (KindAndKey newDep: updatedDependencies) { + Set depsToThisNewDep = dependenciesTo.get(newDep); + if (depsToThisNewDep == null) { + depsToThisNewDep = new HashSet<>(); + dependenciesTo.put(newDep, depsToThisNewDep); + } + depsToThisNewDep.add(fromWhat); + } + } + } + + public void reset() { + dependenciesFrom.clear(); + dependenciesTo.clear(); + } + + /** + * Populates the given set with the union of the initial item and all items that directly or indirectly + * depend on it (based on the current state of the dependency graph). + * + * @param itemsOut an existing set to be updated + * @param initialModifiedItem an item that has been modified + */ + public void addAffectedItems(Set itemsOut, KindAndKey initialModifiedItem) { + if (!itemsOut.contains(initialModifiedItem)) { + itemsOut.add(initialModifiedItem); + Set affectedItems = dependenciesTo.get(initialModifiedItem); + if (affectedItems != null) { + for (KindAndKey affectedItem: affectedItems) { + addAffectedItems(itemsOut, affectedItem); + } + } + } + } + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImpl.java b/src/main/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImpl.java new file mode 100644 index 000000000..db63225ee --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImpl.java @@ -0,0 +1,36 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; + +// Simple delegator to ensure that LDClient.getDataStoreStatusProvider() never returns null and that +// the application isn't given direct access to the store. +final class DataStoreStatusProviderImpl implements DataStoreStatusProvider { + private final DataStoreStatusProvider delegateTo; + + DataStoreStatusProviderImpl(DataStore store) { + delegateTo = store instanceof DataStoreStatusProvider ? (DataStoreStatusProvider)store : null; + } + + @Override + public Status getStoreStatus() { + return delegateTo == null ? null : delegateTo.getStoreStatus(); + } + + @Override + public boolean addStatusListener(StatusListener listener) { + return delegateTo != null && delegateTo.addStatusListener(listener); + } + + @Override + public void removeStatusListener(StatusListener listener) { + if (delegateTo != null) { + delegateTo.removeStatusListener(listener); + } + } + + @Override + public CacheStats getCacheStats() { + return delegateTo == null ? null : delegateTo.getCacheStats(); + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/DataStoreUpdatesImpl.java b/src/main/java/com/launchdarkly/sdk/server/DataStoreUpdatesImpl.java new file mode 100644 index 000000000..7edc3ef14 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/DataStoreUpdatesImpl.java @@ -0,0 +1,153 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.launchdarkly.sdk.server.DataModelDependencies.KindAndKey; +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; +import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; +import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import static com.google.common.collect.Iterables.concat; +import static com.launchdarkly.sdk.server.DataModel.ALL_DATA_KINDS; +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static java.util.Collections.emptyMap; + +/** + * The data source will push updates into this component. We then apply any necessary + * transformations before putting them into the data store; currently that just means sorting + * the data set for init(). We also generate flag change events for any updates or deletions. + * + * @since 4.11.0 + */ +final class DataStoreUpdatesImpl implements DataStoreUpdates { + private final DataStore store; + private final FlagChangeEventPublisher flagChangeEventPublisher; + private final DataModelDependencies.DependencyTracker dependencyTracker = new DataModelDependencies.DependencyTracker(); + private final DataStoreStatusProvider dataStoreStatusProvider; + + DataStoreUpdatesImpl(DataStore store, FlagChangeEventPublisher flagChangeEventPublisher) { + this.store = store; + this.flagChangeEventPublisher = flagChangeEventPublisher; + this.dataStoreStatusProvider = new DataStoreStatusProviderImpl(store); + } + + @Override + public void init(FullDataSet allData) { + Map> oldData = null; + + if (hasFlagChangeEventListeners()) { + // Query the existing data if any, so that after the update we can send events for whatever was changed + oldData = new HashMap<>(); + for (DataKind kind: ALL_DATA_KINDS) { + KeyedItems items = store.getAll(kind); + oldData.put(kind, ImmutableMap.copyOf(items.getItems())); + } + } + + store.init(DataModelDependencies.sortAllCollections(allData)); + + // We must always update the dependency graph even if we don't currently have any event listeners, because if + // listeners are added later, we don't want to have to reread the whole data store to compute the graph + updateDependencyTrackerFromFullDataSet(allData); + + // Now, if we previously queried the old data because someone is listening for flag change events, compare + // the versions of all items and generate events for those (and any other items that depend on them) + if (oldData != null) { + sendChangeEvents(computeChangedItemsForFullDataSet(oldData, fullDataSetToMap(allData))); + } + } + + @Override + public void upsert(DataKind kind, String key, ItemDescriptor item) { + boolean successfullyUpdated = store.upsert(kind, key, item); + + if (successfullyUpdated) { + dependencyTracker.updateDependenciesFrom(kind, key, item); + if (hasFlagChangeEventListeners()) { + Set affectedItems = new HashSet<>(); + dependencyTracker.addAffectedItems(affectedItems, new KindAndKey(kind, key)); + sendChangeEvents(affectedItems); + } + } + } + + @Override + public DataStoreStatusProvider getStatusProvider() { + return dataStoreStatusProvider; + } + + private boolean hasFlagChangeEventListeners() { + return flagChangeEventPublisher != null && flagChangeEventPublisher.hasListeners(); + } + + private void sendChangeEvents(Iterable affectedItems) { + if (flagChangeEventPublisher == null) { + return; + } + for (KindAndKey item: affectedItems) { + if (item.kind == FEATURES) { + flagChangeEventPublisher.publishEvent(new FlagChangeEvent(item.key)); + } + } + } + + private void updateDependencyTrackerFromFullDataSet(FullDataSet allData) { + dependencyTracker.reset(); + for (Map.Entry> e0: allData.getData()) { + DataKind kind = e0.getKey(); + for (Map.Entry e1: e0.getValue().getItems()) { + String key = e1.getKey(); + dependencyTracker.updateDependenciesFrom(kind, key, e1.getValue()); + } + } + } + + private Map> fullDataSetToMap(FullDataSet allData) { + Map> ret = new HashMap<>(); + for (Map.Entry> e: allData.getData()) { + ret.put(e.getKey(), ImmutableMap.copyOf(e.getValue().getItems())); + } + return ret; + } + + private Set computeChangedItemsForFullDataSet(Map> oldDataMap, + Map> newDataMap) { + Set affectedItems = new HashSet<>(); + for (DataKind kind: ALL_DATA_KINDS) { + Map oldItems = oldDataMap.get(kind); + Map newItems = newDataMap.get(kind); + if (oldItems == null) { + oldItems = emptyMap(); + } + if (newItems == null) { + newItems = emptyMap(); + } + Set allKeys = ImmutableSet.copyOf(concat(oldItems.keySet(), newItems.keySet())); + for (String key: allKeys) { + ItemDescriptor oldItem = oldItems.get(key); + ItemDescriptor newItem = newItems.get(key); + if (oldItem == null && newItem == null) { // shouldn't be possible due to how we computed allKeys + continue; + } + if (oldItem == null || newItem == null || oldItem.getVersion() < newItem.getVersion()) { + dependencyTracker.addAffectedItems(affectedItems, new KindAndKey(kind, key)); + } + // Note that comparing the version numbers is sufficient; we don't have to compare every detail of the + // flag or segment configuration, because it's a basic underlying assumption of the entire LD data model + // that if an entity's version number hasn't changed, then the entity hasn't changed (and that if two + // version numbers are different, the higher one is the more recent version). + } + } + return affectedItems; + } +} diff --git a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java similarity index 87% rename from src/main/java/com/launchdarkly/client/DefaultEventProcessor.java rename to src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java index a82890e78..a814f81ea 100644 --- a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java @@ -1,9 +1,12 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; 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.sdk.LDUser; +import com.launchdarkly.sdk.server.EventSummarizer.EventSummary; +import com.launchdarkly.sdk.server.interfaces.Event; +import com.launchdarkly.sdk.server.interfaces.EventProcessor; +import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -15,7 +18,6 @@ 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; @@ -29,11 +31,11 @@ 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 static com.launchdarkly.sdk.server.Util.configureHttpClientBuilder; +import static com.launchdarkly.sdk.server.Util.getHeadersBuilderFor; +import static com.launchdarkly.sdk.server.Util.httpErrorMessage; +import static com.launchdarkly.sdk.server.Util.isHttpErrorRecoverable; +import static com.launchdarkly.sdk.server.Util.shutdownHttpClient; import okhttp3.Headers; import okhttp3.MediaType; @@ -47,6 +49,7 @@ 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 static final String EVENT_PAYLOAD_ID_HEADER = "X-LaunchDarkly-Payload-ID"; + private static final MediaType JSON_CONTENT_TYPE = MediaType.parse("application/json; charset=utf-8"); @VisibleForTesting final EventDispatcher dispatcher; private final BlockingQueue inbox; @@ -54,8 +57,8 @@ 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(String sdkKey, EventsConfiguration eventsConfig, HttpConfiguration httpConfig, + DiagnosticAccumulator diagnosticAccumulator, DiagnosticEvent.Init diagnosticInitEvent) { inbox = new ArrayBlockingQueue<>(eventsConfig.capacity); ThreadFactory threadFactory = new ThreadFactoryBuilder() @@ -65,29 +68,24 @@ final class DefaultEventProcessor implements EventProcessor { .build(); scheduler = Executors.newSingleThreadScheduledExecutor(threadFactory); - dispatcher = new EventDispatcher(sdkKey, config, eventsConfig, httpConfig, inbox, threadFactory, closed, diagnosticAccumulator); + dispatcher = new EventDispatcher(sdkKey, eventsConfig, httpConfig, inbox, threadFactory, closed, diagnosticAccumulator, diagnosticInitEvent); - Runnable flusher = new Runnable() { - public void run() { - postMessageAsync(MessageType.FLUSH, null); - } + Runnable flusher = () -> { + postMessageAsync(MessageType.FLUSH, null); }; - 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(flusher, eventsConfig.flushInterval.toMillis(), + eventsConfig.flushInterval.toMillis(), TimeUnit.MILLISECONDS); + Runnable userKeysFlusher = () -> { + postMessageAsync(MessageType.FLUSH_USERS, null); }; - this.scheduler.scheduleAtFixedRate(userKeysFlusher, eventsConfig.userKeysFlushIntervalSeconds, - eventsConfig.userKeysFlushIntervalSeconds, TimeUnit.SECONDS); - 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(userKeysFlusher, eventsConfig.userKeysFlushInterval.toMillis(), + eventsConfig.userKeysFlushInterval.toMillis(), TimeUnit.MILLISECONDS); + if (diagnosticAccumulator != null) { + Runnable diagnosticsTrigger = () -> { + postMessageAsync(MessageType.DIAGNOSTIC, null); }; - this.scheduler.scheduleAtFixedRate(diagnosticsTrigger, eventsConfig.diagnosticRecordingIntervalSeconds, - eventsConfig.diagnosticRecordingIntervalSeconds, TimeUnit.SECONDS); + this.scheduler.scheduleAtFixedRate(diagnosticsTrigger, eventsConfig.diagnosticRecordingInterval.toMillis(), + eventsConfig.diagnosticRecordingInterval.toMillis(), TimeUnit.MILLISECONDS); } } @@ -210,20 +208,20 @@ static final class EventDispatcher { private final OkHttpClient httpClient; 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 DiagnosticAccumulator diagnosticAccumulator; + @VisibleForTesting final DiagnosticAccumulator diagnosticAccumulator; private final ExecutorService diagnosticExecutor; private final SendDiagnosticTaskFactory sendDiagnosticTaskFactory; private long deduplicatedUsers = 0; - private EventDispatcher(String sdkKey, LDConfig config, EventsConfiguration eventsConfig, HttpConfiguration httpConfig, + private EventDispatcher(String sdkKey, EventsConfiguration eventsConfig, HttpConfiguration httpConfig, final BlockingQueue inbox, ThreadFactory threadFactory, final AtomicBoolean closed, - DiagnosticAccumulator diagnosticAccumulator) { + DiagnosticAccumulator diagnosticAccumulator, + DiagnosticEvent.Init diagnosticInitEvent) { this.eventsConfig = eventsConfig; this.diagnosticAccumulator = diagnosticAccumulator; this.busyFlushWorkersCount = new AtomicInteger(0); @@ -236,13 +234,12 @@ 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); - - Thread mainThread = threadFactory.newThread(new Runnable() { - public void run() { - runMainLoop(inbox, outbox, userKeys, payloadQueue); - } + + Thread mainThread = threadFactory.newThread(() -> { + runMainLoop(inbox, outbox, userKeys, payloadQueue); }); mainThread.setDaemon(true); @@ -267,11 +264,7 @@ public void uncaughtException(Thread t, Throwable e) { mainThread.start(); flushWorkers = new ArrayList<>(); - EventResponseListener listener = new EventResponseListener() { - public void handleResponse(Response response, Date responseDate) { - EventDispatcher.this.handleResponse(response, responseDate); - } - }; + EventResponseListener listener = this::handleResponse; for (int i = 0; i < MAX_FLUSH_THREADS; i++) { SendEventsTask task = new SendEventsTask(sdkKey, eventsConfig, httpClient, httpConfig, listener, payloadQueue, busyFlushWorkersCount, threadFactory); @@ -282,7 +275,6 @@ public void handleResponse(Response response, Date responseDate) { // Set up diagnostics 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)); } else { diagnosticExecutor = null; @@ -385,23 +377,22 @@ private void processEvent(Event e, SimpleLRUCache userKeys, Even Event debugEvent = null; if (e instanceof Event.FeatureRequest) { - if (shouldSampleEvent()) { - Event.FeatureRequest fe = (Event.FeatureRequest)e; - addFullEvent = fe.trackEvents; - if (shouldDebugEvent(fe)) { - debugEvent = EventFactory.DEFAULT.newDebugEvent(fe); - } + Event.FeatureRequest fe = (Event.FeatureRequest)e; + addFullEvent = fe.isTrackEvents(); + if (shouldDebugEvent(fe)) { + debugEvent = EventFactory.DEFAULT.newDebugEvent(fe); } } else { - addFullEvent = shouldSampleEvent(); + addFullEvent = true; } // 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 || !eventsConfig.inlineUsersInEvents) { - if (e.user != null && e.user.getKey() != null) { + LDUser user = e.getUser(); + if (user != null && user.getKey() != null) { boolean isIndexEvent = e instanceof Event.Identify; - boolean alreadySeen = noticeUser(e.user, userKeys); + boolean alreadySeen = noticeUser(user, userKeys); addIndexEvent = !isIndexEvent & !alreadySeen; if (!isIndexEvent & alreadySeen) { deduplicatedUsers++; @@ -410,7 +401,7 @@ private void processEvent(Event e, SimpleLRUCache userKeys, Even } if (addIndexEvent) { - Event.Index ie = new Event.Index(e.creationDate, e.user); + Event.Index ie = new Event.Index(e.getCreationDate(), e.getUser()); outbox.add(ie); } if (addFullEvent) { @@ -426,23 +417,20 @@ private boolean noticeUser(LDUser user, SimpleLRUCache userKeys) if (user == null || user.getKey() == null) { return false; } - String key = user.getKeyAsString(); + String key = user.getKey(); return userKeys.put(key, key) != null; } - private boolean shouldSampleEvent() { - return eventsConfig.samplingInterval <= 0 || random.nextInt(eventsConfig.samplingInterval) == 0; - } - private boolean shouldDebugEvent(Event.FeatureRequest fe) { - if (fe.debugEventsUntilDate != null) { + long debugEventsUntilDate = fe.getDebugEventsUntilDate(); + if (debugEventsUntilDate > 0) { // The "last known past time" comes from the last HTTP response we got from the server. // In case the client's time is set wrong, at least we know that any expiration date // earlier than that point is definitely in the past. If there's any discrepancy, we // want to err on the side of cutting off event debugging sooner. long lastPast = lastKnownPastTime.get(); - if (fe.debugEventsUntilDate > lastPast && - fe.debugEventsUntilDate > System.currentTimeMillis()) { + if (debugEventsUntilDate > lastPast && + debugEventsUntilDate > System.currentTimeMillis()) { return true; } } @@ -500,7 +488,7 @@ private static void postJson(OkHttpClient httpClient, Headers headers, String js Request request = new Request.Builder() .url(uriStr) - .post(RequestBody.create(MediaType.parse("application/json; charset=utf-8"), json)) + .post(RequestBody.create(json, JSON_CONTENT_TYPE)) .headers(headers) .build(); diff --git a/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java b/src/main/java/com/launchdarkly/sdk/server/DefaultFeatureRequestor.java similarity index 62% rename from src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java rename to src/main/java/com/launchdarkly/sdk/server/DefaultFeatureRequestor.java index 017bcdc73..efcfd2d7a 100644 --- a/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java +++ b/src/main/java/com/launchdarkly/sdk/server/DefaultFeatureRequestor.java @@ -1,9 +1,16 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Maps; import com.google.common.io.Files; -import com.launchdarkly.client.interfaces.HttpConfiguration; -import com.launchdarkly.client.interfaces.SerializationException; +import com.launchdarkly.sdk.server.DataModel.VersionedData; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; +import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; +import com.launchdarkly.sdk.server.interfaces.SerializationException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -11,14 +18,13 @@ import java.io.File; import java.io.IOException; import java.net.URI; -import java.util.HashMap; import java.util.Map; -import static com.launchdarkly.client.Util.configureHttpClientBuilder; -import static com.launchdarkly.client.Util.getHeadersBuilderFor; -import static com.launchdarkly.client.Util.shutdownHttpClient; -import static com.launchdarkly.client.VersionedDataKind.FEATURES; -import static com.launchdarkly.client.VersionedDataKind.SEGMENTS; +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; +import static com.launchdarkly.sdk.server.Util.configureHttpClientBuilder; +import static com.launchdarkly.sdk.server.Util.getHeadersBuilderFor; +import static com.launchdarkly.sdk.server.Util.shutdownHttpClient; import okhttp3.Cache; import okhttp3.Headers; @@ -64,26 +70,37 @@ public void close() { shutdownHttpClient(httpClient); } - public FeatureFlag getFlag(String featureKey) throws IOException, HttpErrorException, SerializationException { + public DataModel.FeatureFlag getFlag(String featureKey) throws IOException, HttpErrorException, SerializationException { String body = get(GET_LATEST_FLAGS_PATH + "/" + featureKey); - return JsonHelpers.deserialize(body, FeatureFlag.class); + return JsonHelpers.deserialize(body, DataModel.FeatureFlag.class); } - public Segment getSegment(String segmentKey) throws IOException, HttpErrorException { + public DataModel.Segment getSegment(String segmentKey) throws IOException, HttpErrorException, SerializationException { String body = get(GET_LATEST_SEGMENTS_PATH + "/" + segmentKey); - return JsonHelpers.deserialize(body, Segment.class); + return JsonHelpers.deserialize(body, DataModel.Segment.class); } - public AllData getAllData() throws IOException, HttpErrorException { + public AllData getAllData() throws IOException, HttpErrorException, SerializationException { String body = get(GET_LATEST_ALL_PATH); return JsonHelpers.deserialize(body, AllData.class); } + + static FullDataSet toFullDataSet(AllData allData) { + return new FullDataSet(ImmutableMap.of( + FEATURES, toKeyedItems(allData.flags), + SEGMENTS, toKeyedItems(allData.segments) + ).entrySet()); + } - static Map, Map> toVersionedDataMap(AllData allData) { - Map, Map> ret = new HashMap<>(); - ret.put(FEATURES, allData.flags); - ret.put(SEGMENTS, allData.segments); - return ret; + static KeyedItems toKeyedItems(Map itemsMap) { + if (itemsMap == null) { + return new KeyedItems<>(null); + } + return new KeyedItems<>( + ImmutableList.copyOf( + Maps.transformValues(itemsMap, item -> new ItemDescriptor(item.getVersion(), item)).entrySet() + ) + ); } private String get(String path) throws IOException, HttpErrorException { diff --git a/src/main/java/com/launchdarkly/client/DiagnosticAccumulator.java b/src/main/java/com/launchdarkly/sdk/server/DiagnosticAccumulator.java similarity index 97% rename from src/main/java/com/launchdarkly/client/DiagnosticAccumulator.java rename to src/main/java/com/launchdarkly/sdk/server/DiagnosticAccumulator.java index 22782294f..cea391e90 100644 --- a/src/main/java/com/launchdarkly/client/DiagnosticAccumulator.java +++ b/src/main/java/com/launchdarkly/sdk/server/DiagnosticAccumulator.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import java.util.ArrayList; import java.util.List; diff --git a/src/main/java/com/launchdarkly/client/DiagnosticEvent.java b/src/main/java/com/launchdarkly/sdk/server/DiagnosticEvent.java similarity index 91% rename from src/main/java/com/launchdarkly/client/DiagnosticEvent.java rename to src/main/java/com/launchdarkly/sdk/server/DiagnosticEvent.java index 4439f3261..4f2c8b887 100644 --- a/src/main/java/com/launchdarkly/client/DiagnosticEvent.java +++ b/src/main/java/com/launchdarkly/sdk/server/DiagnosticEvent.java @@ -1,9 +1,9 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; -import com.launchdarkly.client.interfaces.DiagnosticDescription; -import com.launchdarkly.client.value.LDValue; -import com.launchdarkly.client.value.LDValueType; -import com.launchdarkly.client.value.ObjectBuilder; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.LDValueType; +import com.launchdarkly.sdk.ObjectBuilder; +import com.launchdarkly.sdk.server.interfaces.DiagnosticDescription; import java.util.List; @@ -86,28 +86,26 @@ static class Init extends DiagnosticEvent { this.configuration = getConfigurationData(config); } - @SuppressWarnings("deprecation") 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.getConnectTimeoutMillis()); - builder.put("socketTimeoutMillis", config.httpConfig.getSocketTimeoutMillis()); + builder.put("connectTimeoutMillis", config.httpConfig.getConnectTimeout().toMillis()); + builder.put("socketTimeoutMillis", config.httpConfig.getSocketTimeout().toMillis()); builder.put("usingProxy", config.httpConfig.getProxy() != null); builder.put("usingProxyAuthenticator", config.httpConfig.getProxyAuthentication() != null); builder.put("offline", config.offline); - builder.put("startWaitMillis", config.startWaitMillis); + builder.put("startWaitMillis", config.startWait.toMillis()); // Allow each pluggable component to describe its own relevant properties. - mergeComponentProperties(builder, config.deprecatedFeatureStore, config, "dataStoreType"); mergeComponentProperties(builder, config.dataStoreFactory == null ? Components.inMemoryDataStore() : config.dataStoreFactory, config, "dataStoreType"); mergeComponentProperties(builder, - config.dataSourceFactory == null ? Components.defaultUpdateProcessor() : config.dataSourceFactory, + config.dataSourceFactory == null ? Components.streamingDataSource() : config.dataSourceFactory, config, null); mergeComponentProperties(builder, - config.eventProcessorFactory == null ? Components.defaultEventProcessor() : config.eventProcessorFactory, + config.eventProcessorFactory == null ? Components.sendEvents() : config.eventProcessorFactory, config, null); return builder.build(); } diff --git a/src/main/java/com/launchdarkly/client/DiagnosticId.java b/src/main/java/com/launchdarkly/sdk/server/DiagnosticId.java similarity index 89% rename from src/main/java/com/launchdarkly/client/DiagnosticId.java rename to src/main/java/com/launchdarkly/sdk/server/DiagnosticId.java index 713aebe33..8601a9780 100644 --- a/src/main/java/com/launchdarkly/client/DiagnosticId.java +++ b/src/main/java/com/launchdarkly/sdk/server/DiagnosticId.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import java.util.UUID; diff --git a/src/main/java/com/launchdarkly/sdk/server/Evaluator.java b/src/main/java/com/launchdarkly/sdk/server/Evaluator.java new file mode 100644 index 000000000..4e3136dd4 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/Evaluator.java @@ -0,0 +1,326 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.collect.ImmutableList; +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.LDValueType; +import com.launchdarkly.sdk.server.interfaces.Event; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; + +import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; + +/** + * Encapsulates the feature flag evaluation logic. The Evaluator has no knowledge of the rest of the SDK environment; + * if it needs to retrieve flags or segments that are referenced by a flag, it does so through a read-only interface + * that is provided in the constructor. It also produces feature requests as appropriate for any referenced prerequisite + * flags, but does not send them. + */ +class Evaluator { + private final static Logger logger = LoggerFactory.getLogger(Evaluator.class); + + private final Getters getters; + + /** + * An abstraction of getting flags or segments by key. This ensures that Evaluator cannot modify the data store, + * and simplifies testing. + */ + static interface Getters { + DataModel.FeatureFlag getFlag(String key); + DataModel.Segment getSegment(String key); + } + + /** + * Internal container for the results of an evaluation. This consists of the same information that is in an + * {@link EvaluationDetail}, plus a list of any feature request events generated by prerequisite flags. + * + * Unlike all the other simple data containers in the SDK, this is mutable. The reason is that flag evaluations + * may be done very frequently and we would like to minimize the amount of heap churn from intermediate objects, + * and Java does not support multiple return values as Go does, or value types as C# does. + * + * We never expose an EvalResult to application code and we never preserve a reference to it outside of a single + * xxxVariation() or xxxVariationDetail() call, so the risks from mutability are minimal. The only setter method + * that is accessible from outside of the Evaluator class is setValue(), which is exposed so that LDClient can + * replace null values with default values, + */ + static class EvalResult { + private LDValue value = LDValue.ofNull(); + private int variationIndex = NO_VARIATION; + private EvaluationReason reason = null; + private List prerequisiteEvents; + + public EvalResult(LDValue value, int variationIndex, EvaluationReason reason) { + this.value = value; + this.variationIndex = variationIndex; + this.reason = reason; + } + + public static EvalResult error(EvaluationReason.ErrorKind errorKind) { + return new EvalResult(LDValue.ofNull(), NO_VARIATION, EvaluationReason.error(errorKind)); + } + + LDValue getValue() { + return LDValue.normalize(value); + } + + void setValue(LDValue value) { + this.value = value; + } + + int getVariationIndex() { + return variationIndex; + } + + boolean isDefault() { + return variationIndex < 0; + } + + EvaluationReason getReason() { + return reason; + } + + EvaluationDetail getDetails() { + return EvaluationDetail.fromValue(LDValue.normalize(value), variationIndex, reason); + } + + Iterable getPrerequisiteEvents() { + return prerequisiteEvents == null ? ImmutableList.of() : prerequisiteEvents; + } + + private void setPrerequisiteEvents(List prerequisiteEvents) { + this.prerequisiteEvents = prerequisiteEvents; + } + } + + Evaluator(Getters getters) { + this.getters = getters; + } + + /** + * The client's entry point for evaluating a flag. No other Evaluator methods should be exposed. + * + * @param flag an existing feature flag; any other referenced flags or segments will be queried via {@link Getters} + * @param user the user to evaluate against + * @param eventFactory produces feature request events + * @return an {@link EvalResult} + */ + EvalResult evaluate(DataModel.FeatureFlag flag, LDUser user, EventFactory eventFactory) { + 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", flag.getKey()); + return new EvalResult(null, NO_VARIATION, EvaluationReason.error(EvaluationReason.ErrorKind.USER_NOT_SPECIFIED)); + } + + // If the flag doesn't have any prerequisites (which most flags don't) then it cannot generate any feature + // request events for prerequisites and we can skip allocating a List. + List prerequisiteEvents = flag.getPrerequisites().isEmpty() ? + null : new ArrayList(); // note, getPrerequisites() is guaranteed non-null + EvalResult result = evaluateInternal(flag, user, eventFactory, prerequisiteEvents); + if (prerequisiteEvents != null) { + result.setPrerequisiteEvents(prerequisiteEvents); + } + return result; + } + + private EvalResult evaluateInternal(DataModel.FeatureFlag flag, LDUser user, EventFactory eventFactory, + List eventsOut) { + if (!flag.isOn()) { + return getOffValue(flag, EvaluationReason.off()); + } + + EvaluationReason prereqFailureReason = checkPrerequisites(flag, user, eventFactory, eventsOut); + if (prereqFailureReason != null) { + return getOffValue(flag, prereqFailureReason); + } + + // Check to see if targets match + for (DataModel.Target target: flag.getTargets()) { // getTargets() and getValues() are guaranteed non-null + if (target.getValues().contains(user.getKey())) { + return getVariation(flag, target.getVariation(), EvaluationReason.targetMatch()); + } + } + // Now walk through the rules and see if any match + List rules = flag.getRules(); // guaranteed non-null + for (int i = 0; i < rules.size(); i++) { + DataModel.Rule rule = rules.get(i); + if (ruleMatchesUser(flag, rule, user)) { + EvaluationReason precomputedReason = rule.getRuleMatchReason(); + EvaluationReason reason = precomputedReason != null ? precomputedReason : EvaluationReason.ruleMatch(i, rule.getId()); + return getValueForVariationOrRollout(flag, rule, user, reason); + } + } + // Walk through the fallthrough and see if it matches + return getValueForVariationOrRollout(flag, flag.getFallthrough(), 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(DataModel.FeatureFlag flag, LDUser user, EventFactory eventFactory, + List eventsOut) { + for (DataModel.Prerequisite prereq: flag.getPrerequisites()) { // getPrerequisites() is guaranteed non-null + boolean prereqOk = true; + DataModel.FeatureFlag prereqFeatureFlag = getters.getFlag(prereq.getKey()); + if (prereqFeatureFlag == null) { + logger.error("Could not retrieve prerequisite flag \"{}\" when evaluating \"{}\"", prereq.getKey(), flag.getKey()); + prereqOk = false; + } else { + EvalResult prereqEvalResult = evaluateInternal(prereqFeatureFlag, user, eventFactory, eventsOut); + // 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; + } + if (eventsOut != null) { + eventsOut.add(eventFactory.newPrerequisiteFeatureRequestEvent(prereqFeatureFlag, user, prereqEvalResult, flag)); + } + } + if (!prereqOk) { + EvaluationReason precomputedReason = prereq.getPrerequisiteFailedReason(); + return precomputedReason != null ? precomputedReason : EvaluationReason.prerequisiteFailed(prereq.getKey()); + } + } + return null; + } + + private EvalResult getVariation(DataModel.FeatureFlag flag, int variation, EvaluationReason reason) { + List variations = flag.getVariations(); + if (variation < 0 || variation >= variations.size()) { + logger.error("Data inconsistency in feature flag \"{}\": invalid variation index", flag.getKey()); + return EvalResult.error(EvaluationReason.ErrorKind.MALFORMED_FLAG); + } else { + return new EvalResult(variations.get(variation), variation, reason); + } + } + + private EvalResult getOffValue(DataModel.FeatureFlag flag, EvaluationReason reason) { + Integer offVariation = flag.getOffVariation(); + if (offVariation == null) { // off variation unspecified - return default value + return new EvalResult(null, NO_VARIATION, reason); + } else { + return getVariation(flag, offVariation, reason); + } + } + + private EvalResult getValueForVariationOrRollout(DataModel.FeatureFlag flag, DataModel.VariationOrRollout vr, LDUser user, EvaluationReason reason) { + Integer index = EvaluatorBucketing.variationIndexForUser(vr, user, flag.getKey(), flag.getSalt()); + if (index == null) { + logger.error("Data inconsistency in feature flag \"{}\": variation/rollout object with no variation or rollout", flag.getKey()); + return EvalResult.error(EvaluationReason.ErrorKind.MALFORMED_FLAG); + } else { + return getVariation(flag, index, reason); + } + } + + private boolean ruleMatchesUser(DataModel.FeatureFlag flag, DataModel.Rule rule, LDUser user) { + for (DataModel.Clause clause: rule.getClauses()) { // getClauses() is guaranteed non-null + if (!clauseMatchesUser(clause, user)) { + return false; + } + } + return true; + } + + private boolean clauseMatchesUser(DataModel.Clause clause, 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 (clause.getOp() == DataModel.Operator.segmentMatch) { + for (LDValue j: clause.getValues()) { + if (j.isString()) { + DataModel.Segment segment = getters.getSegment(j.stringValue()); + if (segment != null) { + if (segmentMatchesUser(segment, user)) { + return maybeNegate(clause, true); + } + } + } + } + return maybeNegate(clause, false); + } + + return clauseMatchesUserNoSegments(clause, user); + } + + private boolean clauseMatchesUserNoSegments(DataModel.Clause clause, LDUser user) { + LDValue userValue = user.getAttribute(clause.getAttribute()); + if (userValue.isNull()) { + return false; + } + + 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 (clauseMatchAny(clause, value)) { + return maybeNegate(clause, true); + } + } + return maybeNegate(clause, false); + } else if (userValue.getType() != LDValueType.OBJECT) { + return maybeNegate(clause, clauseMatchAny(clause, userValue)); + } + logger.warn("Got unexpected user attribute type \"{}\" for user key \"{}\" and attribute \"{}\"", + userValue.getType(), user.getKey(), clause.getAttribute()); + return false; + } + + private boolean clauseMatchAny(DataModel.Clause clause, LDValue userValue) { + DataModel.Operator op = clause.getOp(); + if (op != null) { + for (LDValue v : clause.getValues()) { + if (EvaluatorOperators.apply(op, userValue, v)) { + return true; + } + } + } + return false; + } + + private boolean maybeNegate(DataModel.Clause clause, boolean b) { + return clause.isNegate() ? !b : b; + } + + private boolean segmentMatchesUser(DataModel.Segment segment, LDUser user) { + String userKey = user.getKey(); + if (userKey == null) { + return false; + } + if (segment.getIncluded().contains(userKey)) { // getIncluded(), getExcluded(), and getRules() are guaranteed non-null + return true; + } + if (segment.getExcluded().contains(userKey)) { + return false; + } + for (DataModel.SegmentRule rule: segment.getRules()) { + if (segmentRuleMatchesUser(rule, user, segment.getKey(), segment.getSalt())) { + return true; + } + } + return false; + } + + private boolean segmentRuleMatchesUser(DataModel.SegmentRule segmentRule, LDUser user, String segmentKey, String salt) { + for (DataModel.Clause c: segmentRule.getClauses()) { + if (!clauseMatchesUserNoSegments(c, user)) { + return false; + } + } + + // If the Weight is absent, this rule matches + if (segmentRule.getWeight() == null) { + return true; + } + + // All of the clauses are met. See if the user buckets in + double bucket = EvaluatorBucketing.bucketUser(user, segmentKey, segmentRule.getBucketBy(), salt); + double weight = (double)segmentRule.getWeight() / 100000.0; + return bucket < weight; + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/EvaluatorBucketing.java b/src/main/java/com/launchdarkly/sdk/server/EvaluatorBucketing.java new file mode 100644 index 000000000..f45425e2b --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/EvaluatorBucketing.java @@ -0,0 +1,67 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.UserAttribute; + +import org.apache.commons.codec.digest.DigestUtils; + +/** + * Encapsulates the logic for percentage rollouts. + */ +abstract class EvaluatorBucketing { + private static final float LONG_SCALE = (float) 0xFFFFFFFFFFFFFFFL; + + // 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). + static Integer variationIndexForUser(DataModel.VariationOrRollout vr, LDUser user, String key, String salt) { + Integer variation = vr.getVariation(); + if (variation != null) { + return variation; + } else { + DataModel.Rollout rollout = vr.getRollout(); + if (rollout != null && rollout.getVariations() != null && !rollout.getVariations().isEmpty()) { + float bucket = bucketUser(user, key, rollout.getBucketBy(), salt); + float sum = 0F; + for (DataModel.WeightedVariation wv : rollout.getVariations()) { + sum += (float) wv.getWeight() / 100000F; + if (bucket < sum) { + return wv.getVariation(); + } + } + // 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.getVariations().get(rollout.getVariations().size() - 1).getVariation(); + } + } + return null; + } + + static float bucketUser(LDUser user, String key, UserAttribute attr, String salt) { + LDValue userValue = user.getAttribute(attr == null ? UserAttribute.KEY : attr); + String idHash = getBucketableStringValue(userValue); + if (idHash != null) { + if (user.getSecondary() != null) { + idHash = idHash + "." + user.getSecondary(); + } + String hash = DigestUtils.sha1Hex(key + "." + salt + "." + idHash).substring(0, 15); + long longVal = Long.parseLong(hash, 16); + return (float) longVal / LONG_SCALE; + } + return 0F; + } + + 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; + } + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/EvaluatorOperators.java b/src/main/java/com/launchdarkly/sdk/server/EvaluatorOperators.java new file mode 100644 index 000000000..75d02ae52 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/EvaluatorOperators.java @@ -0,0 +1,143 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.LDValue; + +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.regex.Pattern; + +/** + * Defines the behavior of all operators that can be used in feature flag rules and segment rules. + */ +abstract class EvaluatorOperators { + private static enum ComparisonOp { + EQ, + LT, + LTE, + GT, + GTE; + + boolean test(int delta) { + switch (this) { + case EQ: + return delta == 0; + case LT: + return delta < 0; + case LTE: + return delta <= 0; + case GT: + return delta > 0; + case GTE: + return delta >= 0; + } + return false; + } + } + + static boolean apply(DataModel.Operator op, LDValue userValue, LDValue clauseValue) { + switch (op) { + case in: + return userValue.equals(clauseValue); + + case endsWith: + return userValue.isString() && clauseValue.isString() && userValue.stringValue().endsWith(clauseValue.stringValue()); + + case startsWith: + return userValue.isString() && clauseValue.isString() && userValue.stringValue().startsWith(clauseValue.stringValue()); + + case matches: + return userValue.isString() && clauseValue.isString() && + Pattern.compile(clauseValue.stringValue()).matcher(userValue.stringValue()).find(); + + case contains: + return userValue.isString() && clauseValue.isString() && userValue.stringValue().contains(clauseValue.stringValue()); + + case lessThan: + return compareNumeric(ComparisonOp.LT, userValue, clauseValue); + + case lessThanOrEqual: + return compareNumeric(ComparisonOp.LTE, userValue, clauseValue); + + case greaterThan: + return compareNumeric(ComparisonOp.GT, userValue, clauseValue); + + case greaterThanOrEqual: + return compareNumeric(ComparisonOp.GTE, userValue, clauseValue); + + case before: + return compareDate(ComparisonOp.LT, userValue, clauseValue); + + case after: + return compareDate(ComparisonOp.GT, userValue, clauseValue); + + case semVerEqual: + return compareSemVer(ComparisonOp.EQ, userValue, clauseValue); + + case semVerLessThan: + return compareSemVer(ComparisonOp.LT, userValue, clauseValue); + + case semVerGreaterThan: + return compareSemVer(ComparisonOp.GT, userValue, clauseValue); + + case segmentMatch: + // We shouldn't call apply() for this operator, because it is really implemented in + // Evaluator.clauseMatchesUser(). + return false; + }; + return false; + } + + private static boolean compareNumeric(ComparisonOp op, LDValue userValue, LDValue clauseValue) { + if (!userValue.isNumber() || !clauseValue.isNumber()) { + return false; + } + double n1 = userValue.doubleValue(); + double n2 = clauseValue.doubleValue(); + int compare = n1 == n2 ? 0 : (n1 < n2 ? -1 : 1); + return op.test(compare); + } + + private static boolean compareDate(ComparisonOp op, LDValue userValue, LDValue clauseValue) { + ZonedDateTime dt1 = valueToDateTime(userValue); + ZonedDateTime dt2 = valueToDateTime(clauseValue); + if (dt1 == null || dt2 == null) { + return false; + } + return op.test(dt1.compareTo(dt2)); + } + + private static boolean compareSemVer(ComparisonOp op, LDValue userValue, LDValue clauseValue) { + SemanticVersion sv1 = valueToSemVer(userValue); + SemanticVersion sv2 = valueToSemVer(clauseValue); + if (sv1 == null || sv2 == null) { + return false; + } + return op.test(sv1.compareTo(sv2)); + } + + private static ZonedDateTime valueToDateTime(LDValue value) { + if (value.isNumber()) { + return ZonedDateTime.ofInstant(Instant.ofEpochMilli(value.longValue()), ZoneOffset.UTC); + } else if (value.isString()) { + try { + return ZonedDateTime.parse(value.stringValue()); + } catch (Throwable t) { + return null; + } + } else { + return null; + } + } + + private static SemanticVersion valueToSemVer(LDValue value) { + if (!value.isString()) { + return null; + } + try { + return SemanticVersion.parse(value.stringValue(), true); + } catch (SemanticVersion.InvalidVersionException e) { + return null; + } + } +} diff --git a/src/main/java/com/launchdarkly/client/EventFactory.java b/src/main/java/com/launchdarkly/sdk/server/EventFactory.java similarity index 52% rename from src/main/java/com/launchdarkly/client/EventFactory.java rename to src/main/java/com/launchdarkly/sdk/server/EventFactory.java index 4afc7240f..39a9e1346 100644 --- a/src/main/java/com/launchdarkly/client/EventFactory.java +++ b/src/main/java/com/launchdarkly/sdk/server/EventFactory.java @@ -1,6 +1,9 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.interfaces.Event; abstract class EventFactory { public static final EventFactory DEFAULT = new DefaultEventFactory(false); @@ -9,8 +12,8 @@ abstract class EventFactory { protected abstract long getTimestamp(); protected abstract boolean isIncludeReasons(); - public Event.FeatureRequest newFeatureRequestEvent(FeatureFlag flag, LDUser user, LDValue value, - Integer variationIndex, EvaluationReason reason, LDValue defaultValue, String prereqOf) { + public Event.FeatureRequest newFeatureRequestEvent(DataModel.FeatureFlag flag, LDUser user, LDValue value, + int variationIndex, EvaluationReason reason, LDValue defaultValue, String prereqOf) { boolean requireExperimentData = isExperiment(flag, reason); return new Event.FeatureRequest( getTimestamp(), @@ -23,40 +26,70 @@ public Event.FeatureRequest newFeatureRequestEvent(FeatureFlag flag, LDUser user (requireExperimentData || isIncludeReasons()) ? reason : null, prereqOf, requireExperimentData || flag.isTrackEvents(), - flag.getDebugEventsUntilDate(), + flag.getDebugEventsUntilDate() == null ? 0 : flag.getDebugEventsUntilDate().longValue(), false ); } - public Event.FeatureRequest newFeatureRequestEvent(FeatureFlag flag, LDUser user, EvaluationDetail result, LDValue defaultVal) { + public Event.FeatureRequest newFeatureRequestEvent(DataModel.FeatureFlag flag, LDUser user, Evaluator.EvalResult result, LDValue defaultVal) { return newFeatureRequestEvent(flag, user, result == null ? null : result.getValue(), - result == null ? null : result.getVariationIndex(), result == null ? null : result.getReason(), + result == null ? -1 : result.getVariationIndex(), result == null ? null : result.getReason(), defaultVal, null); } - public Event.FeatureRequest newDefaultFeatureRequestEvent(FeatureFlag flag, LDUser user, LDValue defaultValue, + public Event.FeatureRequest newDefaultFeatureRequestEvent(DataModel.FeatureFlag flag, LDUser user, LDValue defaultValue, EvaluationReason.ErrorKind errorKind) { - return new Event.FeatureRequest(getTimestamp(), flag.getKey(), user, flag.getVersion(), - null, defaultValue, defaultValue, isIncludeReasons() ? EvaluationReason.error(errorKind) : null, - null, flag.isTrackEvents(), flag.getDebugEventsUntilDate(), false); + return new Event.FeatureRequest( + getTimestamp(), + flag.getKey(), + user, + flag.getVersion(), + -1, + defaultValue, + defaultValue, + isIncludeReasons() ? EvaluationReason.error(errorKind) : null, + null, + flag.isTrackEvents(), + flag.getDebugEventsUntilDate() == null ? 0 : flag.getDebugEventsUntilDate().longValue(), + false + ); } public Event.FeatureRequest newUnknownFeatureRequestEvent(String key, LDUser user, LDValue defaultValue, EvaluationReason.ErrorKind errorKind) { - return new Event.FeatureRequest(getTimestamp(), key, user, null, null, defaultValue, defaultValue, - isIncludeReasons() ? EvaluationReason.error(errorKind) : null, null, false, null, false); + return new Event.FeatureRequest( + getTimestamp(), + key, + user, + -1, + -1, + defaultValue, + defaultValue, + isIncludeReasons() ? EvaluationReason.error(errorKind) : null, + null, + false, + 0, + false + ); } - 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(), - LDValue.ofNull(), prereqOf.getKey()); + public Event.FeatureRequest newPrerequisiteFeatureRequestEvent(DataModel.FeatureFlag prereqFlag, LDUser user, + Evaluator.EvalResult details, DataModel.FeatureFlag prereqOf) { + return newFeatureRequestEvent( + prereqFlag, + user, + details == null ? null : details.getValue(), + details == null ? -1 : details.getVariationIndex(), + details == null ? null : details.getReason(), + 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.reason, from.prereqOf, from.trackEvents, from.debugEventsUntilDate, true); + return new Event.FeatureRequest( + from.getCreationDate(), from.getKey(), from.getUser(), from.getVersion(), from.getVariation(), from.getValue(), + from.getDefaultVal(), from.getReason(), from.getPrereqOf(), from.isTrackEvents(), from.getDebugEventsUntilDate(), true); } public Event.Custom newCustomEvent(String key, LDUser user, LDValue data, Double metricValue) { @@ -67,7 +100,7 @@ public Event.Identify newIdentifyEvent(LDUser user) { return new Event.Identify(getTimestamp(), user); } - private boolean isExperiment(FeatureFlag flag, EvaluationReason reason) { + private boolean isExperiment(DataModel.FeatureFlag flag, EvaluationReason reason) { if (reason == null) { // doesn't happen in real life, but possible in testing return false; @@ -76,17 +109,12 @@ private boolean isExperiment(FeatureFlag flag, EvaluationReason reason) { 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(); + int ruleIndex = reason.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); + DataModel.Rule rule = flag.getRules().get(ruleIndex); return rule.isTrackEvents(); } return false; diff --git a/src/main/java/com/launchdarkly/client/EventOutputFormatter.java b/src/main/java/com/launchdarkly/sdk/server/EventOutputFormatter.java similarity index 80% rename from src/main/java/com/launchdarkly/client/EventOutputFormatter.java rename to src/main/java/com/launchdarkly/sdk/server/EventOutputFormatter.java index 03ba5a12c..5984cb2a0 100644 --- a/src/main/java/com/launchdarkly/client/EventOutputFormatter.java +++ b/src/main/java/com/launchdarkly/sdk/server/EventOutputFormatter.java @@ -1,10 +1,13 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.gson.Gson; 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 com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.EventSummarizer.CounterKey; +import com.launchdarkly.sdk.server.EventSummarizer.CounterValue; +import com.launchdarkly.sdk.server.interfaces.Event; import java.io.IOException; import java.io.Writer; @@ -44,41 +47,41 @@ int writeOutputEvents(Event[] events, EventSummarizer.EventSummary summary, Writ 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) { + startEvent(fe, fe.isDebug() ? "debug" : "feature", fe.getKey(), jw); + writeUserOrKey(fe, fe.isDebug(), jw); + if (fe.getVersion() >= 0) { jw.name("version"); - jw.value(fe.version); + jw.value(fe.getVersion()); } - if (fe.variation != null) { + if (fe.getVariation() >= 0) { jw.name("variation"); - jw.value(fe.variation); + jw.value(fe.getVariation()); } - writeLDValue("value", fe.value, jw); - writeLDValue("default", fe.defaultVal, jw); - if (fe.prereqOf != null) { + writeLDValue("value", fe.getValue(), jw); + writeLDValue("default", fe.getDefaultVal(), jw); + if (fe.getPrereqOf() != null) { jw.name("prereqOf"); - jw.value(fe.prereqOf); + jw.value(fe.getPrereqOf()); } - writeEvaluationReason("reason", fe.reason, jw); + writeEvaluationReason("reason", fe.getReason(), jw); jw.endObject(); } else if (event instanceof Event.Identify) { - startEvent(event, "identify", event.user == null ? null : event.user.getKeyAsString(), jw); - writeUser(event.user, jw); + startEvent(event, "identify", event.getUser() == null ? null : event.getUser().getKey(), jw); + writeUser(event.getUser(), jw); jw.endObject(); } else if (event instanceof Event.Custom) { Event.Custom ce = (Event.Custom)event; - startEvent(event, "custom", ce.key, jw); + startEvent(event, "custom", ce.getKey(), jw); writeUserOrKey(ce, false, jw); - writeLDValue("data", ce.data, jw); - if (ce.metricValue != null) { + writeLDValue("data", ce.getData(), jw); + if (ce.getMetricValue() != null) { jw.name("metricValue"); - jw.value(ce.metricValue); + jw.value(ce.getMetricValue()); } jw.endObject(); } else if (event instanceof Event.Index) { startEvent(event, "index", null, jw); - writeUser(event.user, jw); + writeUser(event.getUser(), jw); jw.endObject(); } else { return false; @@ -127,11 +130,11 @@ private void writeSummaryEvent(EventSummarizer.EventSummary summary, JsonWriter jw.beginObject(); - if (keyForThisFlag.variation != null) { + if (keyForThisFlag.variation >= 0) { jw.name("variation"); jw.value(keyForThisFlag.variation); } - if (keyForThisFlag.version != null) { + if (keyForThisFlag.version >= 0) { jw.name("version"); jw.value(keyForThisFlag.version); } else { @@ -160,7 +163,7 @@ private void startEvent(Event event, String kind, String key, JsonWriter jw) thr jw.name("kind"); jw.value(kind); jw.name("creationDate"); - jw.value(event.creationDate); + jw.value(event.getCreationDate()); if (key != null) { jw.name("key"); jw.value(key); @@ -168,13 +171,13 @@ private void startEvent(Event event, String kind, String key, JsonWriter jw) thr } private void writeUserOrKey(Event event, boolean forceInline, JsonWriter jw) throws IOException { - LDUser user = event.user; + LDUser user = event.getUser(); if (user != null) { if (config.inlineUsersInEvents || forceInline) { writeUser(user, jw); } else { jw.name("userKey"); - jw.value(user.getKeyAsString()); + jw.value(user.getKey()); } } } diff --git a/src/main/java/com/launchdarkly/client/EventSummarizer.java b/src/main/java/com/launchdarkly/sdk/server/EventSummarizer.java similarity index 83% rename from src/main/java/com/launchdarkly/client/EventSummarizer.java rename to src/main/java/com/launchdarkly/sdk/server/EventSummarizer.java index b21c0d870..eaa3bd583 100644 --- a/src/main/java/com/launchdarkly/client/EventSummarizer.java +++ b/src/main/java/com/launchdarkly/sdk/server/EventSummarizer.java @@ -1,6 +1,7 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.interfaces.Event; import java.util.HashMap; import java.util.Map; @@ -25,8 +26,8 @@ final class EventSummarizer { void summarizeEvent(Event event) { if (event instanceof Event.FeatureRequest) { Event.FeatureRequest fe = (Event.FeatureRequest)event; - eventsState.incrementCounter(fe.key, fe.variation, fe.version, fe.value, fe.defaultVal); - eventsState.noteTimestamp(fe.creationDate); + eventsState.incrementCounter(fe.getKey(), fe.getVariation(), fe.getVersion(), fe.getValue(), fe.getDefaultVal()); + eventsState.noteTimestamp(fe.getCreationDate()); } } @@ -64,7 +65,7 @@ boolean isEmpty() { return counters.isEmpty(); } - void incrementCounter(String flagKey, Integer variation, Integer version, LDValue flagValue, LDValue defaultVal) { + void incrementCounter(String flagKey, int variation, int version, LDValue flagValue, LDValue defaultVal) { CounterKey key = new CounterKey(flagKey, variation, version); CounterValue value = counters.get(key); @@ -101,10 +102,10 @@ public int hashCode() { static final class CounterKey { final String key; - final Integer variation; - final Integer version; + final int variation; + final int version; - CounterKey(String key, Integer variation, Integer version) { + CounterKey(String key, int variation, int version) { this.key = key; this.variation = variation; this.version = version; @@ -114,15 +115,15 @@ static final class CounterKey { public boolean equals(Object other) { if (other instanceof CounterKey) { CounterKey o = (CounterKey)other; - return o.key.equals(this.key) && Objects.equals(o.variation, this.variation) && - Objects.equals(o.version, this.version); + return o.key.equals(this.key) && o.variation == this.variation && + o.version == this.version; } return false; } @Override public int hashCode() { - return key.hashCode() + 31 * (Objects.hashCode(variation) + 31 * Objects.hashCode(version)); + return key.hashCode() + 31 * (variation + 31 * version); } @Override diff --git a/src/main/java/com/launchdarkly/sdk/server/EventUserSerialization.java b/src/main/java/com/launchdarkly/sdk/server/EventUserSerialization.java new file mode 100644 index 000000000..5b49a5b47 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/EventUserSerialization.java @@ -0,0 +1,111 @@ +package com.launchdarkly.sdk.server; + +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.UserAttribute; + +import java.io.IOException; +import java.util.Set; +import java.util.TreeSet; + +class EventUserSerialization { + + // Used internally when including users in analytics events, to ensure that private attributes are stripped out. + static class UserAdapterWithPrivateAttributeBehavior extends TypeAdapter { + private static final UserAttribute[] BUILT_IN_OPTIONAL_STRING_ATTRIBUTES = new UserAttribute[] { + UserAttribute.SECONDARY_KEY, + UserAttribute.IP, + UserAttribute.EMAIL, + UserAttribute.NAME, + UserAttribute.AVATAR, + UserAttribute.FIRST_NAME, + UserAttribute.LAST_NAME, + UserAttribute.COUNTRY + }; + + private final EventsConfiguration config; + + public UserAdapterWithPrivateAttributeBehavior(EventsConfiguration config) { + this.config = config; + } + + @Override + public void write(JsonWriter out, LDUser user) throws IOException { + if (user == null) { + out.value((String)null); + return; + } + + // Collect the private attribute names (use TreeSet to make ordering predictable for tests) + Set privateAttributeNames = new TreeSet(); + + out.beginObject(); + // The key can never be private + out.name("key").value(user.getKey()); + + for (UserAttribute attr: BUILT_IN_OPTIONAL_STRING_ATTRIBUTES) { + LDValue value = user.getAttribute(attr); + if (!value.isNull()) { + if (!checkAndAddPrivate(attr, user, privateAttributeNames)) { + out.name(attr.getName()).value(value.stringValue()); + } + } + } + if (!user.getAttribute(UserAttribute.ANONYMOUS).isNull()) { + out.name("anonymous").value(user.isAnonymous()); + } + writeCustomAttrs(out, user, privateAttributeNames); + writePrivateAttrNames(out, privateAttributeNames); + + out.endObject(); + } + + private void writePrivateAttrNames(JsonWriter out, Set names) throws IOException { + if (names.isEmpty()) { + return; + } + out.name("privateAttrs"); + out.beginArray(); + for (String name : names) { + out.value(name); + } + out.endArray(); + } + + private boolean checkAndAddPrivate(UserAttribute attribute, LDUser user, Set privateAttrs) { + boolean result = config.allAttributesPrivate || config.privateAttributes.contains(attribute) || user.isAttributePrivate(attribute); + if (result) { + privateAttrs.add(attribute.getName()); + } + return result; + } + + private void writeCustomAttrs(JsonWriter out, LDUser user, Set privateAttributeNames) throws IOException { + boolean beganObject = false; + for (UserAttribute attribute: user.getCustomAttributes()) { + if (!checkAndAddPrivate(attribute, user, privateAttributeNames)) { + if (!beganObject) { + out.name("custom"); + out.beginObject(); + beganObject = true; + } + out.name(attribute.getName()); + LDValue value = user.getAttribute(attribute); + JsonHelpers.gsonInstance().toJson(value, LDValue.class, out); + } + } + if (beganObject) { + out.endObject(); + } + } + + @Override + public LDUser read(JsonReader in) throws IOException { + // We never need to unmarshal user objects, so there's no need to implement this + return null; + } + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/EventsConfiguration.java b/src/main/java/com/launchdarkly/sdk/server/EventsConfiguration.java new file mode 100644 index 000000000..92acfb4b0 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/EventsConfiguration.java @@ -0,0 +1,38 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.collect.ImmutableSet; +import com.launchdarkly.sdk.UserAttribute; + +import java.net.URI; +import java.time.Duration; +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 Duration flushInterval; + final boolean inlineUsersInEvents; + final ImmutableSet privateAttributes; + final int samplingInterval; + final int userKeysCapacity; + final Duration userKeysFlushInterval; + final Duration diagnosticRecordingInterval; + + EventsConfiguration(boolean allAttributesPrivate, int capacity, URI eventsUri, Duration flushInterval, + boolean inlineUsersInEvents, Set privateAttributes, int samplingInterval, + int userKeysCapacity, Duration userKeysFlushInterval, Duration diagnosticRecordingInterval) { + super(); + this.allAttributesPrivate = allAttributesPrivate; + this.capacity = capacity; + this.eventsUri = eventsUri == null ? LDConfig.DEFAULT_EVENTS_URI : eventsUri; + this.flushInterval = flushInterval; + this.inlineUsersInEvents = inlineUsersInEvents; + this.privateAttributes = privateAttributes == null ? ImmutableSet.of() : ImmutableSet.copyOf(privateAttributes); + this.samplingInterval = samplingInterval; + this.userKeysCapacity = userKeysCapacity; + this.userKeysFlushInterval = userKeysFlushInterval; + this.diagnosticRecordingInterval = diagnosticRecordingInterval; + } +} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java b/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java similarity index 71% rename from src/main/java/com/launchdarkly/client/FeatureFlagsState.java rename to src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java index 0b9577496..af9c4ccb5 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java +++ b/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java @@ -1,33 +1,41 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; -import com.google.common.base.Objects; -import com.google.gson.Gson; -import com.google.gson.JsonElement; import com.google.gson.TypeAdapter; 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 com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.json.JsonSerializable; import java.io.IOException; import java.util.Collections; import java.util.HashMap; import java.util.Map; +import java.util.Objects; + +import static com.launchdarkly.sdk.server.JsonHelpers.gsonInstance; /** * A snapshot of the state of all feature flags with regard to a specific user, generated by - * calling {@link LDClientInterface#allFlagsState(LDUser, FlagsStateOption...)}. + * calling {@link LDClientInterface#allFlagsState(com.launchdarkly.sdk.LDUser, FlagsStateOption...)}. *

    - * Serializing this object to JSON using Gson will produce the appropriate data structure for - * bootstrapping the LaunchDarkly JavaScript client. + * LaunchDarkly defines a standard JSON encoding for this object, suitable for + * bootstrapping + * the LaunchDarkly JavaScript browser SDK. You can convert it to JSON in any of these ways: + *

      + *
    1. With {@link com.launchdarkly.sdk.json.JsonSerialization}. + *
    2. With Gson, if and only if you configure your {@code Gson} instance with + * {@link com.launchdarkly.sdk.json.LDGson}. + *
    3. With Jackson, if and only if you configure your {@code ObjectMapper} instance with + * {@link com.launchdarkly.sdk.json.LDJackson}. + *
    * * @since 4.3.0 */ @JsonAdapter(FeatureFlagsState.JsonSerialization.class) -public class FeatureFlagsState { - private static final Gson gson = new Gson(); - - private final Map flagValues; +public class FeatureFlagsState implements JsonSerializable { + private final Map flagValues; private final Map flagMetadata; private final boolean valid; @@ -51,21 +59,21 @@ static class FlagMetadata { public boolean equals(Object other) { if (other instanceof FlagMetadata) { FlagMetadata o = (FlagMetadata)other; - return Objects.equal(variation, o.variation) && - Objects.equal(version, o.version) && - Objects.equal(trackEvents, o.trackEvents) && - Objects.equal(debugEventsUntilDate, o.debugEventsUntilDate); + return Objects.equals(variation, o.variation) && + Objects.equals(version, o.version) && + Objects.equals(trackEvents, o.trackEvents) && + Objects.equals(debugEventsUntilDate, o.debugEventsUntilDate); } return false; } @Override public int hashCode() { - return Objects.hashCode(variation, version, trackEvents, debugEventsUntilDate); + return Objects.hash(variation, version, trackEvents, debugEventsUntilDate); } } - private FeatureFlagsState(Map flagValues, + private FeatureFlagsState(Map flagValues, Map flagMetadata, boolean valid) { this.flagValues = Collections.unmodifiableMap(flagValues); this.flagMetadata = Collections.unmodifiableMap(flagMetadata); @@ -86,7 +94,7 @@ public boolean isValid() { * @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) { + public LDValue getFlagValue(String key) { return flagValues.get(key); } @@ -108,7 +116,7 @@ public EvaluationReason getFlagReason(String key) { * 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() { + public Map toValuesMap() { return flagValues; } @@ -125,11 +133,11 @@ public boolean equals(Object other) { @Override public int hashCode() { - return Objects.hashCode(flagValues, flagMetadata, valid); + return Objects.hash(flagValues, flagMetadata, valid); } static class Builder { - private Map flagValues = new HashMap<>(); + private Map flagValues = new HashMap<>(); private Map flagMetadata = new HashMap<>(); private final boolean saveReasons; private final boolean detailsOnlyForTrackedFlags; @@ -145,13 +153,13 @@ Builder valid(boolean valid) { return this; } - @SuppressWarnings("deprecation") - Builder addFlag(FeatureFlag flag, EvaluationDetail eval) { - flagValues.put(flag.getKey(), eval.getValue().asUnsafeJsonElement()); + Builder addFlag(DataModel.FeatureFlag flag, Evaluator.EvalResult 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(), + FlagMetadata data = new FlagMetadata( + eval.isDefault() ? null : eval.getVariationIndex(), (saveReasons && wantDetails) ? eval.getReason() : null, wantDetails ? flag.getVersion() : null, flag.isTrackEvents(), @@ -169,12 +177,12 @@ static class JsonSerialization extends TypeAdapter { @Override public void write(JsonWriter out, FeatureFlagsState state) throws IOException { out.beginObject(); - for (Map.Entry entry: state.flagValues.entrySet()) { + for (Map.Entry entry: state.flagValues.entrySet()) { out.name(entry.getKey()); - gson.toJson(entry.getValue(), out); + gsonInstance().toJson(entry.getValue(), LDValue.class, out); } out.name("$flagsState"); - gson.toJson(state.flagMetadata, Map.class, out); + gsonInstance().toJson(state.flagMetadata, Map.class, out); out.name("$valid"); out.value(state.valid); out.endObject(); @@ -183,7 +191,7 @@ 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 { - Map flagValues = new HashMap<>(); + Map flagValues = new HashMap<>(); Map flagMetadata = new HashMap<>(); boolean valid = true; in.beginObject(); @@ -193,14 +201,14 @@ public FeatureFlagsState read(JsonReader in) throws IOException { in.beginObject(); while (in.hasNext()) { String metaName = in.nextName(); - FlagMetadata meta = gson.fromJson(in, FlagMetadata.class); + FlagMetadata meta = gsonInstance().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); + LDValue value = gsonInstance().fromJson(in, LDValue.class); flagValues.put(name, value); } } diff --git a/src/main/java/com/launchdarkly/sdk/server/FeatureRequestor.java b/src/main/java/com/launchdarkly/sdk/server/FeatureRequestor.java new file mode 100644 index 000000000..e1e5b3003 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/FeatureRequestor.java @@ -0,0 +1,53 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.launchdarkly.sdk.server.DataModel.VersionedData; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; + +import java.io.Closeable; +import java.io.IOException; +import java.util.AbstractMap; +import java.util.Map; + +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; + +interface FeatureRequestor extends Closeable { + DataModel.FeatureFlag getFlag(String featureKey) throws IOException, HttpErrorException; + + DataModel.Segment getSegment(String segmentKey) throws IOException, HttpErrorException; + + AllData getAllData() throws IOException, HttpErrorException; + + static class AllData { + final Map flags; + final Map segments; + + AllData(Map flags, Map segments) { + this.flags = flags; + this.segments = segments; + } + + FullDataSet toFullDataSet() { + return new FullDataSet(ImmutableMap.of( + FEATURES, toKeyedItems(FEATURES, flags), + SEGMENTS, toKeyedItems(SEGMENTS, segments) + ).entrySet()); + } + + static KeyedItems toKeyedItems(DataKind kind, Map itemsMap) { + ImmutableList.Builder> builder = ImmutableList.builder(); + if (itemsMap != null) { + for (Map.Entry e: itemsMap.entrySet()) { + ItemDescriptor item = new ItemDescriptor(e.getValue().getVersion(), e.getValue()); + builder.add(new AbstractMap.SimpleEntry<>(e.getKey(), item)); + } + } + return new KeyedItems<>(builder.build()); + } + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/FlagChangeEventPublisher.java b/src/main/java/com/launchdarkly/sdk/server/FlagChangeEventPublisher.java new file mode 100644 index 000000000..217174b32 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/FlagChangeEventPublisher.java @@ -0,0 +1,58 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.util.concurrent.ThreadFactoryBuilder; +import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; +import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; + +import java.io.Closeable; +import java.io.IOException; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; + +final class FlagChangeEventPublisher implements Closeable { + private final CopyOnWriteArrayList listeners = new CopyOnWriteArrayList<>(); + private volatile ExecutorService executor = null; + + public void register(FlagChangeListener listener) { + listeners.add(listener); + synchronized (this) { + if (executor == null) { + executor = createExecutorService(); + } + } + } + + public void unregister(FlagChangeListener listener) { + listeners.remove(listener); + } + + public boolean hasListeners() { + return !listeners.isEmpty(); + } + + public void publishEvent(FlagChangeEvent event) { + for (FlagChangeListener l: listeners) { + executor.execute(() -> { + l.onFlagChange(event); + }); + } + } + + @Override + public void close() throws IOException { + if (executor != null) { + executor.shutdown(); + } + } + + private ExecutorService createExecutorService() { + ThreadFactory threadFactory = new ThreadFactoryBuilder() + .setDaemon(true) + .setNameFormat("LaunchDarkly-FlagChangeEventPublisher-%d") + .setPriority(Thread.MIN_PRIORITY) + .build(); + return Executors.newCachedThreadPool(threadFactory); + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/FlagValueMonitoringListener.java b/src/main/java/com/launchdarkly/sdk/server/FlagValueMonitoringListener.java new file mode 100644 index 000000000..0d756bdae --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/FlagValueMonitoringListener.java @@ -0,0 +1,42 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; +import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; +import com.launchdarkly.sdk.server.interfaces.FlagValueChangeEvent; +import com.launchdarkly.sdk.server.interfaces.FlagValueChangeListener; + +import java.util.concurrent.atomic.AtomicReference; + +/** + * Implementation of the flag change listener wrapper provided by + * {@link Components#flagValueMonitoringListener(LDClientInterface, String, com.launchdarkly.sdk.LDUser, com.launchdarkly.sdk.server.interfaces.FlagValueChangeListener)}. + * This class is deliberately not public, it is an implementation detail. + */ +final class FlagValueMonitoringListener implements FlagChangeListener { + private final LDClientInterface client; + private final AtomicReference currentValue = new AtomicReference<>(LDValue.ofNull()); + private final String flagKey; + private final LDUser user; + private final FlagValueChangeListener valueChangeListener; + + public FlagValueMonitoringListener(LDClientInterface client, String flagKey, LDUser user, FlagValueChangeListener valueChangeListener) { + this.client = client; + this.flagKey = flagKey; + this.user = user; + this.valueChangeListener = valueChangeListener; + currentValue.set(client.jsonValueVariation(flagKey, user, LDValue.ofNull())); + } + + @Override + public void onFlagChange(FlagChangeEvent event) { + if (event.getKey().equals(flagKey)) { + LDValue newValue = client.jsonValueVariation(flagKey, user, LDValue.ofNull()); + LDValue previousValue = currentValue.getAndSet(newValue); + if (!newValue.equals(previousValue)) { + valueChangeListener.onFlagValueChange(new FlagValueChangeEvent(flagKey, previousValue, newValue)); + } + } + } +} diff --git a/src/main/java/com/launchdarkly/client/FlagsStateOption.java b/src/main/java/com/launchdarkly/sdk/server/FlagsStateOption.java similarity index 90% rename from src/main/java/com/launchdarkly/client/FlagsStateOption.java rename to src/main/java/com/launchdarkly/sdk/server/FlagsStateOption.java index 71cb14829..79359fefd 100644 --- a/src/main/java/com/launchdarkly/client/FlagsStateOption.java +++ b/src/main/java/com/launchdarkly/sdk/server/FlagsStateOption.java @@ -1,7 +1,9 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.EvaluationReason; /** - * Optional parameters that can be passed to {@link LDClientInterface#allFlagsState(LDUser, FlagsStateOption...)}. + * Optional parameters that can be passed to {@link LDClientInterface#allFlagsState(com.launchdarkly.sdk.LDUser, FlagsStateOption...)}. * @since 4.3.0 */ public final class FlagsStateOption { diff --git a/src/main/java/com/launchdarkly/client/HttpConfigurationImpl.java b/src/main/java/com/launchdarkly/sdk/server/HttpConfigurationImpl.java similarity index 59% rename from src/main/java/com/launchdarkly/client/HttpConfigurationImpl.java rename to src/main/java/com/launchdarkly/sdk/server/HttpConfigurationImpl.java index c1e9b3e7c..7a616c58a 100644 --- a/src/main/java/com/launchdarkly/client/HttpConfigurationImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/HttpConfigurationImpl.java @@ -1,37 +1,38 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; -import com.launchdarkly.client.interfaces.HttpAuthentication; -import com.launchdarkly.client.interfaces.HttpConfiguration; +import com.launchdarkly.sdk.server.interfaces.HttpAuthentication; +import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; import java.net.Proxy; +import java.time.Duration; import javax.net.ssl.SSLSocketFactory; import javax.net.ssl.X509TrustManager; final class HttpConfigurationImpl implements HttpConfiguration { - final int connectTimeoutMillis; + final Duration connectTimeout; final Proxy proxy; final HttpAuthentication proxyAuth; - final int socketTimeoutMillis; + final Duration socketTimeout; final SSLSocketFactory sslSocketFactory; final X509TrustManager trustManager; final String wrapper; - HttpConfigurationImpl(int connectTimeoutMillis, Proxy proxy, HttpAuthentication proxyAuth, - int socketTimeoutMillis, SSLSocketFactory sslSocketFactory, X509TrustManager trustManager, + HttpConfigurationImpl(Duration connectTimeout, Proxy proxy, HttpAuthentication proxyAuth, + Duration socketTimeout, SSLSocketFactory sslSocketFactory, X509TrustManager trustManager, String wrapper) { - this.connectTimeoutMillis = connectTimeoutMillis; + this.connectTimeout = connectTimeout; this.proxy = proxy; this.proxyAuth = proxyAuth; - this.socketTimeoutMillis = socketTimeoutMillis; + this.socketTimeout = socketTimeout; this.sslSocketFactory = sslSocketFactory; this.trustManager = trustManager; this.wrapper = wrapper; } @Override - public int getConnectTimeoutMillis() { - return connectTimeoutMillis; + public Duration getConnectTimeout() { + return connectTimeout; } @Override @@ -45,8 +46,8 @@ public HttpAuthentication getProxyAuthentication() { } @Override - public int getSocketTimeoutMillis() { - return socketTimeoutMillis; + public Duration getSocketTimeout() { + return socketTimeout; } @Override diff --git a/src/main/java/com/launchdarkly/client/HttpErrorException.java b/src/main/java/com/launchdarkly/sdk/server/HttpErrorException.java similarity index 88% rename from src/main/java/com/launchdarkly/client/HttpErrorException.java rename to src/main/java/com/launchdarkly/sdk/server/HttpErrorException.java index 8450e260f..30b10f3ff 100644 --- a/src/main/java/com/launchdarkly/client/HttpErrorException.java +++ b/src/main/java/com/launchdarkly/sdk/server/HttpErrorException.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; @SuppressWarnings("serial") final class HttpErrorException extends Exception { diff --git a/src/main/java/com/launchdarkly/sdk/server/InMemoryDataStore.java b/src/main/java/com/launchdarkly/sdk/server/InMemoryDataStore.java new file mode 100644 index 000000000..aea263cbc --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/InMemoryDataStore.java @@ -0,0 +1,117 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; +import com.launchdarkly.sdk.server.interfaces.DiagnosticDescription; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +/** + * A thread-safe, versioned store for feature flags and related data based on a + * {@link HashMap}. This is the default implementation of {@link DataStore}. + * + * As of version 5.0.0, this is package-private; applications must use the factory method + * {@link Components#inMemoryDataStore()}. + */ +class InMemoryDataStore implements DataStore, DiagnosticDescription { + private volatile ImmutableMap> allData = ImmutableMap.of(); + private volatile boolean initialized = false; + private Object writeLock = new Object(); + + @Override + public void init(FullDataSet allData) { + synchronized (writeLock) { + ImmutableMap.Builder> newData = ImmutableMap.builder(); + for (Map.Entry> entry: allData.getData()) { + newData.put(entry.getKey(), ImmutableMap.copyOf(entry.getValue().getItems())); + } + this.allData = newData.build(); // replaces the entire map atomically + this.initialized = true; + } + } + + @Override + public ItemDescriptor get(DataKind kind, String key) { + Map items = allData.get(kind); + if (items == null) { + return null; + } + return items.get(key); + } + + @Override + public KeyedItems getAll(DataKind kind) { + Map items = allData.get(kind); + if (items == null) { + return new KeyedItems<>(null); + } + return new KeyedItems<>(ImmutableList.copyOf(items.entrySet())); + } + + @Override + public boolean upsert(DataKind kind, String key, ItemDescriptor item) { + synchronized (writeLock) { + Map existingItems = this.allData.get(kind); + ItemDescriptor oldItem = null; + if (existingItems != null) { + oldItem = existingItems.get(key); + if (oldItem != null && oldItem.getVersion() >= item.getVersion()) { + return false; + } + } + // The following logic is necessary because ImmutableMap.Builder doesn't support overwriting an existing key + ImmutableMap.Builder> newData = ImmutableMap.builder(); + for (Map.Entry> 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()); + } + this.allData = newData.build(); // replaces the entire map atomically + return true; + } + } + + @Override + public boolean isInitialized() { + return initialized; + } + + /** + * Does nothing; this class does not have any resources to release + * + * @throws IOException will never happen + */ + @Override + public void close() throws IOException { + return; + } + + @Override + public LDValue describeConfiguration(LDConfig config) { + return LDValue.of("memory"); + } +} diff --git a/src/main/java/com/launchdarkly/client/JsonHelpers.java b/src/main/java/com/launchdarkly/sdk/server/JsonHelpers.java similarity index 86% rename from src/main/java/com/launchdarkly/client/JsonHelpers.java rename to src/main/java/com/launchdarkly/sdk/server/JsonHelpers.java index 7fb1c0095..d5464e8e3 100644 --- a/src/main/java/com/launchdarkly/client/JsonHelpers.java +++ b/src/main/java/com/launchdarkly/sdk/server/JsonHelpers.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.gson.Gson; import com.google.gson.GsonBuilder; @@ -9,12 +9,17 @@ 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 com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.server.DataModel.FeatureFlag; +import com.launchdarkly.sdk.server.DataModel.Segment; +import com.launchdarkly.sdk.server.DataModel.VersionedData; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.SerializationException; import java.io.IOException; -import static com.launchdarkly.client.VersionedDataKind.FEATURES; -import static com.launchdarkly.client.VersionedDataKind.SEGMENTS; +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; abstract class JsonHelpers { private static final Gson gson = new Gson(); @@ -34,7 +39,7 @@ static Gson gsonInstance() { */ static Gson gsonInstanceForEventsSerialization(EventsConfiguration config) { return new GsonBuilder() - .registerTypeAdapter(LDUser.class, new LDUser.UserAdapterWithPrivateAttributeBehavior(config)) + .registerTypeAdapter(LDUser.class, new EventUserSerialization.UserAdapterWithPrivateAttributeBehavior(config)) .create(); } @@ -82,7 +87,7 @@ static String serialize(Object o) { * @param parsedJson the parsed JSON * @return the deserialized item */ - static VersionedData deserializeFromParsedJson(VersionedDataKind kind, JsonElement parsedJson) throws SerializationException { + static VersionedData deserializeFromParsedJson(DataKind kind, JsonElement parsedJson) throws SerializationException { VersionedData item; try { if (kind == FEATURES) { diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/sdk/server/LDClient.java similarity index 53% rename from src/main/java/com/launchdarkly/client/LDClient.java rename to src/main/java/com/launchdarkly/sdk/server/LDClient.java index cfe55e61d..dc1b53b18 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClient.java @@ -1,8 +1,22 @@ -package com.launchdarkly.client; - -import com.google.gson.JsonElement; -import com.launchdarkly.client.Components.NullUpdateProcessor; -import com.launchdarkly.client.value.LDValue; +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.integrations.EventProcessorBuilder; +import com.launchdarkly.sdk.server.interfaces.DataSource; +import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; +import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; +import com.launchdarkly.sdk.server.interfaces.Event; +import com.launchdarkly.sdk.server.interfaces.EventProcessor; +import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; +import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; import org.apache.commons.codec.binary.Hex; import org.slf4j.Logger; @@ -24,7 +38,9 @@ import javax.crypto.spec.SecretKeySpec; import static com.google.common.base.Preconditions.checkNotNull; -import static com.launchdarkly.client.VersionedDataKind.FEATURES; +import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; /** * A client for the LaunchDarkly API. Client instances are thread-safe. Applications should instantiate @@ -37,12 +53,14 @@ public final class LDClient implements LDClientInterface { private static final String HMAC_ALGORITHM = "HmacSHA256"; static final String CLIENT_VERSION = getClientVersion(); - private final LDConfig config; private final String sdkKey; + private final Evaluator evaluator; + private final FlagChangeEventPublisher flagChangeEventPublisher; final EventProcessor eventProcessor; - final UpdateProcessor updateProcessor; - final FeatureStore featureStore; - final boolean shouldCloseFeatureStore; + final DataSource dataSource; + final DataStore dataStore; + private final DataStoreStatusProvider dataStoreStatusProvider; + private final boolean offline; /** * Creates a new client instance that connects to LaunchDarkly with the default configuration. In most @@ -54,6 +72,16 @@ public LDClient(String sdkKey) { this(sdkKey, LDConfig.DEFAULT); } + private static final DataModel.FeatureFlag getFlag(DataStore store, String key) { + ItemDescriptor item = store.get(FEATURES, key); + return item == null ? null : (DataModel.FeatureFlag)item.getItem(); + } + + private static final DataModel.Segment getSegment(DataStore store, String key) { + ItemDescriptor item = store.get(SEGMENTS, key); + return item == null ? null : (DataModel.Segment)item.getItem(); + } + /** * 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. @@ -62,8 +90,12 @@ public LDClient(String sdkKey) { * @param config a client configuration object */ public LDClient(String sdkKey, LDConfig config) { - this.config = new LDConfig(checkNotNull(config, "config must not be null")); + checkNotNull(config, "config must not be null"); this.sdkKey = checkNotNull(sdkKey, "sdkKey must not be null"); + this.offline = config.offline; + + final EventProcessorFactory epFactory = config.eventProcessorFactory == null ? + Components.sendEvents() : config.eventProcessorFactory; if (config.httpConfig.getProxy() != null) { if (config.httpConfig.getProxyAuthentication() != null) { @@ -73,65 +105,50 @@ public LDClient(String sdkKey, LDConfig config) { } } - FeatureStore store; - 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 - // doing so until the next major version eliminates that mechanism. We will always dispose - // of instances that we created ourselves from a factory. - this.shouldCloseFeatureStore = false; - } else { - FeatureStoreFactory factory = config.dataStoreFactory == null ? - Components.inMemoryDataStore() : config.dataStoreFactory; - store = factory.createFeatureStore(); - this.shouldCloseFeatureStore = true; - } - this.featureStore = new FeatureStoreClientWrapper(store); - - @SuppressWarnings("deprecation") // defaultEventProcessor() will be replaced by sendEvents() once the deprecated config properties are removed - EventProcessorFactory epFactory = this.config.eventProcessorFactory == null ? - Components.defaultEventProcessor() : this.config.eventProcessorFactory; - - 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)); - } + // Do not create diagnostic accumulator if config has specified is opted out, or if we're not using the + // standard event processor + final boolean useDiagnostics = !config.diagnosticOptOut && epFactory instanceof EventProcessorBuilder; + final ClientContextImpl context = new ClientContextImpl(sdkKey, config, + useDiagnostics ? new DiagnosticAccumulator(new DiagnosticId(sdkKey)) : null); - if (epFactory instanceof EventProcessorFactoryWithDiagnostics) { - EventProcessorFactoryWithDiagnostics epwdFactory = ((EventProcessorFactoryWithDiagnostics) epFactory); - this.eventProcessor = epwdFactory.createEventProcessor(sdkKey, this.config, diagnosticAccumulator); - } else { - this.eventProcessor = epFactory.createEventProcessor(sdkKey, this.config); - } + this.eventProcessor = epFactory.createEventProcessor(context); - @SuppressWarnings("deprecation") // defaultUpdateProcessor() will be replaced by streamingDataSource() once the deprecated config.stream is removed - UpdateProcessorFactory upFactory = config.dataSourceFactory == null ? - Components.defaultUpdateProcessor() : config.dataSourceFactory; + DataStoreFactory factory = config.dataStoreFactory == null ? + Components.inMemoryDataStore() : config.dataStoreFactory; + this.dataStore = factory.createDataStore(context); + this.dataStoreStatusProvider = new DataStoreStatusProviderImpl(this.dataStore); - 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); - } + this.evaluator = new Evaluator(new Evaluator.Getters() { + public DataModel.FeatureFlag getFlag(String key) { + return LDClient.getFlag(LDClient.this.dataStore, key); + } - Future startFuture = updateProcessor.start(); - if (this.config.startWaitMillis > 0L) { - if (!(updateProcessor instanceof NullUpdateProcessor)) { - logger.info("Waiting up to " + this.config.startWaitMillis + " milliseconds for LaunchDarkly client to start..."); + public DataModel.Segment getSegment(String key) { + return LDClient.getSegment(LDClient.this.dataStore, key); + } + }); + + this.flagChangeEventPublisher = new FlagChangeEventPublisher(); + + DataSourceFactory dataSourceFactory = config.dataSourceFactory == null ? + Components.streamingDataSource() : config.dataSourceFactory; + DataStoreUpdates dataStoreUpdates = new DataStoreUpdatesImpl(dataStore, flagChangeEventPublisher); + this.dataSource = dataSourceFactory.createDataSource(context, dataStoreUpdates); + + Future startFuture = dataSource.start(); + if (!config.startWait.isZero() && !config.startWait.isNegative()) { + if (!(dataSource instanceof Components.NullDataSource)) { + logger.info("Waiting up to " + config.startWait.toMillis() + " milliseconds for LaunchDarkly client to start..."); } try { - startFuture.get(this.config.startWaitMillis, TimeUnit.MILLISECONDS); + startFuture.get(config.startWait.toMillis(), TimeUnit.MILLISECONDS); } 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.toString()); logger.debug(e.toString(), e); } - if (!updateProcessor.initialized()) { + if (!dataSource.isInitialized()) { logger.warn("LaunchDarkly client was not successfully initialized"); } } @@ -139,7 +156,7 @@ public LDClient(String sdkKey, LDConfig config) { @Override public boolean initialized() { - return updateProcessor.initialized(); + return dataSource.isInitialized(); } @Override @@ -149,28 +166,16 @@ public void track(String eventName, LDUser user) { @Override public void trackData(String eventName, LDUser user, LDValue data) { - if (user == null || user.getKeyAsString() == null) { + 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, null)); } } - @SuppressWarnings("deprecation") - @Override - 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 trackMetric(String eventName, LDUser user, LDValue data, double metricValue) { - if (user == null || user.getKeyAsString() == null) { + 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)); @@ -179,7 +184,7 @@ public void trackMetric(String eventName, LDUser user, LDValue data, double metr @Override public void identify(LDUser user) { - if (user == null || user.getKeyAsString() == null) { + if (user == null || user.getKey() == null) { logger.warn("Identify called with null user or null user key!"); } else { eventProcessor.sendEvent(EventFactory.DEFAULT.newIdentifyEvent(user)); @@ -188,16 +193,6 @@ public void identify(LDUser user) { private void sendFlagRequestEvent(Event.FeatureRequest event) { eventProcessor.sendEvent(event); - NewRelicReflector.annotateTransaction(event.key, String.valueOf(event.value)); - } - - @Override - public Map allFlags(LDUser user) { - FeatureFlagsState state = allFlagsState(user); - if (!state.isValid()) { - return null; - } - return state.toValuesMap(); } @Override @@ -209,33 +204,36 @@ public FeatureFlagsState allFlagsState(LDUser user, FlagsStateOption... options) } if (!initialized()) { - if (featureStore.initialized()) { - logger.warn("allFlagsState() was called before client initialized; using last known values from feature store"); + if (dataStore.isInitialized()) { + logger.warn("allFlagsState() was called before client initialized; using last known values from data store"); } else { - logger.warn("allFlagsState() was called before client initialized; feature store unavailable, returning no data"); + logger.warn("allFlagsState() was called before client initialized; data store unavailable, returning no data"); return builder.valid(false).build(); } } - if (user == null || user.getKeyAsString() == null) { + if (user == null || user.getKey() == null) { logger.warn("allFlagsState() was called with null user or null user key! returning no data"); 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(); + KeyedItems flags = dataStore.getAll(FEATURES); + for (Map.Entry entry : flags.getItems()) { + if (entry.getValue().getItem() == null) { + continue; // deleted flag placeholder + } + DataModel.FeatureFlag flag = (DataModel.FeatureFlag)entry.getValue().getItem(); if (clientSideOnly && !flag.isClientSide()) { continue; } try { - EvaluationDetail result = flag.evaluate(user, featureStore, EventFactory.DEFAULT).getDetails(); + Evaluator.EvalResult result = evaluator.evaluate(flag, user, EventFactory.DEFAULT); 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.fromValue(LDValue.ofNull(), null, EvaluationReason.exception(e))); + builder.addFlag(flag, new Evaluator.EvalResult(LDValue.ofNull(), NO_VARIATION, EvaluationReason.exception(e))); } } return builder.build(); @@ -261,76 +259,62 @@ public String stringVariation(String featureKey, LDUser user, String defaultValu 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, 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); + return evaluate(featureKey, user, LDValue.normalize(defaultValue), false); } @Override public EvaluationDetail boolVariationDetail(String featureKey, LDUser user, boolean defaultValue) { - EvaluationDetail details = evaluateDetail(featureKey, user, LDValue.of(defaultValue), true, + Evaluator.EvalResult result = evaluateInternal(featureKey, user, LDValue.of(defaultValue), true, EventFactory.DEFAULT_WITH_REASONS); - return EvaluationDetail.fromValue(details.getValue().booleanValue(), - details.getVariationIndex(), details.getReason()); + return EvaluationDetail.fromValue(result.getValue().booleanValue(), + result.getVariationIndex(), result.getReason()); } @Override public EvaluationDetail intVariationDetail(String featureKey, LDUser user, int defaultValue) { - EvaluationDetail details = evaluateDetail(featureKey, user, LDValue.of(defaultValue), true, + Evaluator.EvalResult result = evaluateInternal(featureKey, user, LDValue.of(defaultValue), true, EventFactory.DEFAULT_WITH_REASONS); - return EvaluationDetail.fromValue(details.getValue().intValue(), - details.getVariationIndex(), details.getReason()); + return EvaluationDetail.fromValue(result.getValue().intValue(), + result.getVariationIndex(), result.getReason()); } @Override public EvaluationDetail doubleVariationDetail(String featureKey, LDUser user, double defaultValue) { - EvaluationDetail details = evaluateDetail(featureKey, user, LDValue.of(defaultValue), true, + Evaluator.EvalResult result = evaluateInternal(featureKey, user, LDValue.of(defaultValue), true, EventFactory.DEFAULT_WITH_REASONS); - return EvaluationDetail.fromValue(details.getValue().doubleValue(), - details.getVariationIndex(), details.getReason()); + return EvaluationDetail.fromValue(result.getValue().doubleValue(), + result.getVariationIndex(), result.getReason()); } @Override public EvaluationDetail stringVariationDetail(String featureKey, LDUser user, String defaultValue) { - EvaluationDetail details = evaluateDetail(featureKey, user, LDValue.of(defaultValue), true, - EventFactory.DEFAULT_WITH_REASONS); - return EvaluationDetail.fromValue(details.getValue().stringValue(), - details.getVariationIndex(), details.getReason()); - } - - @SuppressWarnings("deprecation") - @Override - public EvaluationDetail jsonVariationDetail(String featureKey, LDUser user, JsonElement defaultValue) { - EvaluationDetail details = evaluateDetail(featureKey, user, LDValue.unsafeFromJsonElement(defaultValue), false, + Evaluator.EvalResult result = evaluateInternal(featureKey, user, LDValue.of(defaultValue), true, EventFactory.DEFAULT_WITH_REASONS); - return EvaluationDetail.fromValue(details.getValue().asUnsafeJsonElement(), - details.getVariationIndex(), details.getReason()); + return EvaluationDetail.fromValue(result.getValue().stringValue(), + result.getVariationIndex(), result.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); + Evaluator.EvalResult result = evaluateInternal(featureKey, user, LDValue.normalize(defaultValue), false, EventFactory.DEFAULT_WITH_REASONS); + return EvaluationDetail.fromValue(result.getValue(), result.getVariationIndex(), result.getReason()); } @Override public boolean isFlagKnown(String featureKey) { if (!initialized()) { - if (featureStore.initialized()) { - logger.warn("isFlagKnown called before client initialized for feature flag \"{}\"; using last known values from feature store", featureKey); + if (dataStore.isInitialized()) { + logger.warn("isFlagKnown called before client initialized for feature flag \"{}\"; using last known values from data store", featureKey); } else { - logger.warn("isFlagKnown called before client initialized for feature flag \"{}\"; feature store unavailable, returning false", featureKey); + logger.warn("isFlagKnown called before client initialized for feature flag \"{}\"; data store unavailable, returning false", featureKey); return false; } } try { - if (featureStore.get(FEATURES, featureKey) != null) { + if (getFlag(dataStore, featureKey) != null) { return true; } } catch (Exception e) { @@ -342,61 +326,61 @@ public boolean isFlagKnown(String featureKey) { } private LDValue evaluate(String featureKey, LDUser user, LDValue defaultValue, boolean checkType) { - return evaluateDetail(featureKey, user, defaultValue, checkType, EventFactory.DEFAULT).getValue(); + return evaluateInternal(featureKey, user, defaultValue, checkType, EventFactory.DEFAULT).getValue(); } - 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 details; + private Evaluator.EvalResult errorResult(EvaluationReason.ErrorKind errorKind, final LDValue defaultValue) { + return new Evaluator.EvalResult(defaultValue, NO_VARIATION, EvaluationReason.error(errorKind)); } - private EvaluationDetail evaluateInternal(String featureKey, LDUser user, LDValue defaultValue, EventFactory eventFactory) { + private Evaluator.EvalResult evaluateInternal(String featureKey, LDUser user, LDValue defaultValue, boolean checkType, + 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); + if (dataStore.isInitialized()) { + logger.warn("Evaluation called before client initialized for feature flag \"{}\"; using last known values from data store", featureKey); } else { - logger.warn("Evaluation called before client initialized for feature flag \"{}\"; feature store unavailable, returning default value", featureKey); + logger.warn("Evaluation called before client initialized for feature flag \"{}\"; data store unavailable, returning default value", featureKey); sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue, EvaluationReason.ErrorKind.CLIENT_NOT_READY)); - return EvaluationDetail.error(EvaluationReason.ErrorKind.CLIENT_NOT_READY, defaultValue); + return errorResult(EvaluationReason.ErrorKind.CLIENT_NOT_READY, defaultValue); } } - FeatureFlag featureFlag = null; + DataModel.FeatureFlag featureFlag = null; try { - featureFlag = featureStore.get(FEATURES, featureKey); + featureFlag = getFlag(dataStore, featureKey); if (featureFlag == null) { logger.info("Unknown feature flag \"{}\"; returning default value", featureKey); sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue, EvaluationReason.ErrorKind.FLAG_NOT_FOUND)); - return EvaluationDetail.error(EvaluationReason.ErrorKind.FLAG_NOT_FOUND, defaultValue); + return errorResult(EvaluationReason.ErrorKind.FLAG_NOT_FOUND, defaultValue); } - if (user == null || user.getKeyAsString() == null) { + if (user == null || user.getKey() == 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)); - return EvaluationDetail.error(EvaluationReason.ErrorKind.USER_NOT_SPECIFIED, defaultValue); + return errorResult(EvaluationReason.ErrorKind.USER_NOT_SPECIFIED, defaultValue); } - if (user.getKeyAsString().isEmpty()) { + if (user.getKey().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); + Evaluator.EvalResult evalResult = evaluator.evaluate(featureFlag, user, eventFactory); for (Event.FeatureRequest event : evalResult.getPrerequisiteEvents()) { eventProcessor.sendEvent(event); } - EvaluationDetail details = evalResult.getDetails(); - if (details.isDefaultValue()) { - details = EvaluationDetail.fromValue(defaultValue, null, details.getReason()); + if (evalResult.isDefault()) { + evalResult.setValue(defaultValue); + } else { + LDValue value = evalResult.getValue(); // guaranteed not to be an actual Java null, but can be LDValue.ofNull() + if (checkType && !value.isNull() && !defaultValue.isNull() && defaultValue.getType() != value.getType()) { + logger.error("Feature flag evaluation expected result as {}, but got {}", defaultValue.getType(), value.getType()); + sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue, + EvaluationReason.ErrorKind.WRONG_TYPE)); + return errorResult(EvaluationReason.ErrorKind.WRONG_TYPE, defaultValue); + } } - sendFlagRequestEvent(eventFactory.newFeatureRequestEvent(featureFlag, user, details, defaultValue)); - return details; + sendFlagRequestEvent(eventFactory.newFeatureRequestEvent(featureFlag, user, evalResult, defaultValue)); + return evalResult; } catch (Exception e) { logger.error("Encountered exception while evaluating feature flag \"{}\": {}", featureKey, e.toString()); logger.debug(e.toString(), e); @@ -407,18 +391,32 @@ private EvaluationDetail evaluateInternal(String featureKey, LDUser use sendFlagRequestEvent(eventFactory.newDefaultFeatureRequestEvent(featureFlag, user, defaultValue, EvaluationReason.ErrorKind.EXCEPTION)); } - return EvaluationDetail.fromValue(defaultValue, null, EvaluationReason.exception(e)); + return new Evaluator.EvalResult(defaultValue, NO_VARIATION, EvaluationReason.exception(e)); } } + @Override + public void registerFlagChangeListener(FlagChangeListener listener) { + flagChangeEventPublisher.register(listener); + } + + @Override + public void unregisterFlagChangeListener(FlagChangeListener listener) { + flagChangeEventPublisher.unregister(listener); + } + + @Override + public DataStoreStatusProvider getDataStoreStatusProvider() { + return dataStoreStatusProvider; + } + @Override public void close() throws IOException { logger.info("Closing LaunchDarkly Client"); - if (shouldCloseFeatureStore) { // see comment in constructor about this variable - this.featureStore.close(); - } + this.dataStore.close(); this.eventProcessor.close(); - this.updateProcessor.close(); + this.dataSource.close(); + this.flagChangeEventPublisher.close(); } @Override @@ -428,18 +426,18 @@ public void flush() { @Override public boolean isOffline() { - return config.offline; + return offline; } @Override public String secureModeHash(LDUser user) { - if (user == null || user.getKeyAsString() == null) { + if (user == null || user.getKey() == null) { return null; } try { Mac mac = Mac.getInstance(HMAC_ALGORITHM); mac.init(new SecretKeySpec(sdkKey.getBytes(), HMAC_ALGORITHM)); - return Hex.encodeHexString(mac.doFinal(user.getKeyAsString().getBytes("UTF8"))); + return Hex.encodeHexString(mac.doFinal(user.getKey().getBytes("UTF8"))); } catch (InvalidKeyException | UnsupportedEncodingException | NoSuchAlgorithmException e) { logger.error("Could not generate secure mode hash: {}", e.toString()); logger.debug(e.toString(), e); diff --git a/src/main/java/com/launchdarkly/client/LDClientInterface.java b/src/main/java/com/launchdarkly/sdk/server/LDClientInterface.java similarity index 70% rename from src/main/java/com/launchdarkly/client/LDClientInterface.java rename to src/main/java/com/launchdarkly/sdk/server/LDClientInterface.java index 80db0168b..20220e432 100644 --- a/src/main/java/com/launchdarkly/client/LDClientInterface.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClientInterface.java @@ -1,11 +1,13 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; -import com.google.gson.JsonElement; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; +import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; import java.io.Closeable; import java.io.IOException; -import java.util.Map; /** * This interface defines the public methods of {@link LDClient}. @@ -27,17 +29,6 @@ public interface LDClientInterface extends Closeable { */ 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 - * @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. * @@ -48,26 +39,6 @@ public interface LDClientInterface extends Closeable { */ void trackData(String eventName, LDUser user, LDValue data); - /** - * 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. - * - * @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)}. - */ - @Deprecated - 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. *

    @@ -94,23 +65,6 @@ public interface LDClientInterface extends Closeable { */ void identify(LDUser user); - /** - * Returns a map from feature flag keys to {@code JsonElement} feature flag values for a given user. - * If the result of a flag's evaluation would have returned the default variation, it will have a null entry - * in the map. If the client is offline, has not been initialized, or a null user or user with null/empty user key a {@code null} map will be returned. - * This method will 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. - * - * @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 also metadata that can be used on the front end. This method does not send analytics events @@ -168,19 +122,6 @@ public interface LDClientInterface extends Closeable { */ String stringVariation(String featureKey, LDUser user, String defaultValue); - /** - * Calculates the {@link JsonElement} 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 - * @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. * @@ -245,21 +186,6 @@ public interface LDClientInterface extends Closeable { */ 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 - * 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 - * @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. * @@ -299,6 +225,58 @@ public interface LDClientInterface extends Closeable { */ boolean isOffline(); + /** + * Registers a listener to be notified of feature flag changes. + *

    + * The listener will be notified whenever the SDK receives any change to any feature flag's configuration, + * or to a user segment that is referenced by a feature flag. If the updated flag is used as a prerequisite + * for other flags, the SDK assumes that those flags may now behave differently and sends events for them + * as well. + *

    + * Note that this does not necessarily mean the flag's value has changed for any particular user, only that + * some part of the flag configuration was changed so that it may return a different value than it + * previously returned for some user. + *

    + * Change events only work if the SDK is actually connecting to LaunchDarkly (or using the file data source). + * If the SDK is only reading flags from a database ({@link Components#externalUpdatesOnly()}) then it cannot + * know when there is a change, because flags are read on an as-needed basis. + *

    + * The listener will be called from a worker thread. + *

    + * Calling this method for an already-registered listener has no effect. + * + * @param listener the event listener to register + * @see #unregisterFlagChangeListener(FlagChangeListener) + * @see FlagChangeListener + * @since 5.0.0 + */ + void registerFlagChangeListener(FlagChangeListener listener); + + /** + * Unregisters a listener so that it will no longer be notified of feature flag changes. + *

    + * Calling this method for a listener that was not previously registered has no effect. + * + * @param listener the event listener to unregister + * @see #registerFlagChangeListener(FlagChangeListener) + * @see FlagChangeListener + * @since 5.0.0 + */ + void unregisterFlagChangeListener(FlagChangeListener listener); + + /** + * Returns an interface for tracking the status of a persistent data store. + *

    + * The {@link DataStoreStatusProvider} has methods for checking whether the data store is (as far as the + * SDK knows) currently operational, tracking changes in this status, and getting cache statistics. These + * are only relevant for a persistent data store; if you are using an in-memory data store, then this + * method will return a stub object that provides no information. + * + * @return a {@link DataStoreStatusProvider} + * @since 5.0.0 + */ + DataStoreStatusProvider getDataStoreStatusProvider(); + /** * For more info: https://github.com/launchdarkly/js-client#secure-mode * @param user the user to be hashed along with the SDK key diff --git a/src/main/java/com/launchdarkly/sdk/server/LDConfig.java b/src/main/java/com/launchdarkly/sdk/server/LDConfig.java new file mode 100644 index 000000000..235ce9196 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/LDConfig.java @@ -0,0 +1,206 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; +import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; +import com.launchdarkly.sdk.server.interfaces.EventProcessor; +import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; +import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; +import com.launchdarkly.sdk.server.interfaces.HttpConfigurationFactory; + +import java.net.URI; +import java.time.Duration; + +/** + * This class exposes advanced configuration options for the {@link LDClient}. Instances of this class must be constructed with a {@link com.launchdarkly.sdk.server.LDConfig.Builder}. + */ +public final class LDConfig { + 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 Duration DEFAULT_START_WAIT = Duration.ofSeconds(5); + + protected static final LDConfig DEFAULT = new Builder().build(); + + final DataSourceFactory dataSourceFactory; + final DataStoreFactory dataStoreFactory; + final boolean diagnosticOptOut; + final EventProcessorFactory eventProcessorFactory; + final HttpConfiguration httpConfig; + final boolean offline; + final Duration startWait; + + protected LDConfig(Builder builder) { + this.dataStoreFactory = builder.dataStoreFactory; + this.eventProcessorFactory = builder.eventProcessorFactory; + this.dataSourceFactory = builder.dataSourceFactory; + this.diagnosticOptOut = builder.diagnosticOptOut; + this.httpConfig = builder.httpConfigFactory == null ? + Components.httpConfiguration().createHttpConfiguration() : + builder.httpConfigFactory.createHttpConfiguration(); + this.offline = builder.offline; + this.startWait = builder.startWait; + } + + LDConfig(LDConfig config) { + this.dataSourceFactory = config.dataSourceFactory; + this.dataStoreFactory = config.dataStoreFactory; + this.diagnosticOptOut = config.diagnosticOptOut; + this.eventProcessorFactory = config.eventProcessorFactory; + this.httpConfig = config.httpConfig; + this.offline = config.offline; + this.startWait = config.startWait; + } + + /** + * A builder that helps construct + * {@link com.launchdarkly.sdk.server.LDConfig} objects. Builder calls can be chained, enabling the + * following pattern: + *

    +   * LDConfig config = new LDConfig.Builder()
    +   *      .connectTimeoutMillis(3)
    +   *      .socketTimeoutMillis(3)
    +   *      .build()
    +   * 
    + */ + public static class Builder { + private DataSourceFactory dataSourceFactory = null; + private DataStoreFactory dataStoreFactory = null; + private boolean diagnosticOptOut = false; + private EventProcessorFactory eventProcessorFactory = null; + private HttpConfigurationFactory httpConfigFactory = null; + private boolean offline = false; + private Duration startWait = DEFAULT_START_WAIT; + + /** + * Creates a builder with all configuration parameters set to the default + */ + public Builder() { + } + + /** + * Sets the implementation of the component that receives feature flag data from LaunchDarkly, + * 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.sdk.server.integrations.FileData#dataSource()}. See those methods + * for details on how to configure them. + * + * @param factory the factory object + * @return the builder + * @since 4.12.0 + */ + public Builder dataSource(DataSourceFactory factory) { + this.dataSourceFactory = factory; + 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()}; for database integrations, use + * {@link Components#persistentDataStore(com.launchdarkly.sdk.server.interfaces.PersistentDataStoreFactory)}. + * + * @param factory the factory object + * @return the builder + * @since 4.12.0 + */ + public Builder dataStore(DataStoreFactory factory) { + this.dataStoreFactory = factory; + return this; + } + + /** + * Set to true to opt out of sending diagnostics data. + *

    + * 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.sdk.server.integrations.EventProcessorBuilder#diagnosticRecordingInterval(Duration) + * + * @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; + return this; + } + + /** + * 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 events(EventProcessorFactory factory) { + this.eventProcessorFactory = factory; + return this; + } + + /** + * 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; + } + + /** + * 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 events(Components.noEvents())}. It overrides any other values you may have set for + * {@link #dataSource(DataSourceFactory)} or {@link #events(EventProcessorFactory)}. + * + * @param offline when set to true no calls to LaunchDarkly will be made + * @return the builder + */ + public Builder offline(boolean offline) { + this.offline = offline; + return this; + } + + /** + * Set how long the constructor will block awaiting a successful connection to LaunchDarkly. + * Setting this to a zero or negative duration will not block and cause the constructor to return immediately. + * Default value: 5000 + * + * @param startWait maximum time to wait; null to use the default + * @return the builder + */ + public Builder startWait(Duration startWait) { + this.startWait = startWait == null ? DEFAULT_START_WAIT : startWait; + return this; + } + + /** + * Builds the configured {@link com.launchdarkly.sdk.server.LDConfig} object. + * + * @return the {@link com.launchdarkly.sdk.server.LDConfig} configured by this builder + */ + public LDConfig build() { + return new LDConfig(this); + } + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java b/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java new file mode 100644 index 000000000..6bb689245 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java @@ -0,0 +1,88 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.util.concurrent.ThreadFactoryBuilder; +import com.launchdarkly.sdk.server.interfaces.DataSource; +import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; +import com.launchdarkly.sdk.server.interfaces.SerializationException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import static com.launchdarkly.sdk.server.Util.httpErrorMessage; +import static com.launchdarkly.sdk.server.Util.isHttpErrorRecoverable; + +final class PollingProcessor implements DataSource { + private static final Logger logger = LoggerFactory.getLogger(PollingProcessor.class); + + @VisibleForTesting final FeatureRequestor requestor; + private final DataStoreUpdates dataStoreUpdates; + @VisibleForTesting final Duration pollInterval; + private AtomicBoolean initialized = new AtomicBoolean(false); + private ScheduledExecutorService scheduler = null; + + PollingProcessor(FeatureRequestor requestor, DataStoreUpdates dataStoreUpdates, Duration pollInterval) { + this.requestor = requestor; // note that HTTP configuration is applied to the requestor when it is created + this.dataStoreUpdates = dataStoreUpdates; + this.pollInterval = pollInterval; + } + + @Override + public boolean isInitialized() { + return initialized.get(); + } + + @Override + public void close() throws IOException { + logger.info("Closing LaunchDarkly PollingProcessor"); + if (scheduler != null) { + scheduler.shutdown(); + } + requestor.close(); + } + + @Override + public Future start() { + logger.info("Starting LaunchDarkly polling client with interval: " + + pollInterval.toMillis() + " milliseconds"); + final CompletableFuture initFuture = new CompletableFuture<>(); + ThreadFactory threadFactory = new ThreadFactoryBuilder() + .setNameFormat("LaunchDarkly-PollingProcessor-%d") + .build(); + scheduler = Executors.newScheduledThreadPool(1, threadFactory); + + scheduler.scheduleAtFixedRate(() -> { + try { + FeatureRequestor.AllData allData = requestor.getAllData(); + dataStoreUpdates.init(allData.toFullDataSet()); + if (!initialized.getAndSet(true)) { + logger.info("Initialized LaunchDarkly client."); + initFuture.complete(null); + } + } catch (HttpErrorException e) { + logger.error(httpErrorMessage(e.getStatus(), "polling request", "will retry")); + if (!isHttpErrorRecoverable(e.getStatus())) { + scheduler.shutdown(); + initFuture.complete(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.toString()); + logger.debug(e.toString(), e); + } catch (SerializationException e) { + logger.error("Polling request received malformed data: {}", e.toString()); + } + }, 0L, pollInterval.toMillis(), TimeUnit.MILLISECONDS); + + return initFuture; + } +} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/SemanticVersion.java b/src/main/java/com/launchdarkly/sdk/server/SemanticVersion.java similarity index 99% rename from src/main/java/com/launchdarkly/client/SemanticVersion.java rename to src/main/java/com/launchdarkly/sdk/server/SemanticVersion.java index 7e0ef034c..cb5a152cd 100644 --- a/src/main/java/com/launchdarkly/client/SemanticVersion.java +++ b/src/main/java/com/launchdarkly/sdk/server/SemanticVersion.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import java.util.regex.Matcher; import java.util.regex.Pattern; diff --git a/src/main/java/com/launchdarkly/client/SimpleLRUCache.java b/src/main/java/com/launchdarkly/sdk/server/SimpleLRUCache.java similarity index 94% rename from src/main/java/com/launchdarkly/client/SimpleLRUCache.java rename to src/main/java/com/launchdarkly/sdk/server/SimpleLRUCache.java index a048e9a06..cc79f6cf1 100644 --- a/src/main/java/com/launchdarkly/client/SimpleLRUCache.java +++ b/src/main/java/com/launchdarkly/sdk/server/SimpleLRUCache.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import java.util.LinkedHashMap; import java.util.Map; diff --git a/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java new file mode 100644 index 000000000..83b376abf --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java @@ -0,0 +1,481 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.annotations.VisibleForTesting; +import com.google.gson.JsonElement; +import com.launchdarkly.eventsource.ConnectionErrorHandler; +import com.launchdarkly.eventsource.ConnectionErrorHandler.Action; +import com.launchdarkly.eventsource.EventHandler; +import com.launchdarkly.eventsource.EventSource; +import com.launchdarkly.eventsource.MessageEvent; +import com.launchdarkly.eventsource.UnsuccessfulResponseException; +import com.launchdarkly.sdk.server.DataModel.VersionedData; +import com.launchdarkly.sdk.server.interfaces.DataSource; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; +import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; +import com.launchdarkly.sdk.server.interfaces.SerializationException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.URI; +import java.time.Duration; +import java.util.AbstractMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicBoolean; + +import static com.launchdarkly.sdk.server.DataModel.ALL_DATA_KINDS; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; +import static com.launchdarkly.sdk.server.Util.configureHttpClientBuilder; +import static com.launchdarkly.sdk.server.Util.getHeadersBuilderFor; +import static com.launchdarkly.sdk.server.Util.httpErrorMessage; +import static com.launchdarkly.sdk.server.Util.isHttpErrorRecoverable; + +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. + * 2a. If the data store supports status notifications (which all persistent stores normally do), then we can + * assume it has entered a failed state and will notify us once it is working again. If and when it recovers, then + * it will tell us whether we need to restart the stream (to ensure that we haven't missed any updates), or + * whether it has already persisted all of the stream updates we received during the outage. + * 2b. If the data store doesn't support status notifications (which is normally only true of the in-memory store) + * then we don't know the significance of the error, but we must assume that updates have been lost, so we'll + * restart the stream. + * 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 DataSource { + private static final String PUT = "put"; + private static final String PATCH = "patch"; + private static final String DELETE = "delete"; + private static final String INDIRECT_PUT = "indirect/put"; + private static final String INDIRECT_PATCH = "indirect/patch"; + private static final Logger logger = LoggerFactory.getLogger(StreamProcessor.class); + private static final Duration DEAD_CONNECTION_INTERVAL = Duration.ofSeconds(300); + + private final DataStoreUpdates dataStoreUpdates; + private final HttpConfiguration httpConfig; + private final Headers headers; + @VisibleForTesting final URI streamUri; + @VisibleForTesting final Duration initialReconnectDelay; + @VisibleForTesting final FeatureRequestor requestor; + private final DiagnosticAccumulator diagnosticAccumulator; + private final EventSourceCreator eventSourceCreator; + private final DataStoreStatusProvider.StatusListener statusListener; + 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 + + static final class EventSourceParams { + final EventHandler handler; + final URI streamUri; + final Duration initialReconnectDelay; + final ConnectionErrorHandler errorHandler; + final Headers headers; + final HttpConfiguration httpConfig; + + EventSourceParams(EventHandler handler, URI streamUri, Duration initialReconnectDelay, + ConnectionErrorHandler errorHandler, Headers headers, HttpConfiguration httpConfig) { + this.handler = handler; + this.streamUri = streamUri; + this.initialReconnectDelay = initialReconnectDelay; + this.errorHandler = errorHandler; + this.headers = headers; + this.httpConfig = httpConfig; + } + } + + @FunctionalInterface + static interface EventSourceCreator { + EventSource createEventSource(EventSourceParams params); + } + + StreamProcessor( + String sdkKey, + HttpConfiguration httpConfig, + FeatureRequestor requestor, + DataStoreUpdates dataStoreUpdates, + EventSourceCreator eventSourceCreator, + DiagnosticAccumulator diagnosticAccumulator, + URI streamUri, + Duration initialReconnectDelay + ) { + this.dataStoreUpdates = dataStoreUpdates; + this.httpConfig = httpConfig; + this.requestor = requestor; + this.diagnosticAccumulator = diagnosticAccumulator; + this.eventSourceCreator = eventSourceCreator != null ? eventSourceCreator : StreamProcessor::defaultEventSourceCreator; + this.streamUri = streamUri; + this.initialReconnectDelay = initialReconnectDelay; + + this.headers = getHeadersBuilderFor(sdkKey, httpConfig) + .add("Accept", "text/event-stream") + .build(); + + DataStoreStatusProvider.StatusListener statusListener = this::onStoreStatusChanged; + if (dataStoreUpdates.getStatusProvider().addStatusListener(statusListener)) { + this.statusListener = statusListener; + } else { + this.statusListener = null; + } + } + + private void onStoreStatusChanged(DataStoreStatusProvider.Status newStatus) { + if (newStatus.isAvailable()) { + if (newStatus.isRefreshNeeded()) { + // The store has just transitioned from unavailable to available, and we can't guarantee that + // all of the latest data got cached, so let's restart the stream to refresh all the data. + EventSource stream = es; + if (stream != null) { + logger.warn("Restarting stream to refresh data after data store outage"); + stream.restart(); + } + } + } + } + + private ConnectionErrorHandler createDefaultConnectionErrorHandler() { + return (Throwable t) -> { + recordStreamInit(true); + if (t instanceof UnsuccessfulResponseException) { + int status = ((UnsuccessfulResponseException)t).getCode(); + logger.error(httpErrorMessage(status, "streaming connection", "will retry")); + if (!isHttpErrorRecoverable(status)) { + return Action.SHUTDOWN; + } + esStarted = System.currentTimeMillis(); + return Action.PROCEED; + } + return Action.PROCEED; + }; + } + + @Override + public Future start() { + final CompletableFuture initFuture = new CompletableFuture<>(); + + ConnectionErrorHandler wrappedConnectionErrorHandler = (Throwable t) -> { + Action result = connectionErrorHandler.onConnectionError(t); + if (result == Action.SHUTDOWN) { + initFuture.complete(null); // if client is initializing, make it stop waiting; has no effect if already inited + } + return result; + }; + + EventHandler handler = new StreamEventHandler(initFuture); + + es = eventSourceCreator.createEventSource(new EventSourceParams(handler, + URI.create(streamUri.toASCIIString() + "/all"), + initialReconnectDelay, + wrappedConnectionErrorHandler, + headers, + httpConfig)); + 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"); + if (statusListener != null) { + dataStoreUpdates.getStatusProvider().removeStatusListener(statusListener); + } + if (es != null) { + es.close(); + } + requestor.close(); + } + + @Override + public boolean isInitialized() { + return initialized.get(); + } + + private class StreamEventHandler implements EventHandler { + private final CompletableFuture initFuture; + + StreamEventHandler(CompletableFuture initFuture) { + this.initFuture = initFuture; + } + + @Override + public void onOpen() throws Exception { + } + + @Override + public void onClosed() throws Exception { + } + + @Override + public void onMessage(String name, MessageEvent event) throws Exception { + try { + switch (name) { + case PUT: + handlePut(event.getData()); + break; + + case PATCH: + handlePatch(event.getData()); + break; + + case DELETE: + handleDelete(event.getData()); + break; + + case INDIRECT_PUT: + handleIndirectPut(); + break; + + case INDIRECT_PATCH: + handleIndirectPatch(event.getData()); + break; + + default: + logger.warn("Unexpected event found in stream: " + name); + break; + } + lastStoreUpdateFailed = false; + } 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) { + // See item 2 in error handling comments at top of class + if (!lastStoreUpdateFailed) { + logger.error("Unexpected data store failure when storing updates from stream: {}", + e.getCause().toString()); + logger.debug(e.getCause().toString(), e.getCause()); + } + if (statusListener == null) { + if (!lastStoreUpdateFailed) { + logger.warn("Restarting stream to ensure that we have the latest data"); + } + es.restart(); + } + lastStoreUpdateFailed = true; + } catch (Exception e) { + logger.warn("Unexpected error from stream processor: {}", e.toString()); + logger.debug(e.toString(), e); + } + } + + private void handlePut(String eventData) throws StreamInputException, StreamStoreException { + recordStreamInit(false); + esStarted = 0; + PutData putData = parseStreamJson(PutData.class, eventData); + FullDataSet allData = putData.data.toFullDataSet(); + try { + dataStoreUpdates.init(allData); + } catch (Exception e) { + throw new StreamStoreException(e); + } + if (!initialized.getAndSet(true)) { + initFuture.complete(null); + logger.info("Initialized LaunchDarkly client."); + } + } + + private void handlePatch(String eventData) throws StreamInputException, StreamStoreException { + PatchData data = parseStreamJson(PatchData.class, eventData); + Map.Entry kindAndKey = getKindAndKeyFromStreamApiPath(data.path); + if (kindAndKey == null) { + return; + } + DataKind kind = kindAndKey.getKey(); + String key = kindAndKey.getValue(); + VersionedData item = deserializeFromParsedJson(kind, data.data); + try { + dataStoreUpdates.upsert(kind, key, new ItemDescriptor(item.getVersion(), item)); + } catch (Exception e) { + throw new StreamStoreException(e); + } + } + + private void handleDelete(String eventData) throws StreamInputException, StreamStoreException { + DeleteData data = parseStreamJson(DeleteData.class, eventData); + Map.Entry kindAndKey = getKindAndKeyFromStreamApiPath(data.path); + if (kindAndKey == null) { + return; + } + DataKind kind = kindAndKey.getKey(); + String key = kindAndKey.getValue(); + ItemDescriptor placeholder = new ItemDescriptor(data.version, null); + try { + dataStoreUpdates.upsert(kind, key, placeholder); + } catch (Exception e) { + throw new StreamStoreException(e); + } + } + + private void handleIndirectPut() throws StreamInputException, StreamStoreException { + FeatureRequestor.AllData putData; + try { + putData = requestor.getAllData(); + } catch (Exception e) { + throw new StreamInputException(e); + } + FullDataSet allData = putData.toFullDataSet(); + try { + dataStoreUpdates.init(allData); + } catch (Exception e) { + throw new StreamStoreException(e); + } + if (!initialized.getAndSet(true)) { + initFuture.complete(null); + logger.info("Initialized LaunchDarkly client."); + } + } + + private void handleIndirectPatch(String path) throws StreamInputException, StreamStoreException { + Map.Entry kindAndKey = getKindAndKeyFromStreamApiPath(path); + DataKind kind = kindAndKey.getKey(); + String key = kindAndKey.getValue(); + VersionedData item; + try { + item = kind == SEGMENTS ? requestor.getSegment(key) : requestor.getFlag(key); + } catch (Exception e) { + throw new StreamInputException(e); + // In this case, StreamInputException doesn't necessarily represent malformed data from the service - it + // could be that the request to the polling endpoint failed in some other way. But either way, we must + // assume that we did not get valid data from LD so we have missed an update. + } + try { + dataStoreUpdates.upsert(kind, key, new ItemDescriptor(item.getVersion(), item)); + } catch (Exception e) { + throw new StreamStoreException(e); + } + } + + @Override + public void onComment(String comment) { + logger.debug("Received a heartbeat"); + } + + @Override + public void onError(Throwable throwable) { + logger.warn("Encountered EventSource error: {}", throwable.toString()); + logger.debug(throwable.toString(), throwable); + } + } + + private static EventSource defaultEventSourceCreator(EventSourceParams params) { + EventSource.Builder builder = new EventSource.Builder(params.handler, params.streamUri) + .clientBuilderActions(new EventSource.Builder.ClientConfigurer() { + public void configure(OkHttpClient.Builder builder) { + configureHttpClientBuilder(params.httpConfig, builder); + } + }) + .connectionErrorHandler(params.errorHandler) + .headers(params.headers) + .reconnectTime(params.initialReconnectDelay) + .readTimeout(DEAD_CONNECTION_INTERVAL); + // Note that this is not the same read timeout that can be set in LDConfig. We default to a smaller one + // there because we don't expect long delays within any *non*-streaming response that the LD client gets. + // A read timeout on the stream will result in the connection being cycled, so we set this to be slightly + // more than the expected interval between heartbeat signals. + + return builder.build(); + } + + private static Map.Entry getKindAndKeyFromStreamApiPath(String path) throws StreamInputException { + if (path == null) { + throw new StreamInputException("missing item path"); + } + for (DataKind kind: ALL_DATA_KINDS) { + String prefix = (kind == SEGMENTS) ? "/segments/" : "/flags/"; + if (path.startsWith(prefix)) { + return new AbstractMap.SimpleEntry(kind, 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(DataKind 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; + + @SuppressWarnings("unused") // used by Gson + public PutData() { } + } + + private static final class PatchData { + String path; + JsonElement data; + + @SuppressWarnings("unused") // used by Gson + public PatchData() { } + } + + private static final class DeleteData { + String path; + int version; + + @SuppressWarnings("unused") // used by Gson + public DeleteData() { } + } +} diff --git a/src/main/java/com/launchdarkly/client/Util.java b/src/main/java/com/launchdarkly/sdk/server/Util.java similarity index 73% rename from src/main/java/com/launchdarkly/client/Util.java rename to src/main/java/com/launchdarkly/sdk/server/Util.java index 24ada3497..b77811c61 100644 --- a/src/main/java/com/launchdarkly/client/Util.java +++ b/src/main/java/com/launchdarkly/sdk/server/Util.java @@ -1,13 +1,7 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; -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 com.launchdarkly.sdk.server.interfaces.HttpAuthentication; +import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; import java.io.IOException; import java.util.concurrent.TimeUnit; @@ -23,25 +17,6 @@ import okhttp3.Route; 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 number or a string that may contain a valid timestamp. - * @return null if input is not a valid format. - */ - static DateTime jsonPrimitiveToDateTime(LDValue maybeDate) { - if (maybeDate.isNumber()) { - return new DateTime((long)maybeDate.doubleValue()); - } else if (maybeDate.isString()) { - try { - return new DateTime(maybeDate.stringValue(), DateTimeZone.UTC); - } catch (Throwable t) { - return null; - } - } else { - return null; - } - } - static Headers.Builder getHeadersBuilderFor(String sdkKey, HttpConfiguration config) { Headers.Builder builder = new Headers.Builder() .add("Authorization", sdkKey) @@ -56,9 +31,9 @@ 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.getConnectTimeoutMillis(), TimeUnit.MILLISECONDS) - .readTimeout(config.getSocketTimeoutMillis(), TimeUnit.MILLISECONDS) - .writeTimeout(config.getSocketTimeoutMillis(), TimeUnit.MILLISECONDS) + .connectTimeout(config.getConnectTimeout()) + .readTimeout(config.getSocketTimeout()) + .writeTimeout(config.getSocketTimeout()) .retryOnConnectionFailure(false); // we will implement our own retry logic if (config.getSslSocketFactory() != null) { @@ -85,11 +60,7 @@ public Request authenticate(Route route, Response response) throws IOException { 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()); - } - }); + c -> new HttpAuthentication.Challenge(c.scheme(), c.realm())); String credential = strategy.provideAuthorization(challenges); return response.request().newBuilder() .header(responseHeaderName, credential) diff --git a/src/main/java/com/launchdarkly/client/integrations/EventProcessorBuilder.java b/src/main/java/com/launchdarkly/sdk/server/integrations/EventProcessorBuilder.java similarity index 57% rename from src/main/java/com/launchdarkly/client/integrations/EventProcessorBuilder.java rename to src/main/java/com/launchdarkly/sdk/server/integrations/EventProcessorBuilder.java index fb229fc61..80274006e 100644 --- a/src/main/java/com/launchdarkly/client/integrations/EventProcessorBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/EventProcessorBuilder.java @@ -1,9 +1,11 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; -import com.launchdarkly.client.Components; -import com.launchdarkly.client.EventProcessorFactory; +import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; import java.net.URI; +import java.time.Duration; import java.util.Arrays; import java.util.HashSet; import java.util.Set; @@ -13,16 +15,13 @@ *

    * 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)}: + * properties with the methods of this class, and pass it to {@link com.launchdarkly.sdk.server.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 @@ -34,14 +33,14 @@ public abstract class EventProcessorBuilder implements EventProcessorFactory { public static final int DEFAULT_CAPACITY = 10000; /** - * The default value for {@link #diagnosticRecordingIntervalSeconds(int)}. + * The default value for {@link #diagnosticRecordingInterval(Duration)}: 15 minutes. */ - public static final int DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL_SECONDS = 60 * 15; + public static final Duration DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL = Duration.ofMinutes(15); /** - * The default value for {@link #flushIntervalSeconds(int)}. + * The default value for {@link #flushInterval(Duration)}: 5 seconds. */ - public static final int DEFAULT_FLUSH_INTERVAL_SECONDS = 5; + public static final Duration DEFAULT_FLUSH_INTERVAL = Duration.ofSeconds(5); /** * The default value for {@link #userKeysCapacity(int)}. @@ -49,36 +48,36 @@ public abstract class EventProcessorBuilder implements EventProcessorFactory { public static final int DEFAULT_USER_KEYS_CAPACITY = 1000; /** - * The default value for {@link #userKeysFlushIntervalSeconds(int)}. + * The default value for {@link #userKeysFlushInterval(Duration)}: 5 minutes. */ - public static final int DEFAULT_USER_KEYS_FLUSH_INTERVAL_SECONDS = 60 * 5; + public static final Duration DEFAULT_USER_KEYS_FLUSH_INTERVAL = Duration.ofMinutes(5); /** - * The minimum value for {@link #diagnosticRecordingIntervalSeconds(int)}. + * The minimum value for {@link #diagnosticRecordingInterval(Duration)}: 60 seconds. */ - public static final int MIN_DIAGNOSTIC_RECORDING_INTERVAL_SECONDS = 60; + public static final Duration MIN_DIAGNOSTIC_RECORDING_INTERVAL = Duration.ofSeconds(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 Duration diagnosticRecordingInterval = DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL; + protected Duration flushInterval = DEFAULT_FLUSH_INTERVAL; protected boolean inlineUsersInEvents = false; - protected Set privateAttrNames; + protected Set privateAttributes; protected int userKeysCapacity = DEFAULT_USER_KEYS_CAPACITY; - protected int userKeysFlushIntervalSeconds = DEFAULT_USER_KEYS_FLUSH_INTERVAL_SECONDS; + protected Duration userKeysFlushInterval = DEFAULT_USER_KEYS_FLUSH_INTERVAL; /** * 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}. + * {@link com.launchdarkly.sdk.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 + * @see com.launchdarkly.sdk.LDUser.Builder */ public EventProcessorBuilder allAttributesPrivate(boolean allAttributesPrivate) { this.allAttributesPrivate = allAttributesPrivate; @@ -107,7 +106,7 @@ public EventProcessorBuilder baseURI(URI baseURI) { * 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 + * the buffer is flushed (see {@link #flushInterval(Duration)}, 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}. @@ -123,18 +122,22 @@ public EventProcessorBuilder capacity(int capacity) { /** * 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}. + * The default value is {@link #DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL}; the minimum value is + * {@link #MIN_DIAGNOSTIC_RECORDING_INTERVAL}. This property is ignored if + * {@link com.launchdarkly.sdk.server.LDConfig.Builder#diagnosticOptOut(boolean)} is set to {@code true}. * - * @see com.launchdarkly.client.LDConfig.Builder#diagnosticOptOut(boolean) + * @see com.launchdarkly.sdk.server.LDConfig.Builder#diagnosticOptOut(boolean) * - * @param diagnosticRecordingIntervalSeconds the diagnostics interval in seconds + * @param diagnosticRecordingInterval the diagnostics interval; null to use the default * @return the builder */ - public EventProcessorBuilder diagnosticRecordingIntervalSeconds(int diagnosticRecordingIntervalSeconds) { - this.diagnosticRecordingIntervalSeconds = diagnosticRecordingIntervalSeconds < MIN_DIAGNOSTIC_RECORDING_INTERVAL_SECONDS ? - MIN_DIAGNOSTIC_RECORDING_INTERVAL_SECONDS : diagnosticRecordingIntervalSeconds; + public EventProcessorBuilder diagnosticRecordingInterval(Duration diagnosticRecordingInterval) { + if (diagnosticRecordingInterval == null) { + this.diagnosticRecordingInterval = DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL; + } else { + this.diagnosticRecordingInterval = diagnosticRecordingInterval.compareTo(MIN_DIAGNOSTIC_RECORDING_INTERVAL) < 0 ? + MIN_DIAGNOSTIC_RECORDING_INTERVAL : diagnosticRecordingInterval; + } return this; } @@ -143,13 +146,13 @@ public EventProcessorBuilder diagnosticRecordingIntervalSeconds(int diagnosticRe *

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

    - * The default value is {@link #DEFAULT_FLUSH_INTERVAL_SECONDS}. + * The default value is {@link #DEFAULT_FLUSH_INTERVAL}. * - * @param flushIntervalSeconds the flush interval in seconds + * @param flushInterval the flush interval; null to use the default * @return the builder */ - public EventProcessorBuilder flushIntervalSeconds(int flushIntervalSeconds) { - this.flushIntervalSeconds = flushIntervalSeconds; + public EventProcessorBuilder flushInterval(Duration flushInterval) { + this.flushInterval = flushInterval == null ? DEFAULT_FLUSH_INTERVAL : flushInterval; return this; } @@ -172,15 +175,39 @@ public EventProcessorBuilder inlineUsersInEvents(boolean inlineUsersInEvents) { *

    * 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. + * individual user with {@link com.launchdarkly.sdk.LDUser.Builder} methods. + *

    + * Using {@link #privateAttributes(UserAttribute...)} is preferable to avoid the possibility of + * misspelling a built-in attribute. * * @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 + * @see com.launchdarkly.sdk.LDUser.Builder */ public EventProcessorBuilder privateAttributeNames(String... attributeNames) { - this.privateAttrNames = new HashSet<>(Arrays.asList(attributeNames)); + privateAttributes = new HashSet<>(); + for (String a: attributeNames) { + privateAttributes.add(UserAttribute.forName(a)); + } + 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.sdk.LDUser.Builder} methods. + * + * @param attributes a set of attributes that will be removed from user data set to LaunchDarkly + * @return the builder + * @see #allAttributesPrivate(boolean) + * @see com.launchdarkly.sdk.LDUser.Builder + * @see #privateAttributeNames + */ + public EventProcessorBuilder privateAttributes(UserAttribute... attributes) { + privateAttributes = new HashSet<>(Arrays.asList(attributes)); return this; } @@ -188,7 +215,7 @@ public EventProcessorBuilder privateAttributeNames(String... attributeNames) { * 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)}. + * recently seen user keys, expiring at an interval set by {@link #userKeysFlushInterval(Duration)}. *

    * The default value is {@link #DEFAULT_USER_KEYS_CAPACITY}. * @@ -203,13 +230,13 @@ public EventProcessorBuilder userKeysCapacity(int userKeysCapacity) { /** * 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}. + * The default value is {@link #DEFAULT_USER_KEYS_FLUSH_INTERVAL}. * - * @param userKeysFlushIntervalSeconds the flush interval in seconds + * @param userKeysFlushInterval the flush interval; null to use the default * @return the builder */ - public EventProcessorBuilder userKeysFlushIntervalSeconds(int userKeysFlushIntervalSeconds) { - this.userKeysFlushIntervalSeconds = userKeysFlushIntervalSeconds; + public EventProcessorBuilder userKeysFlushInterval(Duration userKeysFlushInterval) { + this.userKeysFlushInterval = userKeysFlushInterval == null ? DEFAULT_USER_KEYS_FLUSH_INTERVAL : userKeysFlushInterval; return this; } } diff --git a/src/main/java/com/launchdarkly/client/integrations/FileData.java b/src/main/java/com/launchdarkly/sdk/server/integrations/FileData.java similarity index 93% rename from src/main/java/com/launchdarkly/client/integrations/FileData.java rename to src/main/java/com/launchdarkly/sdk/server/integrations/FileData.java index 9771db552..f38722cf1 100644 --- a/src/main/java/com/launchdarkly/client/integrations/FileData.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/FileData.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; /** * Integration between the LaunchDarkly SDK and file data. @@ -17,7 +17,7 @@ public abstract class FileData { *

    * 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)}. + * {@link com.launchdarkly.sdk.server.LDConfig.Builder#dataSource(com.launchdarkly.sdk.server.interfaces.DataSourceFactory)}. *

    * 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 @@ -34,8 +34,8 @@ public abstract class FileData { *

    * 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)}. + * this with {@link com.launchdarkly.sdk.server.Components#noEvents()} or + * {@link com.launchdarkly.sdk.server.LDConfig.Builder#offline(boolean)}. *

    * Flag data files can be either JSON or YAML. They contain an object with three possible * properties: diff --git a/src/main/java/com/launchdarkly/client/integrations/FileDataSourceBuilder.java b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBuilder.java similarity index 80% rename from src/main/java/com/launchdarkly/client/integrations/FileDataSourceBuilder.java rename to src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBuilder.java index 49df9e3de..429f27382 100644 --- a/src/main/java/com/launchdarkly/client/integrations/FileDataSourceBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBuilder.java @@ -1,9 +1,9 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; -import com.launchdarkly.client.FeatureStore; -import com.launchdarkly.client.LDConfig; -import com.launchdarkly.client.UpdateProcessor; -import com.launchdarkly.client.UpdateProcessorFactory; +import com.launchdarkly.sdk.server.interfaces.ClientContext; +import com.launchdarkly.sdk.server.interfaces.DataSource; +import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; +import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; import java.nio.file.InvalidPathException; import java.nio.file.Path; @@ -14,13 +14,13 @@ /** * 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)}. + * then pass the resulting object to {@link com.launchdarkly.sdk.server.LDConfig.Builder#dataSource(DataSourceFactory)}. *

    * For more details, see {@link FileData}. * * @since 4.12.0 */ -public final class FileDataSourceBuilder implements UpdateProcessorFactory { +public final class FileDataSourceBuilder implements DataSourceFactory { private final List sources = new ArrayList<>(); private boolean autoUpdate = false; @@ -77,7 +77,7 @@ public FileDataSourceBuilder autoUpdate(boolean autoUpdate) { * Used internally by the LaunchDarkly client. */ @Override - public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, FeatureStore featureStore) { - return new FileDataSourceImpl(featureStore, sources, autoUpdate); + public DataSource createDataSource(ClientContext context, DataStoreUpdates dataStoreUpdates) { + return new FileDataSourceImpl(dataStoreUpdates, sources, autoUpdate); } } \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/integrations/FileDataSourceImpl.java b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java similarity index 69% rename from src/main/java/com/launchdarkly/client/integrations/FileDataSourceImpl.java rename to src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java index cd2244564..f4d93ffcb 100644 --- a/src/main/java/com/launchdarkly/client/integrations/FileDataSourceImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java @@ -1,15 +1,17 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.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 com.google.common.collect.ImmutableList; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FileDataException; +import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FlagFactory; +import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FlagFileParser; +import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FlagFileRep; +import com.launchdarkly.sdk.server.interfaces.DataSource; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; +import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -24,15 +26,19 @@ import java.nio.file.WatchKey; import java.nio.file.WatchService; import java.nio.file.Watchable; +import java.util.AbstractMap; 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.CompletableFuture; import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicBoolean; +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; import static java.nio.file.StandardWatchEventKinds.ENTRY_CREATE; import static java.nio.file.StandardWatchEventKinds.ENTRY_DELETE; import static java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY; @@ -41,16 +47,16 @@ * Implements taking flag data from files and putting it into the data store, at startup time and * optionally whenever files change. */ -final class FileDataSourceImpl implements UpdateProcessor { +final class FileDataSourceImpl implements DataSource { private static final Logger logger = LoggerFactory.getLogger(FileDataSourceImpl.class); - private final FeatureStore store; + private final DataStoreUpdates dataStoreUpdates; private final DataLoader dataLoader; private final AtomicBoolean inited = new AtomicBoolean(false); private final FileWatcher fileWatcher; - FileDataSourceImpl(FeatureStore store, List sources, boolean autoUpdate) { - this.store = store; + FileDataSourceImpl(DataStoreUpdates dataStoreUpdates, List sources, boolean autoUpdate) { + this.dataStoreUpdates = dataStoreUpdates; this.dataLoader = new DataLoader(sources); FileWatcher fw = null; @@ -67,7 +73,7 @@ final class FileDataSourceImpl implements UpdateProcessor { @Override public Future start() { - final Future initFuture = Futures.immediateFuture(null); + final Future initFuture = CompletableFuture.completedFuture(null); reload(); @@ -76,10 +82,8 @@ public Future start() { // if we are told to reload by the file watcher. if (fileWatcher != null) { - fileWatcher.start(new Runnable() { - public void run() { - FileDataSourceImpl.this.reload(); - } + fileWatcher.start(() -> { + FileDataSourceImpl.this.reload(); }); } @@ -94,13 +98,13 @@ private boolean reload() { logger.error(e.getDescription()); return false; } - store.init(builder.build()); + dataStoreUpdates.init(builder.build()); inited.set(true); return true; } @Override - public boolean initialized() { + public boolean isInitialized() { return inited.get(); } @@ -214,18 +218,18 @@ public void load(DataBuilder builder) throws FileDataException 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())); + for (Map.Entry e: fileContents.flags.entrySet()) { + builder.add(FEATURES, e.getKey(), 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())); + for (Map.Entry e: fileContents.flagValues.entrySet()) { + builder.add(FEATURES, e.getKey(), 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())); + for (Map.Entry e: fileContents.segments.entrySet()) { + builder.add(SEGMENTS, e.getKey(), FlagFactory.segmentFromJson(e.getValue())); } } } catch (FileDataException e) { @@ -242,23 +246,26 @@ public void load(DataBuilder builder) throws FileDataException * 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<>(); + private final Map> allData = new HashMap<>(); - public Map, Map> build() { - return allData; + public FullDataSet build() { + ImmutableList.Builder>> allBuilder = ImmutableList.builder(); + for (Map.Entry> e0: allData.entrySet()) { + allBuilder.add(new AbstractMap.SimpleEntry<>(e0.getKey(), new KeyedItems<>(e0.getValue().entrySet()))); + } + return new FullDataSet<>(allBuilder.build()); } - public void add(VersionedDataKind kind, VersionedData item) throws FileDataException { - @SuppressWarnings("unchecked") - Map items = (Map)allData.get(kind); + public void add(DataKind kind, String key, ItemDescriptor item) throws FileDataException { + Map items = allData.get(kind); if (items == null) { - items = new HashMap(); + 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); + if (items.containsKey(key)) { + throw new FileDataException("in " + kind.getName() + ", key \"" + key + "\" was already defined", null, null); } - items.put(item.getKey(), item); + items.put(key, item); } } } diff --git a/src/main/java/com/launchdarkly/client/integrations/FileDataSourceParsing.java b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceParsing.java similarity index 82% rename from src/main/java/com/launchdarkly/client/integrations/FileDataSourceParsing.java rename to src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceParsing.java index 08083e4d9..74d3d46e3 100644 --- a/src/main/java/com/launchdarkly/client/integrations/FileDataSourceParsing.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceParsing.java @@ -1,12 +1,10 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.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 com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import org.yaml.snakeyaml.Yaml; import org.yaml.snakeyaml.error.YAMLException; @@ -19,6 +17,9 @@ import java.nio.file.Path; import java.util.Map; +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; + abstract class FileDataSourceParsing { /** * Indicates that the file processor encountered an error in one of the input files. This exception is @@ -65,13 +66,13 @@ public String getDescription() { * 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; + Map flags; + Map flagValues; + Map segments; FlagFileRep() {} - FlagFileRep(Map flags, Map flagValues, Map segments) { + FlagFileRep(Map flags, Map flagValues, Map segments) { this.flags = flags; this.flagValues = flagValues; this.segments = segments; @@ -182,42 +183,36 @@ public FlagFileRep parse(InputStream input) throws FileDataException, IOExceptio * 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 ItemDescriptor flagFromJson(String jsonString) { + return FEATURES.deserialize(jsonString); } - static VersionedData flagFromJson(JsonElement jsonTree) { - return gson.fromJson(jsonTree, VersionedDataKind.FEATURES.getItemClass()); + static ItemDescriptor flagFromJson(LDValue jsonTree) { + return flagFromJson(jsonTree.toJsonString()); } /** * 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); + static ItemDescriptor flagWithValue(String key, LDValue jsonValue) { + LDValue o = LDValue.buildObject() + .put("key", key) + .put("on", true) + .put("variations", LDValue.buildArray().add(jsonValue).build()) + .put("fallthrough", LDValue.buildObject().put("variation", 0).build()) + .build(); // 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); + return FEATURES.deserialize(o.toJsonString()); } - static VersionedData segmentFromJson(String jsonString) { - return segmentFromJson(gson.fromJson(jsonString, JsonElement.class)); + static ItemDescriptor segmentFromJson(String jsonString) { + return SEGMENTS.deserialize(jsonString); } - static VersionedData segmentFromJson(JsonElement jsonTree) { - return gson.fromJson(jsonTree, VersionedDataKind.SEGMENTS.getItemClass()); + static ItemDescriptor segmentFromJson(LDValue jsonTree) { + return segmentFromJson(jsonTree.toJsonString()); } } } diff --git a/src/main/java/com/launchdarkly/client/integrations/HttpConfigurationBuilder.java b/src/main/java/com/launchdarkly/sdk/server/integrations/HttpConfigurationBuilder.java similarity index 70% rename from src/main/java/com/launchdarkly/client/integrations/HttpConfigurationBuilder.java rename to src/main/java/com/launchdarkly/sdk/server/integrations/HttpConfigurationBuilder.java index 3392f0e9f..7fa33d889 100644 --- a/src/main/java/com/launchdarkly/client/integrations/HttpConfigurationBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/HttpConfigurationBuilder.java @@ -1,8 +1,10 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; -import com.launchdarkly.client.Components; -import com.launchdarkly.client.interfaces.HttpAuthentication; -import com.launchdarkly.client.interfaces.HttpConfigurationFactory; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.interfaces.HttpAuthentication; +import com.launchdarkly.sdk.server.interfaces.HttpConfigurationFactory; + +import java.time.Duration; import javax.net.ssl.SSLSocketFactory; import javax.net.ssl.X509TrustManager; @@ -12,7 +14,7 @@ *

    * 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)}: + * and pass it to {@link com.launchdarkly.sdk.server.LDConfig.Builder#http(HttpConfigurationFactory)}: *

    
      *     LDConfig config = new LDConfig.Builder()
      *         .http(
    @@ -23,29 +25,26 @@
      *         .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)}. + * The default value for {@link #connectTimeout(Duration)}: two seconds. */ - public static final int DEFAULT_CONNECT_TIMEOUT_MILLIS = 2000; + public static final Duration DEFAULT_CONNECT_TIMEOUT = Duration.ofSeconds(2); /** - * The default value for {@link #socketTimeoutMillis(int)}. + * The default value for {@link #socketTimeout(Duration)}: 10 seconds. */ - public static final int DEFAULT_SOCKET_TIMEOUT_MILLIS = 10000; + public static final Duration DEFAULT_SOCKET_TIMEOUT = Duration.ofSeconds(10); - protected int connectTimeoutMillis = DEFAULT_CONNECT_TIMEOUT_MILLIS; + protected Duration connectTimeout = DEFAULT_CONNECT_TIMEOUT; protected HttpAuthentication proxyAuth; protected String proxyHost; protected int proxyPort; - protected int socketTimeoutMillis = DEFAULT_SOCKET_TIMEOUT_MILLIS; + protected Duration socketTimeout = DEFAULT_SOCKET_TIMEOUT; protected SSLSocketFactory sslSocketFactory; protected X509TrustManager trustManager; protected String wrapperName; @@ -55,13 +54,13 @@ public abstract class HttpConfigurationBuilder implements HttpConfigurationFacto * 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}. + * The default is {@link #DEFAULT_CONNECT_TIMEOUT}. * - * @param connectTimeoutMillis the connection timeout, in milliseconds + * @param connectTimeout the connection timeout; null to use the default * @return the builder */ - public HttpConfigurationBuilder connectTimeoutMillis(int connectTimeoutMillis) { - this.connectTimeoutMillis = connectTimeoutMillis; + public HttpConfigurationBuilder connectTimeout(Duration connectTimeout) { + this.connectTimeout = connectTimeout == null ? DEFAULT_CONNECT_TIMEOUT : connectTimeout; return this; } @@ -93,16 +92,16 @@ public HttpConfigurationBuilder proxyAuth(HttpAuthentication strategy) { /** * 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 + * used by {@link com.launchdarkly.sdk.server.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}. + * The default is {@link #DEFAULT_SOCKET_TIMEOUT}. * - * @param socketTimeoutMillis the socket timeout, in milliseconds + * @param socketTimeout the socket timeout; null to use the default * @return the builder */ - public HttpConfigurationBuilder socketTimeoutMillis(int socketTimeoutMillis) { - this.socketTimeoutMillis = socketTimeoutMillis; + public HttpConfigurationBuilder socketTimeout(Duration socketTimeout) { + this.socketTimeout = socketTimeout == null ? DEFAULT_SOCKET_TIMEOUT : socketTimeout; return this; } diff --git a/src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreBuilder.java b/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreBuilder.java similarity index 70% rename from src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreBuilder.java rename to src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreBuilder.java index 21c0f1177..d80e825e6 100644 --- a/src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreBuilder.java @@ -1,11 +1,14 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; -import com.launchdarkly.client.Components; -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.sdk.server.Components; +import com.launchdarkly.sdk.server.interfaces.ClientContext; +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; +import com.launchdarkly.sdk.server.interfaces.PersistentDataStore; +import com.launchdarkly.sdk.server.interfaces.PersistentDataStoreFactory; +import java.time.Duration; import java.util.concurrent.TimeUnit; /** @@ -16,7 +19,7 @@ * There is also universal behavior that the SDK provides for all persistent data stores, such as caching; * the {@link PersistentDataStoreBuilder} adds this. *

    - * After configuring this object, pass it to {@link com.launchdarkly.client.LDConfig.Builder#dataStore(FeatureStoreFactory)} + * After configuring this object, pass it to {@link com.launchdarkly.sdk.server.LDConfig.Builder#dataStore(DataStoreFactory)} * to use it in the SDK configuration. For example, using the Redis integration: * *

    
    @@ -36,16 +39,16 @@
      * {@link Components#persistentDataStore(PersistentDataStoreFactory)}.
      * @since 4.12.0
      */
    -@SuppressWarnings("deprecation")
    -public abstract class PersistentDataStoreBuilder implements FeatureStoreFactory, DiagnosticDescription {
    +public abstract class PersistentDataStoreBuilder implements DataStoreFactory {
       /**
        * The default value for the cache TTL.
        */
    -  public static final int DEFAULT_CACHE_TTL_SECONDS = 15;
    +  public static final Duration DEFAULT_CACHE_TTL = Duration.ofSeconds(15);
     
    -  protected final PersistentDataStoreFactory persistentDataStoreFactory;
    -  protected FeatureStoreCacheConfig caching = FeatureStoreCacheConfig.DEFAULT;
    -  protected CacheMonitor cacheMonitor = null;
    +  protected final PersistentDataStoreFactory persistentDataStoreFactory; // see Components for why this is not private
    +  private Duration cacheTime = DEFAULT_CACHE_TTL;
    +  private StaleValuesPolicy staleValuesPolicy = StaleValuesPolicy.EVICT;
    +  private boolean recordCacheStats = false;
     
       /**
        * Possible values for {@link #staleValuesPolicy(StaleValuesPolicy)}.
    @@ -108,7 +111,7 @@ protected PersistentDataStoreBuilder(PersistentDataStoreFactory persistentDataSt
        * @return the builder
        */
       public PersistentDataStoreBuilder noCaching() {
    -    return cacheTime(0, TimeUnit.MILLISECONDS);
    +    return cacheTime(Duration.ZERO);
       }
       
       /**
    @@ -119,33 +122,32 @@ public PersistentDataStoreBuilder 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 + * @param cacheTime the cache TTL; null to use the default * @return the builder */ - public PersistentDataStoreBuilder cacheTime(long cacheTime, TimeUnit cacheTimeUnit) { - caching = caching.ttl(cacheTime, cacheTimeUnit); + public PersistentDataStoreBuilder cacheTime(Duration cacheTime) { + this.cacheTime = cacheTime == null ? DEFAULT_CACHE_TTL : cacheTime; return this; } /** - * Shortcut for calling {@link #cacheTime(long, TimeUnit)} with {@link TimeUnit#MILLISECONDS}. + * Shortcut for calling {@link #cacheTime(Duration)} with a duration in milliseconds. * * @param millis the cache TTL in milliseconds * @return the builder */ public PersistentDataStoreBuilder cacheMillis(long millis) { - return cacheTime(millis, TimeUnit.MILLISECONDS); + return cacheTime(Duration.ofMillis(millis)); } /** - * Shortcut for calling {@link #cacheTime(long, TimeUnit)} with {@link TimeUnit#SECONDS}. + * Shortcut for calling {@link #cacheTime(Duration)} with a duration in seconds. * * @param seconds the cache TTL in seconds * @return the builder */ public PersistentDataStoreBuilder cacheSeconds(long seconds) { - return cacheTime(seconds, TimeUnit.SECONDS); + return cacheTime(Duration.ofSeconds(seconds)); } /** @@ -161,7 +163,7 @@ public PersistentDataStoreBuilder cacheSeconds(long seconds) { * @return the builder */ public PersistentDataStoreBuilder cacheForever() { - return cacheTime(-1, TimeUnit.MILLISECONDS); + return cacheTime(Duration.ofMillis(-1)); } /** @@ -172,37 +174,35 @@ public PersistentDataStoreBuilder cacheForever() { * @return the builder */ public PersistentDataStoreBuilder staleValuesPolicy(StaleValuesPolicy staleValuesPolicy) { - caching = caching.staleValuesPolicy(FeatureStoreCacheConfig.StaleValuesPolicy.fromNewEnum(staleValuesPolicy)); + this.staleValuesPolicy = staleValuesPolicy == null ? StaleValuesPolicy.EVICT : staleValuesPolicy; return this; } /** - * Provides a conduit for an application to monitor the effectiveness of the in-memory cache. + * Enables monitoring 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: + * If set to true, this makes caching statistics available through the {@link DataStoreStatusProvider} + * that you can obtain from the client instance. This may slightly decrease performance, due to the + * need to record statistics for each cache operation. + *

    + * By default, it is false: statistics will not be recorded and the {@link DataStoreStatusProvider#getCacheStats()} + * method will return null. * - *
    
    -   *     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} + * @param recordCacheStats true to record caching statiistics * @return the builder + * @since 5.0.0 */ - public PersistentDataStoreBuilder cacheMonitor(CacheMonitor cacheMonitor) { - this.cacheMonitor = cacheMonitor; + public PersistentDataStoreBuilder recordCacheStats(boolean recordCacheStats) { + this.recordCacheStats = recordCacheStats; return this; } + + /** + * Called by the SDK to create the data store instance. + */ + @Override + public DataStore createDataStore(ClientContext context) { + PersistentDataStore core = persistentDataStoreFactory.createPersistentDataStore(context); + return new PersistentDataStoreWrapper(core, cacheTime, staleValuesPolicy, recordCacheStats); + } } diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreStatusManager.java b/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreStatusManager.java new file mode 100644 index 000000000..a59528420 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreStatusManager.java @@ -0,0 +1,138 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.google.common.util.concurrent.ThreadFactoryBuilder; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider.Status; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider.StatusListener; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; + +/** + * Used internally to encapsulate the data store status broadcasting mechanism for PersistentDataStoreWrapper. + *

    + * This is currently only used by PersistentDataStoreWrapper, but encapsulating it in its own class helps with + * clarity and also lets us reuse this logic in tests. + */ +final class PersistentDataStoreStatusManager { + private static final Logger logger = LoggerFactory.getLogger(PersistentDataStoreStatusManager.class); + static final int POLL_INTERVAL_MS = 500; // visible for testing + + private final List listeners = new ArrayList<>(); + private final ScheduledExecutorService scheduler; + private final Callable statusPollFn; + private final boolean refreshOnRecovery; + private volatile boolean lastAvailable; + private volatile ScheduledFuture pollerFuture; + + PersistentDataStoreStatusManager(boolean refreshOnRecovery, boolean availableNow, Callable statusPollFn) { + this.refreshOnRecovery = refreshOnRecovery; + this.lastAvailable = availableNow; + this.statusPollFn = statusPollFn; + + ThreadFactory threadFactory = new ThreadFactoryBuilder() + .setNameFormat("LaunchDarkly-DataStoreStatusManager-%d") + .build(); + scheduler = Executors.newSingleThreadScheduledExecutor(threadFactory); + // Using newSingleThreadScheduledExecutor avoids ambiguity about execution order if we might have + // have a StatusNotificationTask happening soon after another one. + } + + synchronized void addStatusListener(StatusListener listener) { + listeners.add(listener); + } + + synchronized void removeStatusListener(StatusListener listener) { + listeners.remove(listener); + } + + void updateAvailability(boolean available) { + StatusListener[] copyOfListeners = null; + synchronized (this) { + if (lastAvailable == available) { + return; + } + lastAvailable = available; + copyOfListeners = listeners.toArray(new StatusListener[listeners.size()]); + } + + Status status = new Status(available, available && refreshOnRecovery); + + if (available) { + logger.warn("Persistent store is available again"); + } + + // Notify all the subscribers (on a worker thread, so we can't be blocked by a slow listener). + if (copyOfListeners.length > 0) { + scheduler.schedule(new StatusNotificationTask(status, copyOfListeners), 0, TimeUnit.MILLISECONDS); + } + + // If the store has just become unavailable, start a poller to detect when it comes back. If it has + // become available, stop any polling we are currently doing. + if (available) { + synchronized (this) { + if (pollerFuture != null) { + pollerFuture.cancel(false); + pollerFuture = null; + } + } + } else { + logger.warn("Detected persistent store unavailability; updates will be cached until it recovers"); + + // Start polling until the store starts working again + Runnable pollerTask = new Runnable() { + public void run() { + try { + if (statusPollFn.call()) { + updateAvailability(true); + } + } catch (Exception e) { + logger.error("Unexpected error from data store status function: {0}", e); + } + } + }; + synchronized (this) { + if (pollerFuture == null) { + pollerFuture = scheduler.scheduleAtFixedRate(pollerTask, POLL_INTERVAL_MS, POLL_INTERVAL_MS, TimeUnit.MILLISECONDS); + } + } + } + } + + synchronized boolean isAvailable() { + return lastAvailable; + } + + void close() { + scheduler.shutdown(); + } + + private static final class StatusNotificationTask implements Runnable { + private final Status status; + private final StatusListener[] listeners; + + StatusNotificationTask(Status status, StatusListener[] listeners) { + this.status = status; + this.listeners = listeners; + } + + public void run() { + for (StatusListener listener: listeners) { + try { + listener.dataStoreStatusChanged(status); + } catch (Exception e) { + logger.error("Unexpected error from StatusListener: {0}", e); + } + } + } + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapper.java b/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapper.java new file mode 100644 index 000000000..f5868ee3a --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapper.java @@ -0,0 +1,479 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.google.common.base.Optional; +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import com.google.common.collect.ImmutableList; +import com.google.common.util.concurrent.ListeningExecutorService; +import com.google.common.util.concurrent.MoreExecutors; +import com.google.common.util.concurrent.ThreadFactoryBuilder; +import com.google.common.util.concurrent.UncheckedExecutionException; +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.SerializedItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.PersistentDataStore; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.time.Duration; +import java.util.AbstractMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicBoolean; + +import static com.google.common.collect.Iterables.concat; +import static com.google.common.collect.Iterables.filter; +import static com.google.common.collect.Iterables.isEmpty; + +/** + * Package-private implementation of {@link DataStore} that delegates the basic functionality to an + * instance of {@link PersistentDataStore}. It provides optional caching behavior and other logic that + * would otherwise be repeated in every data store implementation. This makes it easier to create new + * database integrations by implementing only the database-specific logic. + *

    + * This class is only constructed by {@link PersistentDataStoreBuilder}. + */ +final class PersistentDataStoreWrapper implements DataStore, DataStoreStatusProvider { + private static final Logger logger = LoggerFactory.getLogger(PersistentDataStoreWrapper.class); + private static final String CACHE_REFRESH_THREAD_POOL_NAME_FORMAT = "CachingStoreWrapper-refresher-pool-%d"; + + private final PersistentDataStore core; + private final LoadingCache> itemCache; + private final LoadingCache> allCache; + private final LoadingCache initCache; + private final PersistentDataStoreStatusManager statusManager; + private final boolean cacheIndefinitely; + private final Set cachedDataKinds = new HashSet<>(); // this map is used in pollForAvailability() + private final AtomicBoolean inited = new AtomicBoolean(false); + private final ListeningExecutorService executorService; + + PersistentDataStoreWrapper( + final PersistentDataStore core, + Duration cacheTtl, + PersistentDataStoreBuilder.StaleValuesPolicy staleValuesPolicy, + boolean recordCacheStats + ) { + this.core = core; + + if (cacheTtl == null || cacheTtl.isZero()) { + itemCache = null; + allCache = null; + initCache = null; + executorService = null; + cacheIndefinitely = false; + } else { + cacheIndefinitely = cacheTtl.isNegative(); + CacheLoader> itemLoader = new CacheLoader>() { + @Override + public Optional load(CacheKey key) throws Exception { + return Optional.fromNullable(getAndDeserializeItem(key.kind, key.key)); + } + }; + CacheLoader> allLoader = new CacheLoader>() { + @Override + public KeyedItems load(DataKind kind) throws Exception { + return getAllAndDeserialize(kind); + } + }; + CacheLoader initLoader = new CacheLoader() { + @Override + public Boolean load(String key) throws Exception { + return core.isInitialized(); + } + }; + + if (staleValuesPolicy == PersistentDataStoreBuilder.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); + + // 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; + } + + itemCache = newCacheBuilder(cacheTtl, staleValuesPolicy, recordCacheStats).build(itemLoader); + allCache = newCacheBuilder(cacheTtl, staleValuesPolicy, recordCacheStats).build(allLoader); + initCache = newCacheBuilder(cacheTtl, staleValuesPolicy, recordCacheStats).build(initLoader); + } + statusManager = new PersistentDataStoreStatusManager(!cacheIndefinitely, true, this::pollAvailabilityAfterOutage); + } + + private static CacheBuilder newCacheBuilder( + Duration cacheTtl, + PersistentDataStoreBuilder.StaleValuesPolicy staleValuesPolicy, + boolean recordCacheStats + ) { + CacheBuilder builder = CacheBuilder.newBuilder(); + boolean isInfiniteTtl = cacheTtl.isNegative(); + if (!isInfiniteTtl) { + if (staleValuesPolicy == PersistentDataStoreBuilder.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(cacheTtl); + } 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. + builder = builder.refreshAfterWrite(cacheTtl); + } + } + if (recordCacheStats) { + builder = builder.recordStats(); + } + return builder; + } + + @Override + public void close() throws IOException { + if (executorService != null) { + executorService.shutdownNow(); + } + statusManager.close(); + core.close(); + } + + @Override + public boolean isInitialized() { + if (inited.get()) { + return true; + } + boolean result; + if (initCache != null) { + try { + result = initCache.get(""); + } catch (ExecutionException e) { + result = false; + } + } else { + result = core.isInitialized(); + } + if (result) { + inited.set(true); + } + return result; + } + + @Override + public void init(FullDataSet allData) { + synchronized (cachedDataKinds) { + cachedDataKinds.clear(); + for (Map.Entry> e: allData.getData()) { + cachedDataKinds.add(e.getKey()); + } + } + ImmutableList.Builder>> allBuilder = ImmutableList.builder(); + for (Map.Entry> e0: allData.getData()) { + DataKind kind = e0.getKey(); + KeyedItems items = serializeAll(kind, e0.getValue()); + allBuilder.add(new AbstractMap.SimpleEntry<>(kind, items)); + } + RuntimeException failure = initCore(new FullDataSet<>(allBuilder.build())); + if (itemCache != null && allCache != null) { + itemCache.invalidateAll(); + allCache.invalidateAll(); + if (failure != null && !cacheIndefinitely) { + // 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. + throw failure; + } + for (Map.Entry> e0: allData.getData()) { + DataKind kind = e0.getKey(); + KeyedItems immutableItems = new KeyedItems<>(ImmutableList.copyOf(e0.getValue().getItems())); + allCache.put(kind, immutableItems); + for (Map.Entry e1: e0.getValue().getItems()) { + itemCache.put(CacheKey.forItem(kind, e1.getKey()), Optional.of(e1.getValue())); + } + } + } + if (failure == null || cacheIndefinitely) { + inited.set(true); + } + if (failure != null) { + throw failure; + } + } + + private RuntimeException initCore(FullDataSet allData) { + try { + core.init(allData); + processError(null); + return null; + } catch (RuntimeException e) { + processError(e); + return e; + } + } + + @Override + public ItemDescriptor get(DataKind kind, String key) { + try { + ItemDescriptor ret = itemCache != null ? itemCache.get(CacheKey.forItem(kind, key)).orNull() : + getAndDeserializeItem(kind, key); + processError(null); + return ret; + } catch (Exception e) { + processError(e); + throw getAsRuntimeException(e); + } + } + + @Override + public KeyedItems getAll(DataKind kind) { + try { + KeyedItems ret; + ret = allCache != null ? allCache.get(kind) : getAllAndDeserialize(kind); + processError(null); + return ret; + } catch (Exception e) { + processError(e); + throw getAsRuntimeException(e); + } + } + + private static RuntimeException getAsRuntimeException(Exception e) { + Throwable t = (e instanceof ExecutionException || e instanceof UncheckedExecutionException) + ? e.getCause() // this is a wrapped exception thrown by a cache + : e; + return t instanceof RuntimeException ? (RuntimeException)t : new RuntimeException(t); + } + + @Override + public boolean upsert(DataKind kind, String key, ItemDescriptor item) { + synchronized (cachedDataKinds) { + cachedDataKinds.add(kind); + } + SerializedItemDescriptor serializedItem = serialize(kind, item); + boolean updated = false; + RuntimeException failure = null; + try { + updated = core.upsert(kind, key, serializedItem); + processError(null); + } 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. + processError(e); + if (!cacheIndefinitely) + { + throw e; + } + failure = e; + } + if (itemCache != null) { + CacheKey cacheKey = CacheKey.forItem(kind, key); + if (failure == null) { + if (updated) { + itemCache.put(cacheKey, Optional.of(item)); + } else { + // there was a concurrent modification elsewhere - update the cache to get the new state + itemCache.refresh(cacheKey); + } + } else { + Optional oldItem = itemCache.getIfPresent(cacheKey); + if (oldItem == null || !oldItem.isPresent() || oldItem.get().getVersion() < item.getVersion()) { + itemCache.put(cacheKey, Optional.of(item)); + } + } + } + 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 (cacheIndefinitely) { + KeyedItems cachedAll = allCache.getIfPresent(kind); + allCache.put(kind, updateSingleItem(cachedAll, key, item)); + } else { + allCache.invalidate(kind); + } + } + if (failure != null) { + throw failure; + } + return updated; + } + + @Override + public Status getStoreStatus() { + return new Status(statusManager.isAvailable(), false); + } + + @Override + public boolean addStatusListener(StatusListener listener) { + statusManager.addStatusListener(listener); + return true; + } + + @Override + public void removeStatusListener(StatusListener listener) { + statusManager.removeStatusListener(listener); + } + + @Override + public CacheStats getCacheStats() { + if (itemCache == null || allCache == null) { + return null; + } + com.google.common.cache.CacheStats itemStats = itemCache.stats(); + com.google.common.cache.CacheStats allStats = allCache.stats(); + return new 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()); + } + + /** + * Return the underlying implementation object. + * + * @return the underlying implementation object + */ + public PersistentDataStore getCore() { + return core; + } + + private ItemDescriptor getAndDeserializeItem(DataKind kind, String key) { + SerializedItemDescriptor maybeSerializedItem = core.get(kind, key); + return maybeSerializedItem == null ? null : deserialize(kind, maybeSerializedItem); + } + + private KeyedItems getAllAndDeserialize(DataKind kind) { + KeyedItems allItems = core.getAll(kind); + if (isEmpty(allItems.getItems())) { + return new KeyedItems(null); + } + ImmutableList.Builder> b = ImmutableList.builder(); + for (Map.Entry e: allItems.getItems()) { + b.add(new AbstractMap.SimpleEntry<>(e.getKey(), deserialize(kind, e.getValue()))); + } + return new KeyedItems<>(b.build()); + } + + private SerializedItemDescriptor serialize(DataKind kind, ItemDescriptor itemDesc) { + boolean isDeleted = itemDesc.getItem() == null; + return new SerializedItemDescriptor(itemDesc.getVersion(), isDeleted, kind.serialize(itemDesc)); + } + + private KeyedItems serializeAll(DataKind kind, KeyedItems items) { + ImmutableList.Builder> itemsBuilder = ImmutableList.builder(); + for (Map.Entry e: items.getItems()) { + itemsBuilder.add(new AbstractMap.SimpleEntry<>(e.getKey(), serialize(kind, e.getValue()))); + } + return new KeyedItems<>(itemsBuilder.build()); + } + + private ItemDescriptor deserialize(DataKind kind, SerializedItemDescriptor serializedItemDesc) { + if (serializedItemDesc.isDeleted() || serializedItemDesc.getSerializedItem() == null) { + return ItemDescriptor.deletedItem(serializedItemDesc.getVersion()); + } + ItemDescriptor deserializedItem = kind.deserialize(serializedItemDesc.getSerializedItem()); + if (serializedItemDesc.getVersion() == 0 || serializedItemDesc.getVersion() == deserializedItem.getVersion() + || deserializedItem.getItem() == null) { + return deserializedItem; + } + // If the store gave us a version number that isn't what was encoded in the object, trust it + return new ItemDescriptor(serializedItemDesc.getVersion(), deserializedItem.getItem()); + } + + private KeyedItems updateSingleItem(KeyedItems items, String key, ItemDescriptor item) { + // This is somewhat inefficient but it's preferable to use immutable data structures in the cache. + return new KeyedItems<>( + ImmutableList.copyOf(concat( + items == null ? ImmutableList.of() : filter(items.getItems(), e -> !e.getKey().equals(key)), + ImmutableList.>of(new AbstractMap.SimpleEntry<>(key, item)) + ) + )); + } + + private void processError(Throwable error) { + if (error == null) { + // If we're waiting to recover after a failure, we'll let the polling routine take care + // of signaling success. Even if we could signal success a little earlier based on the + // success of whatever operation we just did, we'd rather avoid the overhead of acquiring + // w.statusLock every time we do anything. So we'll just do nothing here. + return; + } + statusManager.updateAvailability(false); + } + + private boolean pollAvailabilityAfterOutage() { + if (!core.isStoreAvailable()) { + return false; + } + + if (cacheIndefinitely && allCache != null) { + // If we're in infinite cache mode, then we can assume the cache has a full set of current + // flag data (since presumably the data source has still been running) and we can just + // write the contents of the cache to the underlying data store. + DataKind[] allKinds; + synchronized (cachedDataKinds) { + allKinds = cachedDataKinds.toArray(new DataKind[cachedDataKinds.size()]); + } + ImmutableList.Builder>> builder = ImmutableList.builder(); + for (DataKind kind: allKinds) { + KeyedItems items = allCache.getIfPresent(kind); + if (items != null) { + builder.add(new AbstractMap.SimpleEntry<>(kind, serializeAll(kind, items))); + } + } + RuntimeException e = initCore(new FullDataSet<>(builder.build())); + if (e == null) { + logger.warn("Successfully updated persistent store from cached data"); + } else { + // We failed to write the cached data to the underlying store. In this case, we should not + // return to a recovered state, but just try this all again next time the poll task runs. + logger.error("Tried to write cached data to persistent store after a store outage, but failed: {}", e); + return false; + } + } + + return true; + } + + private static final class CacheKey { + final DataKind kind; + final String key; + + public static CacheKey forItem(DataKind kind, String key) { + return new CacheKey(kind, key); + } + + private CacheKey(DataKind 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.getName().equals(this.kind.getName()) && o.key.equals(this.key); + } + return false; + } + + @Override + public int hashCode() { + return kind.getName().hashCode() * 31 + key.hashCode(); + } + } +} diff --git a/src/main/java/com/launchdarkly/client/integrations/PollingDataSourceBuilder.java b/src/main/java/com/launchdarkly/sdk/server/integrations/PollingDataSourceBuilder.java similarity index 64% rename from src/main/java/com/launchdarkly/client/integrations/PollingDataSourceBuilder.java rename to src/main/java/com/launchdarkly/sdk/server/integrations/PollingDataSourceBuilder.java index 72a461cbd..43f1fa38a 100644 --- a/src/main/java/com/launchdarkly/client/integrations/PollingDataSourceBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/PollingDataSourceBuilder.java @@ -1,9 +1,10 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; -import com.launchdarkly.client.Components; -import com.launchdarkly.client.UpdateProcessorFactory; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; import java.net.URI; +import java.time.Duration; /** * Contains methods for configuring the polling data source. @@ -14,28 +15,25 @@ * 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)}: + * change its properties with the methods of this class, and pass it to {@link com.launchdarkly.sdk.server.LDConfig.Builder#dataSource(DataSourceFactory)}: *

    
      *     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 { +public abstract class PollingDataSourceBuilder implements DataSourceFactory { /** - * The default and minimum value for {@link #pollIntervalMillis(long)}. + * The default and minimum value for {@link #pollInterval(Duration)}: 30 seconds. */ - public static final long DEFAULT_POLL_INTERVAL_MILLIS = 30000L; + public static final Duration DEFAULT_POLL_INTERVAL = Duration.ofSeconds(30); protected URI baseURI; - protected long pollIntervalMillis = DEFAULT_POLL_INTERVAL_MILLIS; + protected Duration pollInterval = DEFAULT_POLL_INTERVAL; /** * Sets a custom base URI for the polling service. @@ -58,16 +56,18 @@ public PollingDataSourceBuilder baseURI(URI baseURI) { /** * 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 + * The default and minimum value is {@link #DEFAULT_POLL_INTERVAL}. Values less than this will be * set to the default. * - * @param pollIntervalMillis the polling interval in milliseconds + * @param pollInterval the polling interval; null to use the default * @return the builder */ - public PollingDataSourceBuilder pollIntervalMillis(long pollIntervalMillis) { - this.pollIntervalMillis = pollIntervalMillis < DEFAULT_POLL_INTERVAL_MILLIS ? - DEFAULT_POLL_INTERVAL_MILLIS : - pollIntervalMillis; + public PollingDataSourceBuilder pollInterval(Duration pollInterval) { + if (pollInterval == null) { + this.pollInterval = DEFAULT_POLL_INTERVAL; + } else { + this.pollInterval = pollInterval.compareTo(DEFAULT_POLL_INTERVAL) < 0 ? DEFAULT_POLL_INTERVAL : pollInterval; + } return this; } } diff --git a/src/main/java/com/launchdarkly/client/integrations/StreamingDataSourceBuilder.java b/src/main/java/com/launchdarkly/sdk/server/integrations/StreamingDataSourceBuilder.java similarity index 72% rename from src/main/java/com/launchdarkly/client/integrations/StreamingDataSourceBuilder.java rename to src/main/java/com/launchdarkly/sdk/server/integrations/StreamingDataSourceBuilder.java index 62e891d3d..4943858d2 100644 --- a/src/main/java/com/launchdarkly/client/integrations/StreamingDataSourceBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/StreamingDataSourceBuilder.java @@ -1,38 +1,36 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; -import com.launchdarkly.client.Components; -import com.launchdarkly.client.UpdateProcessorFactory; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; import java.net.URI; +import java.time.Duration; /** * 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)}: + * change its properties with the methods of this class, and pass it to {@link com.launchdarkly.sdk.server.LDConfig.Builder#dataSource(DataSourceFactory)}: *

    
      *     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#streamingDataSource()}. * * @since 4.12.0 */ -public abstract class StreamingDataSourceBuilder implements UpdateProcessorFactory { +public abstract class StreamingDataSourceBuilder implements DataSourceFactory { /** - * The default value for {@link #initialReconnectDelayMillis(long)}. + * The default value for {@link #initialReconnectDelay(Duration)}: 1000 milliseconds. */ - public static final long DEFAULT_INITIAL_RECONNECT_DELAY_MILLIS = 1000; + public static final Duration DEFAULT_INITIAL_RECONNECT_DELAY = Duration.ofMillis(1000); protected URI baseURI; protected URI pollingBaseURI; - protected long initialReconnectDelayMillis = DEFAULT_INITIAL_RECONNECT_DELAY_MILLIS; + protected Duration initialReconnectDelay = DEFAULT_INITIAL_RECONNECT_DELAY; /** * Sets a custom base URI for the streaming service. @@ -59,14 +57,14 @@ public StreamingDataSourceBuilder baseURI(URI baseURI) { * 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}. + * The default value is {@link #DEFAULT_INITIAL_RECONNECT_DELAY}. * - * @param initialReconnectDelayMillis the reconnect time base value in milliseconds + * @param initialReconnectDelay the reconnect time base value; null to use the default * @return the builder */ - public StreamingDataSourceBuilder initialReconnectDelayMillis(long initialReconnectDelayMillis) { - this.initialReconnectDelayMillis = initialReconnectDelayMillis; + public StreamingDataSourceBuilder initialReconnectDelay(Duration initialReconnectDelay) { + this.initialReconnectDelay = initialReconnectDelay == null ? DEFAULT_INITIAL_RECONNECT_DELAY : initialReconnectDelay; return this; } diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/package-info.java b/src/main/java/com/launchdarkly/sdk/server/integrations/package-info.java new file mode 100644 index 000000000..7c5d27cb6 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/package-info.java @@ -0,0 +1,11 @@ +/** + * 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 the configuration builders + * for the standard SDK components such as {@link com.launchdarkly.sdk.server.integrations.StreamingDataSourceBuilder}, + * the {@link com.launchdarkly.sdk.server.integrations.PersistentDataStoreBuilder} builder for use with + * database integrations (the specific database integrations themselves are provided by add-on libraries), + * and {@link com.launchdarkly.sdk.server.integrations.FileData} (for reading flags from a file in testing). + */ +package com.launchdarkly.sdk.server.integrations; diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/ClientContext.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/ClientContext.java new file mode 100644 index 000000000..6c43e21c1 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/ClientContext.java @@ -0,0 +1,31 @@ +package com.launchdarkly.sdk.server.interfaces; + +/** + * Context information provided by the {@link com.launchdarkly.sdk.server.LDClient} when creating components. + *

    + * This is passed as a parameter to {@link DataStoreFactory#createDataStore(ClientContext)}, etc. The + * actual implementation class may contain other properties that are only relevant to the built-in SDK + * components and are therefore not part of the public interface; this allows the SDK to add its own + * context information as needed without disturbing the public API. + * + * @since 5.0.0 + */ +public interface ClientContext { + /** + * The current {@link com.launchdarkly.sdk.server.LDClient} instance's SDK key. + * @return the SDK key + */ + public String getSdkKey(); + + /** + * True if {@link com.launchdarkly.sdk.server.LDConfig.Builder#offline(boolean)} was set to true. + * @return the offline status + */ + public boolean isOffline(); + + /** + * The configured networking properties that apply to all components. + * @return the HTTP configuration + */ + public HttpConfiguration getHttpConfiguration(); +} diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSource.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSource.java new file mode 100644 index 000000000..848420cb3 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSource.java @@ -0,0 +1,30 @@ +package com.launchdarkly.sdk.server.interfaces; + +import java.io.Closeable; +import java.io.IOException; +import java.util.concurrent.Future; + +/** + * Interface for an object that receives updates to feature flags, user segments, and anything + * else that might come from LaunchDarkly, and passes them to a {@link DataStore}. + * @since 5.0.0 + */ +public interface DataSource extends Closeable { + /** + * Starts the client. + * @return {@link Future}'s completion status indicates the client has been initialized. + */ + Future start(); + + /** + * Returns true once the client has been initialized and will never return false again. + * @return true if the client has been initialized + */ + boolean isInitialized(); + + /** + * 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; +} diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceFactory.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceFactory.java new file mode 100644 index 000000000..87f9c1482 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceFactory.java @@ -0,0 +1,19 @@ +package com.launchdarkly.sdk.server.interfaces; + +import com.launchdarkly.sdk.server.Components; + +/** + * Interface for a factory that creates some implementation of {@link DataSource}. + * @see Components + * @since 4.11.0 + */ +public interface DataSourceFactory { + /** + * Creates an implementation instance. + * + * @param context allows access to the client configuration + * @param dataStoreUpdates the component pushes data into the SDK via this interface + * @return an {@link DataSource} + */ + public DataSource createDataSource(ClientContext context, DataStoreUpdates dataStoreUpdates); +} diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStore.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStore.java new file mode 100644 index 000000000..83f8d34cd --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStore.java @@ -0,0 +1,81 @@ +package com.launchdarkly.sdk.server.interfaces; + +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; + +import java.io.Closeable; + +/** + * Interface for a data store that holds feature flags and related data received by the SDK. + *

    + * Ordinarily, the only implementations of this interface are the default in-memory implementation, + * which holds references to actual SDK data model objects, and the persistent data store + * implementation that delegates to a {@link PersistentDataStore}. + *

    + * All implementations must permit concurrent access and updates. + * + * @since 5.0.0 + */ +public interface DataStore extends Closeable { + /** + * Overwrites the store's contents with a set of items for each collection. + *

    + * All previous data should be discarded, regardless of versioning. + *

    + * The update should be done atomically. If it cannot be done atomically, then the store + * must first add or update each item in the same order that they are given in the input + * data, and then delete any previously stored items that were not in the input data. + * + * @param allData a list of {@link DataStoreTypes.DataKind} instances and their corresponding data sets + */ + void init(FullDataSet allData); + + /** + * Retrieves an item from the specified collection, if available. + *

    + * If the item has been deleted and the store contains a placeholder, it should + * return that placeholder rather than null. + * + * @param kind specifies which collection to use + * @param key the unique key of the item within that collection + * @return a versioned item that contains the stored data (or placeholder for deleted data); + * null if the key is unknown + */ + ItemDescriptor get(DataKind kind, String key); + + /** + * Retrieves all items from the specified collection. + *

    + * If the store contains placeholders for deleted items, it should include them in + * the results, not filter them out. + * + * @param kind specifies which collection to use + * @return a collection of key-value pairs; the ordering is not significant + */ + KeyedItems getAll(DataKind kind); + + /** + * Updates or inserts an item in the specified collection. For updates, the object will only be + * updated if the existing version is less than the new version. + *

    + * The SDK may pass an {@link ItemDescriptor} that contains a null, to represent a placeholder + * for a deleted item. In that case, assuming the version is greater than any existing version of + * that item, the store should retain that placeholder rather than simply not storing anything. + * + * @param kind specifies which collection to use + * @param key the unique key for the item within that collection + * @param item the item to insert or update + * @return true if the item was updated; false if it was not updated because the store contains + * an equal or greater version + */ + boolean upsert(DataKind kind, String key, ItemDescriptor item); + + /** + * Checks whether this store has been initialized with any data yet. + * + * @return true if the store contains data + */ + boolean isInitialized(); +} diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreFactory.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreFactory.java new file mode 100644 index 000000000..0ed2456ad --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreFactory.java @@ -0,0 +1,18 @@ +package com.launchdarkly.sdk.server.interfaces; + +import com.launchdarkly.sdk.server.Components; + +/** + * Interface for a factory that creates some implementation of {@link DataStore}. + * @see Components + * @since 4.11.0 + */ +public interface DataStoreFactory { + /** + * Creates an implementation instance. + * + * @param context allows access to the client configuration + * @return a {@link DataStore} + */ + DataStore createDataStore(ClientContext context); +} diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProvider.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProvider.java new file mode 100644 index 000000000..25b43b08b --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProvider.java @@ -0,0 +1,230 @@ +package com.launchdarkly.sdk.server.interfaces; + +import com.launchdarkly.sdk.server.integrations.PersistentDataStoreBuilder; + +import java.util.Objects; + +/** + * An interface for querying the status of a persistent data store. + *

    + * An implementation of this interface is returned by {@link com.launchdarkly.sdk.server.LDClientInterface#getDataStoreStatusProvider}. + * If the data store is a persistent data store, then these methods are implemented by the SDK; if it is a custom + * class that implements this interface, then these methods delegate to the corresponding methods of the class; + * if it is the default in-memory data store, then these methods do nothing and return null values. + * + * @since 5.0.0 + */ +public interface DataStoreStatusProvider { + /** + * Returns the current status of the store. + * + * @return the latest status, or null if not available + */ + public Status getStoreStatus(); + + /** + * Subscribes for notifications of status changes. + *

    + * Applications may wish to know if there is an outage in a persistent data store, since that could mean that + * flag evaluations are unable to get the flag data from the store (unless it is currently cached) and therefore + * might return default values. + *

    + * If the SDK receives an exception while trying to query or update the data store, then it notifies listeners + * that the store appears to be offline ({@link Status#isAvailable()} is false) and begins polling the store + * at intervals until a query succeeds. Once it succeeds, it notifies listeners again with {@link Status#isAvailable()} + * set to true. + *

    + * This method has no effect if the data store implementation does not support status tracking, such as if you + * are using the default in-memory store rather than a persistent store. + * + * @param listener the listener to add + * @return true if the listener was added, or was already registered; false if the data store does not support + * status tracking + */ + public boolean addStatusListener(StatusListener listener); + + /** + * Unsubscribes from notifications of status changes. + *

    + * This method has no effect if the data store implementation does not support status tracking, such as if you + * are using the default in-memory store rather than a persistent store. + * + * @param listener the listener to remove; if no such listener was added, this does nothing + */ + public void removeStatusListener(StatusListener listener); + + /** + * Queries the current cache statistics, if this is a persistent store with caching enabled. + *

    + * This method returns null if the data store implementation does not support cache statistics because it is + * not a persistent store, or because you did not enable cache monitoring with + * {@link PersistentDataStoreBuilder#recordCacheStats(boolean)}. + * + * @return a {@link CacheStats} instance; null if not applicable + */ + public CacheStats getCacheStats(); + + /** + * Information about a status change. + */ + public static final class Status { + private final boolean available; + private final boolean refreshNeeded; + + /** + * Creates an instance. + * @param available see {@link #isAvailable()} + * @param refreshNeeded see {@link #isRefreshNeeded()} + */ + public Status(boolean available, boolean refreshNeeded) { + this.available = available; + this.refreshNeeded = refreshNeeded; + } + + /** + * Returns true if the SDK believes the data store is now available. + *

    + * This property is normally true. If the SDK receives an exception while trying to query or update the data + * store, then it sets this property to false (notifying listeners, if any) and polls the store at intervals + * until a query succeeds. Once it succeeds, it sets the property back to true (again notifying listeners). + * + * @return true if store is available + */ + public boolean isAvailable() { + return available; + } + + /** + * Returns true if the store may be out of date due to a previous outage, so the SDK should attempt to refresh + * all feature flag data and rewrite it to the store. + *

    + * This property is not meaningful to application code. + * + * @return true if data should be rewritten + */ + public boolean isRefreshNeeded() { + return refreshNeeded; + } + } + + /** + * Interface for receiving status change notifications. + */ + public static interface StatusListener { + /** + * Called when the store status has changed. + * @param newStatus the new status + */ + public void dataStoreStatusChanged(Status newStatus); + } + + /** + * 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. + * + * @see DataStoreStatusProvider#getCacheStats() + * @see PersistentDataStoreBuilder#recordCacheStats(boolean) + * @since 4.12.0 + */ + 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 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) { + 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/sdk/server/interfaces/DataStoreTypes.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypes.java new file mode 100644 index 000000000..04fda02f9 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypes.java @@ -0,0 +1,296 @@ +package com.launchdarkly.sdk.server.interfaces; + +import com.google.common.collect.ImmutableList; + +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; + +/** + * Types that are used by the {@link DataStore} interface. + * + * @since 5.0.0 + */ +public abstract class DataStoreTypes { + /** + * Represents a separately namespaced collection of storable data items. + *

    + * The SDK passes instances of this type to the data store to specify whether it is referring to + * a feature flag, a user segment, etc. The data store implementation should not look for a + * specific data kind (such as feature flags), but should treat all data kinds generically. + */ + public static final class DataKind { + private final String name; + private final Function serializer; + private final Function deserializer; + + /** + * A case-sensitive alphabetic string that uniquely identifies this data kind. + *

    + * This is in effect a namespace for a collection of items of the same kind. Item keys must be + * unique within that namespace. Persistent data store implementations could use this string + * as part of a composite key or table name. + * + * @return the namespace string + */ + public String getName() { + return name; + } + + /** + * Returns a serialized representation of an item of this kind. + *

    + * The SDK uses this function to generate the data that is stored by a {@link PersistentDataStore}. + * Store implementations normally do not need to call it, except in a special case described in the + * documentation for {@link PersistentDataStore} regarding deleted item placeholders. + * + * @param item an {@link ItemDescriptor} describing the object to be serialized + * @return the serialized representation + * @exception ClassCastException if the object is of the wrong class + */ + public String serialize(ItemDescriptor item) { + return serializer.apply(item); + } + + /** + * Creates an item of this kind from its serialized representation. + *

    + * The SDK uses this function to translate data that is returned by a {@link PersistentDataStore}. + * Store implementations do not normally need to call it, but there is a special case described in + * the documentation for {@link PersistentDataStore}, regarding updates. + *

    + * The returned {@link ItemDescriptor} has two properties: {@link ItemDescriptor#getItem()}, which + * is the deserialized object or a {@code null} value for a deleted item placeholder, and + * {@link ItemDescriptor#getVersion()}, which provides the object's version number regardless of + * whether it is deleted or not. + * + * @param s the serialized representation + * @return an {@link ItemDescriptor} describing the deserialized object + */ + public ItemDescriptor deserialize(String s) { + return deserializer.apply(s); + } + + /** + * Constructs a DataKind instance. + * + * @param name the value for {@link #getName()} + * @param serializer the function to use for {@link #serialize(DataStoreTypes.ItemDescriptor)} + * @param deserializer the function to use for {@link #deserialize(String)} + */ + public DataKind(String name, Function serializer, Function deserializer) { + this.name = name; + this.serializer = serializer; + this.deserializer = deserializer; + } + + @Override + public String toString() { + return "DataKind(" + name + ")"; + } + } + + /** + * A versioned item (or placeholder) storable in a {@link DataStore}. + *

    + * This is used for data stores that directly store objects as-is, as the default in-memory + * store does. Items are typed as {@code Object}; the store should not know or care what the + * actual object is. + *

    + * For any given key within a {@link DataKind}, there can be either an existing item with a + * version, or a "tombstone" placeholder representing a deleted item (also with a version). + * Deleted item placeholders are used so that if an item is first updated with version N and + * then deleted with version N+1, but the SDK receives those changes out of order, version N + * will not overwrite the deletion. + *

    + * Persistent data stores use {@link SerializedItemDescriptor} instead. + */ + public static final class ItemDescriptor { + private final int version; + private final Object item; + + /** + * Returns the version number of this data, provided by the SDK. + * + * @return the version number + */ + public int getVersion() { + return version; + } + + /** + * Returns the data item, or null if this is a placeholder for a deleted item. + * + * @return an object or null + */ + public Object getItem() { + return item; + } + + /** + * Constructs a new instance. + * + * @param version the version number + * @param item an object or null + */ + public ItemDescriptor(int version, Object item) { + this.version = version; + this.item = item; + } + + /** + * Convenience method for constructing a deleted item placeholder. + * + * @param version the version number + * @return an ItemDescriptor + */ + public static ItemDescriptor deletedItem(int version) { + return new ItemDescriptor(version, null); + } + + @Override + public boolean equals(Object o) { + if (o instanceof ItemDescriptor) { + ItemDescriptor other = (ItemDescriptor)o; + return version == other.version && Objects.equals(item, other.item); + } + return false; + } + + @Override + public String toString() { + return "ItemDescriptor(" + version + "," + item + ")"; + } + } + + /** + * A versioned item (or placeholder) storable in a {@link PersistentDataStore}. + *

    + * This is equivalent to {@link ItemDescriptor}, but is used for persistent data stores. The + * SDK will convert each data item to and from its serialized string form; the persistent data + * store deals only with the serialized form. + */ + public static final class SerializedItemDescriptor { + private final int version; + private final boolean deleted; + private final String serializedItem; + + /** + * Returns the version number of this data, provided by the SDK. + * @return the version number + */ + public int getVersion() { + return version; + } + + /** + * Returns true if this is a placeholder (tombstone) for a deleted item. If so, + * {@link #getSerializedItem()} will still contain a string representing the deleted item, but + * the persistent store implementation has the option of not storing it if it can represent the + * placeholder in a more efficient way. + * + * @return true if this is a deleted item placeholder + */ + public boolean isDeleted() { + return deleted; + } + + /** + * Returns the data item's serialized representation. This will never be null; for a deleted item + * placeholder, it will contain a special value that can be stored if necessary (see {@link #isDeleted()}). + * + * @return the serialized data or null + */ + public String getSerializedItem() { + return serializedItem; + } + + /** + * Constructs a new instance. + * + * @param version the version number + * @param deleted true if this is a deleted item placeholder + * @param serializedItem the serialized data (will not be null) + */ + public SerializedItemDescriptor(int version, boolean deleted, String serializedItem) { + this.version = version; + this.deleted = deleted; + this.serializedItem = serializedItem; + } + + @Override + public boolean equals(Object o) { + if (o instanceof SerializedItemDescriptor) { + SerializedItemDescriptor other = (SerializedItemDescriptor)o; + return version == other.version && deleted == other.deleted && + Objects.equals(serializedItem, other.serializedItem); + } + return false; + } + + @Override + public String toString() { + return "SerializedItemDescriptor(" + version + "," + deleted + "," + serializedItem + ")"; + } + } + + /** + * Wrapper for a set of storable items being passed to a data store. + *

    + * Since the generic type signature for the data set is somewhat complicated (it is an ordered + * list of key-value pairs where each key is a {@link DataKind}, and each value is another ordered + * list of key-value pairs for the individual data items), this type simplifies the declaration of + * data store methods and makes it easier to see what the type represents. + * + * @param will be {@link ItemDescriptor} or {@link SerializedItemDescriptor} + */ + public static final class FullDataSet { + private final Iterable>> data; + + /** + * Returns the wrapped data set. + * + * @return an enumeration of key-value pairs; may be empty, but will not be null + */ + public Iterable>> getData() { + return data; + } + + /** + * Constructs a new instance. + * + * @param data the data set + */ + public FullDataSet(Iterable>> data) { + this.data = data == null ? ImmutableList.of(): data; + } + } + + /** + * Wrapper for a set of storable items being passed to a data store, within a single + * {@link DataKind}. + * + * @param will be {@link ItemDescriptor} or {@link SerializedItemDescriptor} + */ + public static final class KeyedItems { + private final Iterable> items; + + /** + * Returns the wrapped data set. + * + * @return an enumeration of key-value pairs; may be empty, but will not be null + */ + public Iterable> getItems() { + return items; + } + + /** + * Constructs a new instance. + * + * @param items the data set + */ + public KeyedItems(Iterable> items) { + this.items = items == null ? ImmutableList.of() : items; + } + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreUpdates.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreUpdates.java new file mode 100644 index 000000000..409369833 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreUpdates.java @@ -0,0 +1,55 @@ +package com.launchdarkly.sdk.server.interfaces; + +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; + +/** + * Interface that a data source implementation will use to push data into the underlying + * data store. + *

    + * This layer of indirection allows the SDK to perform any other necessary operations that must + * happen when data is updated, by providing its own implementation of {@link DataStoreUpdates}. + * + * @since 5.0.0 + */ +public interface DataStoreUpdates { + /** + * Overwrites the store's contents with a set of items for each collection. + *

    + * All previous data should be discarded, regardless of versioning. + *

    + * The update should be done atomically. If it cannot be done atomically, then the store + * must first add or update each item in the same order that they are given in the input + * data, and then delete any previously stored items that were not in the input data. + * + * @param allData a list of {@link DataStoreTypes.DataKind} instances and their corresponding data sets + */ + void init(FullDataSet allData); + + /** + * Updates or inserts an item in the specified collection. For updates, the object will only be + * updated if the existing version is less than the new version. + *

    + * The SDK may pass an {@link ItemDescriptor} that contains a null, to represent a placeholder + * for a deleted item. In that case, assuming the version is greater than any existing version of + * that item, the store should retain that placeholder rather than simply not storing anything. + * + * @param kind specifies which collection to use + * @param key the unique key for the item within that collection + * @param item the item to insert or update + */ + void upsert(DataKind kind, String key, ItemDescriptor item); + + /** + * Returns an object that provides status tracking for the data store, if applicable. + *

    + * For data stores that do not support status tracking (the in-memory store, or a custom implementation + * that is not based on the SDK's usual persistent data store mechanism), it returns a stub + * implementation that returns null from {@link DataStoreStatusProvider#getStoreStatus()} and + * false from {@link DataStoreStatusProvider#addStatusListener(com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider.StatusListener)}. + * + * @return a {@link DataStoreStatusProvider} + */ + DataStoreStatusProvider getStatusProvider(); +} diff --git a/src/main/java/com/launchdarkly/client/interfaces/DiagnosticDescription.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DiagnosticDescription.java similarity index 70% rename from src/main/java/com/launchdarkly/client/interfaces/DiagnosticDescription.java rename to src/main/java/com/launchdarkly/sdk/server/interfaces/DiagnosticDescription.java index 5c8034163..5cbc0c832 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/DiagnosticDescription.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DiagnosticDescription.java @@ -1,15 +1,15 @@ -package com.launchdarkly.client.interfaces; +package com.launchdarkly.sdk.server.interfaces; -import com.launchdarkly.client.LDConfig; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.LDConfig; /** * 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 + * Any class that implements {@link com.launchdarkly.sdk.server.interfaces.DataStoreFactory}, + * {@link com.launchdarkly.sdk.server.interfaces.DataSourceFactory}, {@link com.launchdarkly.sdk.server.interfaces.EventProcessorFactory}, + * or {@link com.launchdarkly.sdk.server.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()}. *

    diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/Event.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/Event.java new file mode 100644 index 000000000..0cba86ac8 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/Event.java @@ -0,0 +1,248 @@ +package com.launchdarkly.sdk.server.interfaces; + +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.LDClientInterface; + +/** + * Base class for all analytics events that are generated by the client. Also defines all of its own subclasses. + * + * Applications do not need to reference these types directly. They are used internally in analytics event + * processing, and are visible only to support writing a custom implementation of {@link EventProcessor} if + * desired. + */ +public class Event { + private final long creationDate; + private final LDUser user; + + /** + * Base event constructor. + * @param creationDate the timestamp in milliseconds + * @param user the user associated with the event + */ + public Event(long creationDate, LDUser user) { + this.creationDate = creationDate; + this.user = user; + } + + /** + * The event timestamp. + * @return the timestamp in milliseconds + */ + public long getCreationDate() { + return creationDate; + } + + /** + * The user associated with the event. + * @return the user object + */ + public LDUser getUser() { + return user; + } + + /** + * A custom event created with {@link LDClientInterface#track(String, LDUser)} or one of its overloads. + */ + public static final class Custom extends Event { + private final String key; + private final LDValue data; + private 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) { + super(timestamp, user); + this.key = key; + this.data = data == null ? LDValue.ofNull() : data; + this.metricValue = metricValue; + } + + /** + * The custom event key. + * @return the event key + */ + public String getKey() { + return key; + } + + /** + * The custom data associated with the event, if any. + * @return the event data (null is equivalent to {@link LDValue#ofNull()}) + */ + public LDValue getData() { + return data; + } + + /** + * The numeric metric value associated with the event, if any. + * @return the metric value or null + */ + public Double getMetricValue() { + return metricValue; + } + } + + /** + * 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 { + private final String key; + private final int variation; + private final LDValue value; + private final LDValue defaultVal; + private final int version; + private final String prereqOf; + private final boolean trackEvents; + private final long debugEventsUntilDate; + private final EvaluationReason reason; + private 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 -1 if the flag was not found + * @param variation the result variation, or -1 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, int version, int variation, LDValue value, + LDValue defaultVal, EvaluationReason reason, String prereqOf, boolean trackEvents, long debugEventsUntilDate, boolean debug) { + super(timestamp, user); + this.key = key; + this.version = version; + this.variation = variation; + this.value = value; + this.defaultVal = defaultVal; + this.prereqOf = prereqOf; + this.trackEvents = trackEvents; + this.debugEventsUntilDate = debugEventsUntilDate; + this.reason = reason; + this.debug = debug; + } + + /** + * The key of the feature flag that was evaluated. + * @return the flag key + */ + public String getKey() { + return key; + } + + /** + * The index of the selected flag variation, or -1 if the application default value was used. + * @return zero-based index of the variation, or -1 + */ + public int getVariation() { + return variation; + } + + /** + * The value of the selected flag variation. + * @return the value + */ + public LDValue getValue() { + return value; + } + + /** + * The application default value used in the evaluation. + * @return the application default + */ + public LDValue getDefaultVal() { + return defaultVal; + } + + /** + * The version of the feature flag that was evaluated, or -1 if the flag was not found. + * @return the flag version or null + */ + public int getVersion() { + return version; + } + + /** + * If this flag was evaluated as a prerequisite for another flag, the key of the other flag. + * @return a flag key or null + */ + public String getPrereqOf() { + return prereqOf; + } + + /** + * True if full event tracking is enabled for this flag. + * @return true if full event tracking is on + */ + public boolean isTrackEvents() { + return trackEvents; + } + + /** + * If debugging is enabled for this flag, the Unix millisecond time at which to stop debugging. + * @return a timestamp or zero + */ + public long getDebugEventsUntilDate() { + return debugEventsUntilDate; + } + + /** + * The {@link EvaluationReason} for this evaluation, or null if the reason was not requested for this evaluation. + * @return a reason object or null + */ + public EvaluationReason getReason() { + return reason; + } + + /** + * True if this event was generated due to debugging being enabled. + * @return true if this is a debug event + */ + public boolean isDebug() { + return debug; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/EventProcessor.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/EventProcessor.java similarity index 51% rename from src/main/java/com/launchdarkly/client/EventProcessor.java rename to src/main/java/com/launchdarkly/sdk/server/interfaces/EventProcessor.java index 2b756cda6..656964257 100644 --- a/src/main/java/com/launchdarkly/client/EventProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/EventProcessor.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server.interfaces; import java.io.Closeable; @@ -20,26 +20,4 @@ public interface EventProcessor extends Closeable { * any events that were not yet delivered prior to shutting down. */ void flush(); - - /** - * 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) { - } - - @Override - public void flush() { - } - - @Override - public void close() { - } - } } diff --git a/src/main/java/com/launchdarkly/client/EventProcessorFactory.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/EventProcessorFactory.java similarity index 54% rename from src/main/java/com/launchdarkly/client/EventProcessorFactory.java rename to src/main/java/com/launchdarkly/sdk/server/interfaces/EventProcessorFactory.java index 3d76b5aad..afc247770 100644 --- a/src/main/java/com/launchdarkly/client/EventProcessorFactory.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/EventProcessorFactory.java @@ -1,4 +1,6 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server.interfaces; + +import com.launchdarkly.sdk.server.Components; /** * Interface for a factory that creates some implementation of {@link EventProcessor}. @@ -8,9 +10,9 @@ public interface EventProcessorFactory { /** * Creates an implementation instance. - * @param sdkKey the SDK key for your LaunchDarkly environment - * @param config the LaunchDarkly configuration + * + * @param context allows access to the client configuration * @return an {@link EventProcessor} */ - EventProcessor createEventProcessor(String sdkKey, LDConfig config); + EventProcessor createEventProcessor(ClientContext context); } diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagChangeEvent.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagChangeEvent.java new file mode 100644 index 000000000..b72a49c4f --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagChangeEvent.java @@ -0,0 +1,38 @@ +package com.launchdarkly.sdk.server.interfaces; + +/** + * Parameter class used with {@link FlagChangeListener}. + *

    + * This is not an analytics event to be sent to LaunchDarkly; it is a notification to the application. + * + * @since 5.0.0 + * @see FlagChangeListener + * @see FlagValueChangeEvent + * @see com.launchdarkly.sdk.server.LDClientInterface#registerFlagChangeListener(FlagChangeListener) + * @see com.launchdarkly.sdk.server.LDClientInterface#unregisterFlagChangeListener(FlagChangeListener) + */ +public class FlagChangeEvent { + private final String key; + + /** + * Constructs a new instance. + * + * @param key the feature flag key + */ + public FlagChangeEvent(String key) { + this.key = key; + } + + /** + * Returns the key of the feature flag whose configuration has changed. + *

    + * The specified flag may have been modified directly, or this may be an indirect change due to a change + * in some other flag that is a prerequisite for this flag, or a user segment that is referenced in the + * flag's rules. + * + * @return the flag key + */ + public String getKey() { + return key; + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagChangeListener.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagChangeListener.java new file mode 100644 index 000000000..42f8093cd --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagChangeListener.java @@ -0,0 +1,29 @@ +package com.launchdarkly.sdk.server.interfaces; + +/** + * An event listener that is notified when a feature flag's configuration has changed. + *

    + * As described in {@link com.launchdarkly.sdk.server.LDClientInterface#registerFlagChangeListener(FlagChangeListener)}, + * this notification does not mean that the flag now returns a different value for any particular user, + * only that it may do so. LaunchDarkly feature flags can be configured to return a single value + * for all users, or to have complex targeting behavior. To know what effect the change would have for + * any given set of user properties, you would need to re-evaluate the flag by calling one of the + * {@code variation} methods on the client. + *

    + * In simple use cases where you know that the flag configuration does not vary per user, or where you + * know ahead of time what user properties you will evaluate the flag with, it may be more convenient + * to use {@link FlagValueChangeListener}. + * + * @since 5.0.0 + * @see FlagValueChangeListener + * @see com.launchdarkly.sdk.server.LDClientInterface#registerFlagChangeListener(FlagChangeListener) + * @see com.launchdarkly.sdk.server.LDClientInterface#unregisterFlagChangeListener(FlagChangeListener) + */ +public interface FlagChangeListener { + /** + * The SDK calls this method when a feature flag's configuration has changed in some way. + * + * @param event the event parameters + */ + void onFlagChange(FlagChangeEvent event); +} diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeEvent.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeEvent.java new file mode 100644 index 000000000..81767de46 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeEvent.java @@ -0,0 +1,64 @@ +package com.launchdarkly.sdk.server.interfaces; + +import com.launchdarkly.sdk.LDValue; + +/** + * Parameter class used with {@link FlagValueChangeListener}. + *

    + * This is not an analytics event to be sent to LaunchDarkly; it is a notification to the application. + * + * @since 5.0.0 + * @see FlagValueChangeListener + * @see com.launchdarkly.sdk.server.LDClientInterface#registerFlagChangeListener(FlagChangeListener) + * @see com.launchdarkly.sdk.server.LDClientInterface#unregisterFlagChangeListener(FlagChangeListener) + * @see com.launchdarkly.sdk.server.Components#flagValueMonitoringListener(com.launchdarkly.sdk.server.LDClientInterface, String, com.launchdarkly.sdk.LDUser, FlagValueChangeListener) + */ +public class FlagValueChangeEvent extends FlagChangeEvent { + private final LDValue oldValue; + private final LDValue newValue; + + /** + * Constructs a new instance. + * + * @param key the feature flag key + * @param oldValue the previous flag value + * @param newValue the new flag value + */ + public FlagValueChangeEvent(String key, LDValue oldValue, LDValue newValue) { + super(key); + this.oldValue = LDValue.normalize(oldValue); + this.newValue = LDValue.normalize(newValue); + } + + /** + * Returns the last known value of the flag for the specified user prior to the update. + *

    + * Since flag values can be of any JSON data type, this is represented as {@link LDValue}. That class + * has methods for converting to a primitive Java type such {@link LDValue#booleanValue()}. + *

    + * If the flag did not exist before or could not be evaluated, this will be {@link LDValue#ofNull()}. + * Note that there is no application default value parameter as there is for the {@code variation} + * methods; it is up to your code to substitute whatever fallback value is appropriate. + * + * @return the previous flag value + */ + public LDValue getOldValue() { + return oldValue; + } + + /** + * Returns the new value of the flag for the specified user. + *

    + * Since flag values can be of any JSON data type, this is represented as {@link LDValue}. That class + * has methods for converting to a primitive Java type such {@link LDValue#booleanValue()}. + *

    + * If the flag was deleted or could not be evaluated, this will be {@link LDValue#ofNull()}. + * Note that there is no application default value parameter as there is for the {@code variation} + * methods; it is up to your code to substitute whatever fallback value is appropriate. + * + * @return the new flag value + */ + public LDValue getNewValue() { + return newValue; + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeListener.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeListener.java new file mode 100644 index 000000000..d5390828c --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeListener.java @@ -0,0 +1,42 @@ +package com.launchdarkly.sdk.server.interfaces; + +/** + * An event listener that is notified when a feature flag's value has changed for a specific user. + *

    + * Use this in conjunction with + * {@link com.launchdarkly.sdk.server.Components#flagValueMonitoringListener(com.launchdarkly.sdk.server.LDClientInterface, String, com.launchdarkly.sdk.LDUser, FlagValueChangeListener)} + * if you want the client to re-evaluate a flag for a specific set of user properties whenever + * the flag's configuration has changed, and notify you only if the new value is different from the old + * value. The listener will not be notified if the flag's configuration is changed in some way that does + * not affect its value for that user. + * + *

    
    + *     String flagKey = "my-important-flag";
    + *     LDUser userForFlagEvaluation = new LDUser("user-key-for-global-flag-state");
    + *     FlagValueChangeListener listenForNewValue = event -> {
    + *         if (event.getKey().equals(flagKey)) {
    + *             doSomethingWithNewValue(event.getNewValue().booleanValue());
    + *         }
    + *     };
    + *     client.registerFlagChangeListener(Components.flagValueMonitoringListener(
    + *         client, flagKey, userForFlagEvaluation, listenForNewValue));
    + * 
    + * + * In the above example, the value provided in {@code event.getNewValue()} is the result of calling + * {@code client.jsonValueVariation(flagKey, userForFlagEvaluation, LDValue.ofNull())} after the flag + * has changed. + * + * @since 5.0.0 + * @see FlagChangeListener + * @see com.launchdarkly.sdk.server.LDClientInterface#registerFlagChangeListener(FlagChangeListener) + * @see com.launchdarkly.sdk.server.LDClientInterface#unregisterFlagChangeListener(FlagChangeListener) + * @see com.launchdarkly.sdk.server.Components#flagValueMonitoringListener(com.launchdarkly.sdk.server.LDClientInterface, String, com.launchdarkly.sdk.LDUser, FlagValueChangeListener) + */ +public interface FlagValueChangeListener { + /** + * The SDK calls this method when a feature flag's value has changed with regard to the specified user. + * + * @param event the event parameters + */ + void onFlagValueChange(FlagValueChangeEvent event); +} diff --git a/src/main/java/com/launchdarkly/client/interfaces/HttpAuthentication.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/HttpAuthentication.java similarity index 96% rename from src/main/java/com/launchdarkly/client/interfaces/HttpAuthentication.java rename to src/main/java/com/launchdarkly/sdk/server/interfaces/HttpAuthentication.java index 879a201ec..f0bf34d3a 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/HttpAuthentication.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/HttpAuthentication.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client.interfaces; +package com.launchdarkly.sdk.server.interfaces; /** * Represents a supported method of HTTP authentication, including proxy authentication. diff --git a/src/main/java/com/launchdarkly/client/interfaces/HttpConfiguration.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/HttpConfiguration.java similarity index 82% rename from src/main/java/com/launchdarkly/client/interfaces/HttpConfiguration.java rename to src/main/java/com/launchdarkly/sdk/server/interfaces/HttpConfiguration.java index 9d0cb31df..abda924f5 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/HttpConfiguration.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/HttpConfiguration.java @@ -1,8 +1,9 @@ -package com.launchdarkly.client.interfaces; +package com.launchdarkly.sdk.server.interfaces; -import com.launchdarkly.client.integrations.HttpConfigurationBuilder; +import com.launchdarkly.sdk.server.integrations.HttpConfigurationBuilder; import java.net.Proxy; +import java.time.Duration; import javax.net.ssl.SSLSocketFactory; import javax.net.ssl.X509TrustManager; @@ -19,9 +20,9 @@ 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 + * @return the connection timeout; must not be null */ - int getConnectTimeoutMillis(); + Duration getConnectTimeout(); /** * The proxy configuration, if any. @@ -40,12 +41,12 @@ public interface HttpConfiguration { /** * 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 + * used by {@link com.launchdarkly.sdk.server.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 + * @return the socket timeout; must not be null */ - int getSocketTimeoutMillis(); + Duration getSocketTimeout(); /** * The configured socket factory for secure connections. diff --git a/src/main/java/com/launchdarkly/client/interfaces/HttpConfigurationFactory.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/HttpConfigurationFactory.java similarity index 59% rename from src/main/java/com/launchdarkly/client/interfaces/HttpConfigurationFactory.java rename to src/main/java/com/launchdarkly/sdk/server/interfaces/HttpConfigurationFactory.java index ade4a5d48..58e912a61 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/HttpConfigurationFactory.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/HttpConfigurationFactory.java @@ -1,10 +1,10 @@ -package com.launchdarkly.client.interfaces; +package com.launchdarkly.sdk.server.interfaces; /** * Interface for a factory that creates an {@link HttpConfiguration}. * - * @see com.launchdarkly.client.Components#httpConfiguration() - * @see com.launchdarkly.client.LDConfig.Builder#http(HttpConfigurationFactory) + * @see com.launchdarkly.sdk.server.Components#httpConfiguration() + * @see com.launchdarkly.sdk.server.LDConfig.Builder#http(HttpConfigurationFactory) * @since 4.13.0 */ public interface HttpConfigurationFactory { diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/PersistentDataStore.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/PersistentDataStore.java new file mode 100644 index 000000000..410baa062 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/PersistentDataStore.java @@ -0,0 +1,138 @@ +package com.launchdarkly.sdk.server.interfaces; + +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.SerializedItemDescriptor; + +import java.io.Closeable; + +/** + * Interface for a data store that holds feature flags and related data in a serialized form. + *

    + * This interface should be used for database integrations, or any other data store + * implementation that stores data in some external service. The SDK will take care of + * converting between its own internal data model and a serialized string form; the data + * store interacts only with the serialized form. The SDK will also provide its own caching + * layer on top of the persistent data store; the data store implementation should not + * provide caching, but simply do every query or update that the SDK tells it to do. + *

    + * Implementations must be thread-safe. + *

    + * Conceptually, each item in the store is a {@link SerializedItemDescriptor} which always has + * a version number, and can represent either a serialized object or a placeholder (tombstone) + * for a deleted item. There are two approaches a persistent store implementation can use for + * persisting this data: + * + * 1. Preferably, it should store the version number and the {@link SerializedItemDescriptor#isDeleted()} + * state separately so that the object does not need to be fully deserialized to read them. In + * this case, deleted item placeholders can ignore the value of {@link SerializedItemDescriptor#getSerializedItem()} + * on writes and can set it to null on reads. The store should never call {@link DataKind#deserialize(String)} + * or {@link DataKind#serialize(DataStoreTypes.ItemDescriptor)}. + * + * 2. If that isn't possible, then the store should simply persist the exact string from + * {@link SerializedItemDescriptor#getSerializedItem()} on writes, and return the persisted + * string on reads (returning zero for the version and false for {@link SerializedItemDescriptor#isDeleted()}). + * The string is guaranteed to provide the SDK with enough information to infer the version and + * the deleted state. On updates, the store must call {@link DataKind#deserialize(String)} in + * order to inspect the version number of the existing item if any. + * + * @since 5.0.0 + */ +public interface PersistentDataStore extends Closeable { + /** + * Overwrites the store's contents with a set of items for each collection. + *

    + * All previous data should be discarded, regardless of versioning. + *

    + * The update should be done atomically. If it cannot be done atomically, then the store + * must first add or update each item in the same order that they are given in the input + * data, and then delete any previously stored items that were not in the input data. + * + * @param allData a list of {@link DataStoreTypes.DataKind} instances and their corresponding data sets + */ + void init(FullDataSet allData); + + /** + * Retrieves an item from the specified collection, if available. + *

    + * If the key is not known at all, the method should return null. Otherwise, it should return + * a {@link SerializedItemDescriptor} as follows: + *

    + * 1. If the version number and deletion state can be determined without fully deserializing + * the item, then the store should set those properties in the {@link SerializedItemDescriptor} + * (and can set {@link SerializedItemDescriptor#getSerializedItem()} to null for deleted items). + *

    + * 2. Otherwise, it should simply set {@link SerializedItemDescriptor#getSerializedItem()} to + * the exact string that was persisted, and can leave the other properties as zero/false. See + * comments on {@link PersistentDataStore} for more about this. + * + * @param kind specifies which collection to use + * @param key the unique key of the item within that collection + * @return a versioned item that contains the stored data (or placeholder for deleted data); + * null if the key is unknown + */ + SerializedItemDescriptor get(DataKind kind, String key); + + /** + * Retrieves all items from the specified collection. + *

    + * If the store contains placeholders for deleted items, it should include them in the results, + * not filter them out. See {@link #get(DataStoreTypes.DataKind, String)} for how to set the properties of the + * {@link SerializedItemDescriptor} for each item. + * + * @param kind specifies which collection to use + * @return a collection of key-value pairs; the ordering is not significant + */ + KeyedItems getAll(DataKind kind); + + /** + * Updates or inserts an item in the specified collection. + *

    + * If the given key already exists in that collection, the store must check the version number + * of the existing item (even if it is a deleted item placeholder); if that version is greater + * than or equal to the version of the new item, the update fails and the method returns false. + * If the store is not able to determine the version number of an existing item without fully + * deserializing the existing item, then it is allowed to call {@link DataKind#deserialize(String)} + * for that purpose. + *

    + * If the item's {@link SerializedItemDescriptor#isDeleted()} method returns true, this is a + * deleted item placeholder. The store must persist this, rather than simply removing the key + * from the store. The SDK will provide a string in {@link SerializedItemDescriptor#getSerializedItem()} + * which the store can persist for this purpose; or, if the store is capable of persisting the + * version number and deleted state without storing anything else, it should do so. + * + * @param kind specifies which collection to use + * @param key the unique key for the item within that collection + * @param item the item to insert or update + * @return true if the item was updated; false if it was not updated because the store contains + * an equal or greater version + */ + boolean upsert(DataKind kind, String key, SerializedItemDescriptor item); + + /** + * Returns true if this store has been initialized. + *

    + * In a shared data store, the implementation should be able to detect this state even if + * {@link #init} was called in a different process, i.e. it must query the underlying + * data store in some way. The method does not need to worry about caching this value; the SDK + * will call it rarely. + * + * @return true if the store has been initialized + */ + boolean isInitialized(); + + /** + * Tests whether the data store seems to be functioning normally. + *

    + * This should not be a detailed test of different kinds of operations, but just the smallest possible + * operation to determine whether (for instance) we can reach the database. + *

    + * Whenever one of the store's other methods throws an exception, the SDK will assume that it may have + * become unavailable (e.g. the database connection was lost). The SDK will then call + * {@link #isStoreAvailable()} at intervals until it returns true. + * + * @return true if the underlying data store is reachable + */ + public boolean isStoreAvailable(); +} diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/PersistentDataStoreFactory.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/PersistentDataStoreFactory.java new file mode 100644 index 000000000..f86b7b788 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/PersistentDataStoreFactory.java @@ -0,0 +1,23 @@ +package com.launchdarkly.sdk.server.interfaces; + +import com.launchdarkly.sdk.server.integrations.PersistentDataStoreBuilder; + +/** + * Interface for a factory that creates some implementation of a persistent data store. + *

    + * This interface is implemented by database integrations. Usage is described in + * {@link com.launchdarkly.sdk.server.Components#persistentDataStore}. + * + * @see com.launchdarkly.sdk.server.Components + * @since 4.12.0 + */ +public interface PersistentDataStoreFactory { + /** + * Called internally from {@link PersistentDataStoreBuilder} to create the implementation object + * for the specific type of data store. + * + * @param context allows access to the client configuration + * @return the implementation object + */ + PersistentDataStore createPersistentDataStore(ClientContext context); +} diff --git a/src/main/java/com/launchdarkly/client/interfaces/SerializationException.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/SerializationException.java similarity index 94% rename from src/main/java/com/launchdarkly/client/interfaces/SerializationException.java rename to src/main/java/com/launchdarkly/sdk/server/interfaces/SerializationException.java index 0473c991f..89256a6fd 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/SerializationException.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/SerializationException.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client.interfaces; +package com.launchdarkly.sdk.server.interfaces; /** * General exception class for all errors in serializing or deserializing JSON. diff --git a/src/main/java/com/launchdarkly/client/interfaces/package-info.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/package-info.java similarity index 83% rename from src/main/java/com/launchdarkly/client/interfaces/package-info.java rename to src/main/java/com/launchdarkly/sdk/server/interfaces/package-info.java index d798dc8f0..5d38c8803 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/package-info.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/package-info.java @@ -4,4 +4,4 @@ * 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; +package com.launchdarkly.sdk.server.interfaces; diff --git a/src/main/java/com/launchdarkly/sdk/server/package-info.java b/src/main/java/com/launchdarkly/sdk/server/package-info.java new file mode 100644 index 000000000..501216981 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/package-info.java @@ -0,0 +1,10 @@ +/** + * Main package for the LaunchDarkly Server-Side Java SDK, containing the client and configuration classes. + *

    + * You will most often use {@link com.launchdarkly.sdk.server.LDClient} (the SDK client) and + * {@link com.launchdarkly.sdk.server.LDConfig} (configuration options for the client). + *

    + * Other commonly used types such as {@link com.launchdarkly.sdk.LDUser} are in the {@code com.launchdarkly.sdk} + * package, since those are not server-side-specific and are shared with the LaunchDarkly Android SDK. + */ +package com.launchdarkly.sdk.server; diff --git a/src/test/java/com/launchdarkly/client/DataStoreTestTypes.java b/src/test/java/com/launchdarkly/client/DataStoreTestTypes.java deleted file mode 100644 index bd0595def..000000000 --- a/src/test/java/com/launchdarkly/client/DataStoreTestTypes.java +++ /dev/null @@ -1,117 +0,0 @@ -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/DeprecatedRedisFeatureStoreTest.java b/src/test/java/com/launchdarkly/client/DeprecatedRedisFeatureStoreTest.java deleted file mode 100644 index 058b252d0..000000000 --- a/src/test/java/com/launchdarkly/client/DeprecatedRedisFeatureStoreTest.java +++ /dev/null @@ -1,80 +0,0 @@ -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; - -@SuppressWarnings({ "javadoc", "deprecation" }) -public class DeprecatedRedisFeatureStoreTest extends FeatureStoreDatabaseTestBase { - - private static final URI REDIS_URI = URI.create("redis://localhost:6379"); - - public DeprecatedRedisFeatureStoreTest(boolean cached) { - super(cached); - } - - @BeforeClass - public static void maybeSkipDatabaseTests() { - String skipParam = System.getenv("LD_SKIP_DATABASE_TESTS"); - assumeTrue(skipParam == null || skipParam.equals("")); - } - - @Override - protected RedisFeatureStore makeStore() { - 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).caching(FeatureStoreCacheConfig.disabled()).prefix(prefix).build(); - } - - @Override - protected void clearAllData() { - try (Jedis client = new Jedis("localhost")) { - 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/EvaluationReasonTest.java b/src/test/java/com/launchdarkly/client/EvaluationReasonTest.java deleted file mode 100644 index f1b409294..000000000 --- a/src/test/java/com/launchdarkly/client/EvaluationReasonTest.java +++ /dev/null @@ -1,200 +0,0 @@ -package com.launchdarkly.client; - -import com.google.gson.Gson; -import com.google.gson.JsonElement; - -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(); - String json = "{\"kind\":\"OFF\"}"; - 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() { - 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 testPrerequisiteFailedSerialization() { - EvaluationReason reason = EvaluationReason.prerequisiteFailed("key"); - String json = "{\"kind\":\"PREREQUISITE_FAILED\",\"prerequisiteKey\":\"key\"}"; - assertJsonEqual(json, gson.toJson(reason)); - assertEquals("PREREQUISITE_FAILED(key)", reason.toString()); - } - - @Test - public void testErrorSerialization() { - 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,java.lang.Exception: something happened)", reason.toString()); - } - - @Test - public void errorInstancesAreReused() { - for (EvaluationReason.ErrorKind errorKind: EvaluationReason.ErrorKind.values()) { - EvaluationReason r0 = EvaluationReason.error(errorKind); - assertEquals(errorKind, r0.getErrorKind()); - EvaluationReason 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); - assertEquals(expected, actual); - } -} diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java deleted file mode 100644 index c8c072605..000000000 --- a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java +++ /dev/null @@ -1,679 +0,0 @@ -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; - -import org.junit.Before; -import org.junit.Test; - -import java.util.Arrays; - -import static com.launchdarkly.client.EvaluationDetail.fromValue; -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; -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 { - - private static LDUser BASE_USER = new LDUser.Builder("x").build(); - - private FeatureStore featureStore; - - @Before - public void before() { - featureStore = new InMemoryFeatureStore(); - } - - @Test - public void flagReturnsOffVariationIfFlagIsOff() throws Exception { - FeatureFlag f = new FeatureFlagBuilder("feature") - .on(false) - .offVariation(1) - .fallthrough(fallthroughVariation(0)) - .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) - .build(); - FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - - assertEquals(fromValue(LDValue.of("off"), 1, EvaluationReason.off()), result.getDetails()); - assertEquals(0, result.getPrerequisiteEvents().size()); - } - - @Test - public void flagReturnsNullIfFlagIsOffAndOffVariationIsUnspecified() throws Exception { - FeatureFlag f = new FeatureFlagBuilder("feature") - .on(false) - .fallthrough(fallthroughVariation(0)) - .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) - .build(); - FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - - assertEquals(fromValue(LDValue.ofNull(), null, EvaluationReason.off()), result.getDetails()); - assertEquals(0, result.getPrerequisiteEvents().size()); - } - - @Test - public void flagReturnsErrorIfFlagIsOffAndOffVariationIsTooHigh() throws Exception { - FeatureFlag f = new FeatureFlagBuilder("feature") - .on(false) - .offVariation(999) - .fallthrough(fallthroughVariation(0)) - .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("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(LDValue.of("fall"), LDValue.of("off"), LDValue.of("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 flagReturnsFallthroughIfFlagIsOnAndThereAreNoRules() throws Exception { - FeatureFlag f = new FeatureFlagBuilder("feature") - .on(true) - .offVariation(1) - .fallthrough(fallthroughVariation(0)) - .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) - .build(); - FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - - assertEquals(fromValue(LDValue.of("fall"), 0, EvaluationReason.fallthrough()), 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(LDValue.of("fall"), LDValue.of("off"), LDValue.of("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(LDValue.of("fall"), LDValue.of("off"), LDValue.of("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(LDValue.of("fall"), LDValue.of("off"), LDValue.of("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(LDValue.of("fall"), LDValue.of("off"), LDValue.of("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") - .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 result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - - EvaluationReason expectedReason = EvaluationReason.prerequisiteFailed("feature1"); - assertEquals(fromValue(LDValue.of("off"), 1, expectedReason), 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(LDValue.of("fall"), LDValue.of("off"), LDValue.of("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(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(fromValue(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(LDValue.of("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") - .on(true) - .prerequisites(Arrays.asList(new Prerequisite("feature1", 1))) - .fallthrough(fallthroughVariation(0)) - .offVariation(1) - .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) - .version(1) - .build(); - FeatureFlag f1 = new FeatureFlagBuilder("feature1") - .on(true) - .fallthrough(fallthroughVariation(0)) - .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(fromValue(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(LDValue.of("nogo"), event.value); - assertEquals(f1.getVersion(), event.version.intValue()); - 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") - .on(true) - .prerequisites(Arrays.asList(new Prerequisite("feature1", 1))) - .fallthrough(fallthroughVariation(0)) - .offVariation(1) - .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) - .version(1) - .build(); - FeatureFlag f1 = new FeatureFlagBuilder("feature1") - .on(true) - .fallthrough(fallthroughVariation(1)) - .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(fromValue(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(LDValue.of("go"), event.value); - assertEquals(f1.getVersion(), event.version.intValue()); - assertEquals(f0.getKey(), event.prereqOf); - } - - @Test - public void multipleLevelsOfPrerequisitesProduceMultipleEvents() throws Exception { - FeatureFlag f0 = new FeatureFlagBuilder("feature0") - .on(true) - .prerequisites(Arrays.asList(new Prerequisite("feature1", 1))) - .fallthrough(fallthroughVariation(0)) - .offVariation(1) - .variations(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(LDValue.of("nogo"), LDValue.of("go")) - .version(2) - .build(); - FeatureFlag f2 = new FeatureFlagBuilder("feature2") - .on(true) - .fallthrough(fallthroughVariation(1)) - .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(fromValue(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(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(LDValue.of("go"), event1.value); - assertEquals(f1.getVersion(), event1.version.intValue()); - assertEquals(f0.getKey(), event1.prereqOf); - } - - @Test - public void flagMatchesUserFromTargets() throws Exception { - FeatureFlag f = new FeatureFlagBuilder("feature") - .on(true) - .targets(Arrays.asList(new Target(ImmutableSet.of("whoever", "userkey"), 2))) - .fallthrough(fallthroughVariation(0)) - .offVariation(1) - .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(fromValue(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(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(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() { - 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(); - 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(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(); - 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(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(); - 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(LDValue.of("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(LDValue.of("Bob")), false); - FeatureFlag f = booleanFlagWithClauses("flag", clause); - LDUser user = new LDUser.Builder("key").name("Bob").build(); - - 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(LDValue.of(4)), false); - FeatureFlag f = booleanFlagWithClauses("flag", clause); - LDUser user = new LDUser.Builder("key").custom("legs", 4).build(); - - 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(LDValue.of(4)), false); - FeatureFlag f = booleanFlagWithClauses("flag", clause); - LDUser user = new LDUser.Builder("key").name("Bob").build(); - - 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(LDValue.of("Bob")), true); - FeatureFlag f = booleanFlagWithClauses("flag", clause); - LDUser user = new LDUser.Builder("key").name("Bob").build(); - - assertEquals(LDValue.of(false), f.evaluate(user, featureStore, EventFactory.DEFAULT).getDetails().getValue()); - } - - @Test - public void clauseWithUnsupportedOperatorStringIsUnmarshalledWithNullOperator() throws Exception { - // This just verifies that GSON will give us a null in this case instead of throwing an exception, - // so we fail as gracefully as possible if a new operator type has been added in the application - // and the SDK hasn't been upgraded yet. - String badClauseJson = "{\"attribute\":\"name\",\"operator\":\"doesSomethingUnsupported\",\"values\":[\"x\"]}"; - Gson gson = new Gson(); - Clause clause = gson.fromJson(badClauseJson, Clause.class); - assertNotNull(clause); - - JsonElement json = gson.toJsonTree(clause); - String expectedJson = "{\"attribute\":\"name\",\"values\":[\"x\"],\"negate\":false}"; - assertEquals(gson.fromJson(expectedJson, JsonElement.class), json); - } - - @Test - public void clauseWithNullOperatorDoesNotMatch() throws Exception { - 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(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(LDValue.of("Bob")), false); - Rule badRule = new Rule("rule1", Arrays.asList(badClause), 1, null); - 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(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(fromValue(LDValue.of(true), 1, EvaluationReason.ruleMatch(1, "rule2")), details); - } - - @Test - public void testSegmentMatchClauseRetrievesSegmentFromStore() throws Exception { - Segment segment = new Segment.Builder("segkey") - .included(Arrays.asList("foo")) - .version(1) - .build(); - featureStore.upsert(SEGMENTS, segment); - - FeatureFlag flag = segmentMatchBooleanFlag("segkey"); - LDUser user = new LDUser.Builder("foo").build(); - - FeatureFlag.EvalResult result = flag.evaluate(user, featureStore, EventFactory.DEFAULT); - assertEquals(LDValue.of(true), result.getDetails().getValue()); - } - - @Test - public void testSegmentMatchClauseFallsThroughIfSegmentNotFound() throws Exception { - FeatureFlag flag = segmentMatchBooleanFlag("segkey"); - LDUser user = new LDUser.Builder("foo").build(); - - 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 = TEST_GSON_INSTANCE.fromJson(json, FeatureFlag.class); - assertFlagHasAllProperties(flag0); - - 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 = TEST_GSON_INSTANCE.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) - .rules(Arrays.asList(rules)) - .fallthrough(fallthroughVariation(0)) - .offVariation(1) - .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) - .build(); - } - - 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()); - } -} diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java b/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java deleted file mode 100644 index 92e4cfc0d..000000000 --- a/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java +++ /dev/null @@ -1,119 +0,0 @@ -package com.launchdarkly.client; - -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.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 = fromValue(LDValue.of("value"), 1, EvaluationReason.off()); - 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 canGetFlagReason() { - 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(); - - assertEquals(EvaluationReason.off(), state.getFlagReason("key")); - } - - @Test - public void unknownFlagReturnsNullReason() { - FeatureFlagsState state = new FeatureFlagsState.Builder().build(); - - assertNull(state.getFlagReason("key")); - } - - @Test - public void reasonIsNullIfReasonsWereNotRecorded() { - EvaluationDetail eval = fromValue(LDValue.of("value1"), 1, EvaluationReason.off()); - FeatureFlag flag = new FeatureFlagBuilder("key").build(); - FeatureFlagsState state = new FeatureFlagsState.Builder().addFlag(flag, eval).build(); - - assertNull(state.getFlagReason("key")); - } - - @Test - public void flagCanHaveNullValue() { - EvaluationDetail eval = fromValue(LDValue.ofNull(), 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 = fromValue(LDValue.of("value1"), 0, EvaluationReason.off()); - FeatureFlag flag1 = new FeatureFlagBuilder("key1").build(); - 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(); - - ImmutableMap expected = ImmutableMap.of("key1", js("value1"), "key2", js("value2")); - assertEquals(expected, state.toValuesMap()); - } - - @Test - public void canConvertToJson() { - EvaluationDetail eval1 = fromValue(LDValue.of("value1"), 0, EvaluationReason.off()); - FeatureFlag flag1 = new FeatureFlagBuilder("key1").version(100).trackEvents(false).build(); - 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(); - - String json = "{\"key1\":\"value1\",\"key2\":\"value2\"," + - "\"$flagsState\":{" + - "\"key1\":{" + - "\"variation\":0,\"version\":100,\"reason\":{\"kind\":\"OFF\"}" + // note, "trackEvents: false" is omitted - "},\"key2\":{" + - "\"variation\":1,\"version\":200,\"reason\":{\"kind\":\"FALLTHROUGH\"},\"trackEvents\":true,\"debugEventsUntilDate\":1000" + - "}" + - "}," + - "\"$valid\":true" + - "}"; - JsonElement expected = gson.fromJson(json, JsonElement.class); - assertEquals(expected, gson.toJsonTree(state)); - } - - @Test - public void canConvertFromJson() { - EvaluationDetail eval1 = fromValue(LDValue.of("value1"), 0, EvaluationReason.off()); - FeatureFlag flag1 = new FeatureFlagBuilder("key1").version(100).trackEvents(false).build(); - 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(); - - String json = gson.toJson(state); - FeatureFlagsState state1 = gson.fromJson(json, FeatureFlagsState.class); - assertEquals(state, state1); - } -} diff --git a/src/test/java/com/launchdarkly/client/FeatureStoreCachingTest.java b/src/test/java/com/launchdarkly/client/FeatureStoreCachingTest.java deleted file mode 100644 index 2d90cd4a6..000000000 --- a/src/test/java/com/launchdarkly/client/FeatureStoreCachingTest.java +++ /dev/null @@ -1,125 +0,0 @@ -package com.launchdarkly.client; - -import org.junit.Test; - -import java.util.concurrent.TimeUnit; - -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; - -@SuppressWarnings({ "deprecation", "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)); - } - - @Test - public void enabledHasExpectedProperties() { - 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.isInfiniteTtl(), equalTo(false)); - assertThat(fsc.getStaleValuesPolicy(), equalTo(EVICT)); - } - - @Test - public void defaultIsEnabled() { - 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)); - } - - @Test - public void canSetTtl() { - FeatureStoreCacheConfig fsc = FeatureStoreCacheConfig.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() { - FeatureStoreCacheConfig fsc = FeatureStoreCacheConfig.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() { - FeatureStoreCacheConfig fsc = FeatureStoreCacheConfig.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() { - FeatureStoreCacheConfig fsc = FeatureStoreCacheConfig.enabled() - .ttl(0, TimeUnit.SECONDS); - assertThat(fsc.isEnabled(), equalTo(false)); - assertThat(fsc.isInfiniteTtl(), equalTo(false)); - } - - @Test - public void negativeTtlMeansEnabledAndInfinite() { - FeatureStoreCacheConfig fsc = FeatureStoreCacheConfig.enabled() - .ttl(-1, TimeUnit.SECONDS); - assertThat(fsc.isEnabled(), equalTo(true)); - assertThat(fsc.isInfiniteTtl(), equalTo(true)); - } - - @Test - public void canSetStaleValuesPolicy() { - FeatureStoreCacheConfig fsc = FeatureStoreCacheConfig.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() { - 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() { - 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() { - 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/FeatureStoreDatabaseTestBase.java b/src/test/java/com/launchdarkly/client/FeatureStoreDatabaseTestBase.java deleted file mode 100644 index 83565442e..000000000 --- a/src/test/java/com/launchdarkly/client/FeatureStoreDatabaseTestBase.java +++ /dev/null @@ -1,238 +0,0 @@ -package com.launchdarkly.client; - -import com.launchdarkly.client.DataStoreTestTypes.TestItem; -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.DataStoreTestTypes.TEST_ITEMS; -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) -@SuppressWarnings("javadoc") -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(TEST_ITEMS, item1).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 TestItem startItem = new TestItem("me", "foo", startVersion); - - Runnable concurrentModifier = new Runnable() { - int versionCounter = store2VersionStart; - public void run() { - if (versionCounter <= store2VersionEnd) { - store2.upsert(TEST_ITEMS, startItem.withVersion(versionCounter)); - versionCounter++; - } - } - }; - - try { - assumeTrue(setUpdateHook(store, concurrentModifier)); - - store.init(new DataBuilder().add(TEST_ITEMS, startItem).build()); - - TestItem store1End = startItem.withVersion(store1VersionEnd); - store.upsert(TEST_ITEMS, store1End); - - VersionedData result = store.get(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.upsert(TEST_ITEMS, startItem.withVersion(store2Version)); - } - }; - - try { - assumeTrue(setUpdateHook(store, concurrentModifier)); - - store.init(new DataBuilder().add(TEST_ITEMS, startItem).build()); - - TestItem store1End = startItem.withVersion(store1VersionEnd); - store.upsert(TEST_ITEMS, store1End); - - VersionedData result = store.get(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.initialized()); - assertFalse(store2.initialized()); - - 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(TEST_ITEMS, item1a, item1b).build()); - assertTrue(store1.initialized()); - assertFalse(store2.initialized()); - - store2.init(new DataBuilder().add(TEST_ITEMS, item2a, item2c).build()); - assertTrue(store1.initialized()); - assertTrue(store2.initialized()); - - Map items1 = store1.all(TEST_ITEMS); - Map items2 = store2.all(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.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 deleted file mode 100644 index d97917b58..000000000 --- a/src/test/java/com/launchdarkly/client/FeatureStoreTestBase.java +++ /dev/null @@ -1,180 +0,0 @@ -package com.launchdarkly.client; - -import com.launchdarkly.client.DataStoreTestTypes.TestItem; -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.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; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; - -/** - * 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 TestItem item2 = new TestItem("second", "key2", 10); - - protected TestItem otherItem1 = new TestItem("other-first", "key1", 11); - - 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() { - store.init(new DataBuilder().build()); - assertTrue(store.initialized()); - } - - @Test - public void initCompletelyReplacesPreviousData() { - clearAllData(); - - Map, Map> allData = - new DataBuilder().add(TEST_ITEMS, item1, item2).add(OTHER_TEST_ITEMS, otherItem1).build(); - store.init(allData); - - 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(TEST_ITEMS, item1.key)); - assertEquals(item2v2, store.get(TEST_ITEMS, item2.key)); - assertNull(store.get(OTHER_TEST_ITEMS, otherItem1.key)); - } - - @Test - public void getExistingItem() { - store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).build()); - assertEquals(item1, store.get(TEST_ITEMS, item1.key)); - } - - @Test - 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(TEST_ITEMS, item1, item2).add(OTHER_TEST_ITEMS, otherItem1).build()); - Map items = store.all(TEST_ITEMS); - assertEquals(2, items.size()); - assertEquals(item1, items.get(item1.key)); - assertEquals(item2, items.get(item2.key)); - } - - @Test - public void getAllWithDeletedItem() { - 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()); - assertEquals(item2, items.get(item2.key)); - } - - @Test - public void upsertWithNewerVersion() { - 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(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 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(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(TEST_ITEMS, item1, item2).build()); - store.delete(TEST_ITEMS, item1.key, item1.version - 1); - assertNotNull(store.get(TEST_ITEMS, item1.key)); - } - - @Test - 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(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/InMemoryFeatureStoreTest.java b/src/test/java/com/launchdarkly/client/InMemoryFeatureStoreTest.java deleted file mode 100644 index fec2aab7c..000000000 --- a/src/test/java/com/launchdarkly/client/InMemoryFeatureStoreTest.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.launchdarkly.client; - -public class InMemoryFeatureStoreTest extends FeatureStoreTestBase { - - @Override - protected InMemoryFeatureStore makeStore() { - return new InMemoryFeatureStore(); - } -} diff --git a/src/test/java/com/launchdarkly/client/LDClientExternalUpdatesOnlyTest.java b/src/test/java/com/launchdarkly/client/LDClientExternalUpdatesOnlyTest.java deleted file mode 100644 index 414fd628a..000000000 --- a/src/test/java/com/launchdarkly/client/LDClientExternalUpdatesOnlyTest.java +++ /dev/null @@ -1,109 +0,0 @@ -package com.launchdarkly.client; - -import com.launchdarkly.client.value.LDValue; - -import org.junit.Test; - -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; - -@SuppressWarnings("javadoc") -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 { - LDConfig config = new LDConfig.Builder() - .useLdd(true) - .build(); - try (LDClient client = new LDClient("SDK_KEY", config)) { - assertEquals(Components.NullUpdateProcessor.class, client.updateProcessor.getClass()); - } - } - - @Test - public void lddModeClientHasDefaultEventProcessor() throws IOException { - @SuppressWarnings("deprecation") - LDConfig config = new LDConfig.Builder() - .useLdd(true) - .build(); - try (LDClient client = new LDClient("SDK_KEY", config)) { - assertEquals(DefaultEventProcessor.class, client.eventProcessor.getClass()); - } - } - - @Test - public void lddModeClientIsInitialized() throws IOException { - @SuppressWarnings("deprecation") - LDConfig config = new LDConfig.Builder() - .useLdd(true) - .build(); - try (LDClient client = new LDClient("SDK_KEY", config)) { - assertTrue(client.initialized()); - } - } - - @Test - public void lddModeClientGetsFlagFromFeatureStore() throws IOException { - FeatureStore testFeatureStore = initedFeatureStore(); - @SuppressWarnings("deprecation") - LDConfig config = new LDConfig.Builder() - .useLdd(true) - .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)); - } - } -} \ No newline at end of file diff --git a/src/test/java/com/launchdarkly/client/LDClientTest.java b/src/test/java/com/launchdarkly/client/LDClientTest.java deleted file mode 100644 index caa1c37a0..000000000 --- a/src/test/java/com/launchdarkly/client/LDClientTest.java +++ /dev/null @@ -1,475 +0,0 @@ -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 com.launchdarkly.client.value.LDValue; - -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; - -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; -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.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; - private LDClientInterface client; - - @SuppressWarnings("unchecked") - @Before - public void before() { - updateProcessor = createStrictMock(UpdateProcessor.class); - eventProcessor = createStrictMock(EventProcessor.class); - 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 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() - .dataSource(Components.externalUpdatesOnly()) - .startWaitMillis(0) - .sendEvents(true) - .build(); - try (LDClient client = new LDClient(SDK_KEY, config)) { - assertEquals(DefaultEventProcessor.class, client.eventProcessor.getClass()); - } - } - - @SuppressWarnings("deprecation") - @Test - public void clientHasNullEventProcessorIfSendEventsIsFalse() throws IOException { - LDConfig config = new LDConfig.Builder() - .dataSource(Components.externalUpdatesOnly()) - .startWaitMillis(0) - .sendEvents(false) - .build(); - try (LDClient client = new LDClient(SDK_KEY, config)) { - assertEquals(Components.NullEventProcessor.class, client.eventProcessor.getClass()); - } - } - - @Test - public void streamingClientHasStreamProcessor() throws Exception { - LDConfig config = new LDConfig.Builder() - .dataSource(Components.streamingDataSource().baseURI(URI.create("http://fake"))) - .startWaitMillis(0) - .build(); - try (LDClient client = new LDClient(SDK_KEY, config)) { - assertEquals(StreamProcessor.class, client.updateProcessor.getClass()); - } - } - - @Test - public void pollingClientHasPollingProcessor() throws IOException { - LDConfig config = new LDConfig.Builder() - .dataSource(Components.pollingDataSource().baseURI(URI.create("http://fake"))) - .startWaitMillis(0) - .build(); - 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() - .startWaitMillis(0) - .events(mockEventProcessorFactory) - .dataSource(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() - .startWaitMillis(0) - .events(mockEventProcessorFactory) - .dataSource(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() - .startWaitMillis(0) - .events(mockEventProcessorFactory) - .dataSource(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() - .startWaitMillis(0L); - - expect(updateProcessor.start()).andReturn(initFuture); - expect(updateProcessor.initialized()).andReturn(false); - replayAll(); - - client = createMockClient(config); - assertFalse(client.initialized()); - - verifyAll(); - } - - @Test - public void willWaitForUpdateProcessorIfWaitMillisIsNonZero() throws Exception { - LDConfig.Builder config = new LDConfig.Builder() - .startWaitMillis(10L); - - expect(updateProcessor.start()).andReturn(initFuture); - expect(initFuture.get(10L, TimeUnit.MILLISECONDS)).andReturn(null); - expect(updateProcessor.initialized()).andReturn(false).anyTimes(); - replayAll(); - - client = createMockClient(config); - assertFalse(client.initialized()); - - verifyAll(); - } - - @Test - public void updateProcessorCanTimeOut() throws Exception { - LDConfig.Builder config = new LDConfig.Builder() - .startWaitMillis(10L); - - expect(updateProcessor.start()).andReturn(initFuture); - expect(initFuture.get(10L, TimeUnit.MILLISECONDS)).andThrow(new TimeoutException()); - expect(updateProcessor.initialized()).andReturn(false).anyTimes(); - replayAll(); - - client = createMockClient(config); - assertFalse(client.initialized()); - - verifyAll(); - } - - @Test - public void clientCatchesRuntimeExceptionFromUpdateProcessor() throws Exception { - LDConfig.Builder config = new LDConfig.Builder() - .startWaitMillis(10L); - - expect(updateProcessor.start()).andReturn(initFuture); - expect(initFuture.get(10L, TimeUnit.MILLISECONDS)).andThrow(new RuntimeException()); - expect(updateProcessor.initialized()).andReturn(false).anyTimes(); - replayAll(); - - client = createMockClient(config); - assertFalse(client.initialized()); - - verifyAll(); - } - - @Test - public void isFlagKnownReturnsTrueForExistingFlag() throws Exception { - FeatureStore testFeatureStore = initedFeatureStore(); - LDConfig.Builder config = new LDConfig.Builder() - .startWaitMillis(0) - .dataStore(specificFeatureStore(testFeatureStore)); - expect(updateProcessor.start()).andReturn(initFuture); - expect(updateProcessor.initialized()).andReturn(true).times(1); - replayAll(); - - client = createMockClient(config); - - testFeatureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(1))); - assertTrue(client.isFlagKnown("key")); - verifyAll(); - } - - @Test - public void isFlagKnownReturnsFalseForUnknownFlag() throws Exception { - FeatureStore testFeatureStore = initedFeatureStore(); - LDConfig.Builder config = new LDConfig.Builder() - .startWaitMillis(0) - .dataStore(specificFeatureStore(testFeatureStore)); - expect(updateProcessor.start()).andReturn(initFuture); - expect(updateProcessor.initialized()).andReturn(true).times(1); - replayAll(); - - client = createMockClient(config); - - assertFalse(client.isFlagKnown("key")); - verifyAll(); - } - - @Test - public void isFlagKnownReturnsFalseIfStoreAndClientAreNotInitialized() throws Exception { - FeatureStore testFeatureStore = new InMemoryFeatureStore(); - LDConfig.Builder config = new LDConfig.Builder() - .startWaitMillis(0) - .dataStore(specificFeatureStore(testFeatureStore)); - expect(updateProcessor.start()).andReturn(initFuture); - expect(updateProcessor.initialized()).andReturn(false).times(1); - replayAll(); - - client = createMockClient(config); - - testFeatureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(1))); - assertFalse(client.isFlagKnown("key")); - verifyAll(); - } - - @Test - public void isFlagKnownUsesStoreIfStoreIsInitializedButClientIsNot() throws Exception { - FeatureStore testFeatureStore = initedFeatureStore(); - LDConfig.Builder config = new LDConfig.Builder() - .startWaitMillis(0) - .dataStore(specificFeatureStore(testFeatureStore)); - expect(updateProcessor.start()).andReturn(initFuture); - expect(updateProcessor.initialized()).andReturn(false).times(1); - replayAll(); - - client = createMockClient(config); - - testFeatureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(1))); - assertTrue(client.isFlagKnown("key")); - verifyAll(); - } - - @Test - public void evaluationUsesStoreIfStoreIsInitializedButClientIsNot() throws Exception { - FeatureStore testFeatureStore = initedFeatureStore(); - LDConfig.Builder config = new LDConfig.Builder() - .dataStore(specificFeatureStore(testFeatureStore)) - .startWaitMillis(0L); - expect(updateProcessor.start()).andReturn(initFuture); - expect(updateProcessor.initialized()).andReturn(false); - expectEventsSent(1); - replayAll(); - - client = createMockClient(config); - - testFeatureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(1))); - assertEquals(new Integer(1), client.intVariation("key", new LDUser("user"), 0)); - - 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() - .dataSource(updateProcessorWithData(DEPENDENCY_ORDERING_TEST_DATA)) - .dataStore(specificFeatureStore(store)) - .events(Components.noEvents()); - 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) { - expectLastCall().times(count); - } else { - expectLastCall().andThrow(new AssertionFailedError("should not have queued an event")).anyTimes(); - } - } - - private LDClientInterface createMockClient(LDConfig.Builder config) { - config.dataSource(TestUtil.specificUpdateProcessor(updateProcessor)); - config.events(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/LDConfigTest.java b/src/test/java/com/launchdarkly/client/LDConfigTest.java deleted file mode 100644 index 3e89c7a5a..000000000 --- a/src/test/java/com/launchdarkly/client/LDConfigTest.java +++ /dev/null @@ -1,218 +0,0 @@ -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 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(); - 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 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.getProxy()); - } - - @SuppressWarnings("deprecation") - @Test - public void testDeprecatedHttpOnlyProxyPortConfiguredHasPortAndDefaultHost() { - LDConfig config = new LDConfig.Builder().proxyPort(1234).build(); - assertEquals(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("localhost", 1234)), config.httpConfig.getProxy()); - } - - @SuppressWarnings("deprecation") - @Test - 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.getProxy()); - } - - @SuppressWarnings("deprecation") - @Test - public void testDeprecatedHttpProxyAuth() { - LDConfig config = new LDConfig.Builder() - .proxyHost("localhost2") - .proxyPort(4444) - .proxyUsername("user") - .proxyPassword("pass") - .build(); - assertNotNull(config.httpConfig.getProxy()); - assertNotNull(config.httpConfig.getProxyAuthentication()); - assertEquals("Basic dXNlcjpwYXNz", config.httpConfig.getProxyAuthentication().provideAuthorization(null)); - } - - @SuppressWarnings("deprecation") - @Test - public void testDeprecatedHttpProxyAuthPartialConfig() { - LDConfig config = new LDConfig.Builder() - .proxyHost("localhost2") - .proxyPort(4444) - .proxyUsername("proxyUser") - .build(); - assertNotNull(config.httpConfig.getProxy()); - assertNull(config.httpConfig.getProxyAuthentication()); - - config = new LDConfig.Builder() - .proxyHost("localhost2") - .proxyPort(4444) - .proxyPassword("proxyPassword") - .build(); - assertNotNull(config.httpConfig.getProxy()); - assertNull(config.httpConfig.getProxyAuthentication()); - } - - @SuppressWarnings("deprecation") - @Test - 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 testDeprecatedHttpWrapperNameOnly() { - LDConfig config = new LDConfig.Builder() - .wrapperName("Scala") - .build(); - assertEquals("Scala", config.httpConfig.getWrapperIdentifier()); - } - - @SuppressWarnings("deprecation") - @Test - public void testDeprecatedHttpWrapperWithVersion() { - LDConfig config = new LDConfig.Builder() - .wrapperName("Scala") - .wrapperVersion("0.1.0") - .build(); - assertEquals("Scala/0.1.0", config.httpConfig.getWrapperIdentifier()); - } -} \ 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 deleted file mode 100644 index fd66966f6..000000000 --- a/src/test/java/com/launchdarkly/client/LDUserTest.java +++ /dev/null @@ -1,511 +0,0 @@ -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; -import com.google.gson.JsonObject; -import com.google.gson.reflect.TypeToken; -import com.launchdarkly.client.value.LDValue; - -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.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; -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; - -@SuppressWarnings("javadoc") -public class LDUserTest { - private static final Gson defaultGson = new Gson(); - - @Test - 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") - .firstName("Bob") - .lastName("Loblaw") - .email("bob@example.com") - .name("Bob Loblaw") - .avatar("image") - .anonymous(false) - .country("US") - .custom("org", "LaunchDarkly") - .build(); - - assert(user.equals(new LDUser.Builder(user).build())); - } - - @Test - 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().stringValue()); - } - - @Test - public void canSetPrivateSecondary() { - LDUser user = new LDUser.Builder("key").privateSecondary("s").build(); - 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().stringValue()); - } - - @Test - public void canSetPrivateIp() { - LDUser user = new LDUser.Builder("key").privateIp("i").build(); - 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().stringValue()); - } - - @Test - public void canSetPrivateEmail() { - LDUser user = new LDUser.Builder("key").privateEmail("e").build(); - 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().stringValue()); - } - - @Test - public void canSetPrivateName() { - LDUser user = new LDUser.Builder("key").privateName("n").build(); - 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().stringValue()); - } - - @Test - public void canSetPrivateAvatar() { - LDUser user = new LDUser.Builder("key").privateAvatar("a").build(); - 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().stringValue()); - } - - @Test - public void canSetPrivateFirstName() { - LDUser user = new LDUser.Builder("key").privateFirstName("f").build(); - 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().stringValue()); - } - - @Test - public void canSetPrivateLastName() { - LDUser user = new LDUser.Builder("key").privateLastName("l").build(); - 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().booleanValue()); - } - - @SuppressWarnings("deprecation") - @Test - public void canSetCountry() { - LDUser user = new LDUser.Builder("key").country(LDCountryCode.US).build(); - assertEquals("US", user.getCountry().stringValue()); - } - - @Test - public void canSetCountryAsString() { - LDUser user = new LDUser.Builder("key").country("US").build(); - assertEquals("US", user.getCountry().stringValue()); - } - - @Test - public void canSetCountryAs3CharacterString() { - LDUser user = new LDUser.Builder("key").country("USA").build(); - 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().stringValue()); - } - - @Test - public void ambiguousCountryNameSetsCountryWithPartialMatch() { - // For an ambiguous match, we return the first match - LDUser user = new LDUser.Builder("key").country("United St").build(); - assertNotNull(user.getCountry()); - } - - @Test - public void partialUniqueMatchSetsCountry() { - LDUser user = new LDUser.Builder("key").country("United States Minor").build(); - assertEquals("UM", user.getCountry().stringValue()); - } - - @Test - public void invalidCountryNameDoesNotSetCountry() { - LDUser user = new LDUser.Builder("key").country("East Jibip").build(); - assertEquals(LDValue.ofNull(), user.getCountry()); - } - - @SuppressWarnings("deprecation") - @Test - public void canSetPrivateCountry() { - LDUser user = new LDUser.Builder("key").privateCountry(LDCountryCode.US).build(); - 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").stringValue()); - } - - @Test - public void canSetPrivateCustomString() { - LDUser user = new LDUser.Builder("key").privateCustom("thing", "value").build(); - 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").intValue()); - } - - @Test - public void canSetPrivateCustomInt() { - LDUser user = new LDUser.Builder("key").privateCustom("thing", 1).build(); - 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").booleanValue()); - } - - @Test - public void canSetPrivateCustomBoolean() { - LDUser user = new LDUser.Builder("key").privateCustom("thing", true).build(); - assertEquals(true, user.getCustom("thing").booleanValue()); - assertEquals(ImmutableSet.of("thing"), user.privateAttributeNames); - } - - @Test - public void canSetCustomJsonValue() { - 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() { - 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()) { - JsonElement expected = TEST_GSON_INSTANCE.fromJson(e.getValue(), JsonElement.class); - JsonElement actual = TEST_GSON_INSTANCE.toJsonTree(e.getKey()); - assertEquals(expected, actual); - } - } - - @Test - public void testAllPropertiesInPrivateAttributeEncoding() { - for (Map.Entry e: getUserPropertiesJsonMap().entrySet()) { - 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\"}"); - 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 privateAttributeEncodingRedactsAllPrivateAttributes() { - EventsConfiguration config = makeEventsConfig(true, false, null); - @SuppressWarnings("deprecation") - 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"); - - JsonObject o = gsonInstanceForEventsSerialization(config).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 privateAttributeEncodingRedactsSpecificPerUserPrivateAttributes() { - LDUser user = new LDUser.Builder("userkey") - .email("e") - .privateName("n") - .custom("bar", 43) - .privateCustom("foo", 42) - .build(); - - 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()); - assertNull(o.get("custom").getAsJsonObject().get("foo")); - assertEquals(ImmutableSet.of("name", "foo"), getPrivateAttrs(o)); - } - - @Test - public void privateAttributeEncodingRedactsSpecificGlobalPrivateAttributes() { - EventsConfiguration config = makeEventsConfig(false, false, ImmutableSet.of("name", "foo")); - LDUser user = new LDUser.Builder("userkey") - .email("e") - .name("n") - .custom("bar", 43) - .custom("foo", 42) - .build(); - - 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()); - assertNull(o.get("custom").getAsJsonObject().get("foo")); - assertEquals(ImmutableSet.of("name", "foo"), getPrivateAttrs(o)); - } - - @Test - public void privateAttributeEncodingWorksForMinimalUser() { - EventsConfiguration config = makeEventsConfig(true, false, null); - LDUser user = new LDUser("userkey"); - - JsonObject o = gsonInstanceForEventsSerialization(config).toJsonTree(user).getAsJsonObject(); - JsonObject expected = new JsonObject(); - expected.addProperty("key", "userkey"); - assertEquals(expected, o); - } - - @Test - public void getValueGetsBuiltInAttribute() { - LDUser user = new LDUser.Builder("key") - .name("Jane") - .build(); - assertEquals(LDValue.of("Jane"), user.getValueForEvaluation("name")); - } - - @Test - public void getValueGetsCustomAttribute() { - LDUser user = new LDUser.Builder("key") - .custom("height", 5) - .build(); - assertEquals(LDValue.of(5), user.getValueForEvaluation("height")); - } - - @Test - public void getValueGetsBuiltInAttributeEvenIfCustomAttrHasSameName() { - LDUser user = new LDUser.Builder("key") - .name("Jane") - .custom("name", "Joan") - .build(); - assertEquals(LDValue.of("Jane"), user.getValueForEvaluation("name")); - } - - @Test - 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() { - LDUser user = new LDUser.Builder("key") - .customString("foo", ImmutableList.of("a", "b")) - .build(); - JsonElement expectedAttr = makeCustomAttrWithListOfValues("foo", js("a"), js("b")); - JsonObject jo = TEST_GSON_INSTANCE.toJsonTree(user).getAsJsonObject(); - assertEquals(expectedAttr, jo.get("custom")); - } - - @Test - public void canAddCustomAttrWithListOfNumbers() { - LDUser user = new LDUser.Builder("key") - .customNumber("foo", ImmutableList.of(new Integer(1), new Double(2))) - .build(); - JsonElement expectedAttr = makeCustomAttrWithListOfValues("foo", jint(1), jdouble(2)); - JsonObject jo = TEST_GSON_INSTANCE.toJsonTree(user).getAsJsonObject(); - assertEquals(expectedAttr, jo.get("custom")); - } - - @Test - public void canAddCustomAttrWithListOfMixedValues() { - LDUser user = new LDUser.Builder("key") - .customValues("foo", ImmutableList.of(js("a"), jint(1), jbool(true))) - .build(); - JsonElement expectedAttr = makeCustomAttrWithListOfValues("foo", js("a"), jint(1), jbool(true)); - JsonObject jo = TEST_GSON_INSTANCE.toJsonTree(user).getAsJsonObject(); - assertEquals(expectedAttr, jo.get("custom")); - } - - private JsonElement makeCustomAttrWithListOfValues(String name, JsonElement... values) { - JsonObject ret = new JsonObject(); - JsonArray a = new JsonArray(); - for (JsonElement v: values) { - a.add(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)); - } -} diff --git a/src/test/java/com/launchdarkly/client/OperatorParameterizedTest.java b/src/test/java/com/launchdarkly/client/OperatorParameterizedTest.java deleted file mode 100644 index 95f9cec02..000000000 --- a/src/test/java/com/launchdarkly/client/OperatorParameterizedTest.java +++ /dev/null @@ -1,132 +0,0 @@ -package com.launchdarkly.client; - -import com.launchdarkly.client.value.LDValue; - -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.Parameterized; - -import java.util.Arrays; - -import static org.junit.Assert.assertEquals; - -@SuppressWarnings("javadoc") -@RunWith(Parameterized.class) -public class OperatorParameterizedTest { - 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 LDValue aValue; - private final LDValue bValue; - private final boolean shouldBe; - - public OperatorParameterizedTest(Operator op, LDValue aValue, LDValue bValue, boolean shouldBe) { - this.op = op; - this.aValue = aValue; - this.bValue = bValue; - this.shouldBe = shouldBe; - } - - @Parameterized.Parameters(name = "{1} {0} {2} should be {3}") - public static Iterable data() { - return Arrays.asList(new Object[][] { - // numeric comparisons - { 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, 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, 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, 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 }, - { Operator.before, dateMs1, dateMs2, true }, - { Operator.before, dateStr2, dateStr1, false }, - { Operator.before, dateMs2, dateMs1, false }, - { Operator.before, dateStr1, dateStr1, false }, - { Operator.before, dateMs1, dateMs1, false }, - { Operator.before, dateStr1, invalidDate, false }, - { Operator.after, dateStr1, dateStr2, false }, - { Operator.after, dateMs1, dateMs2, false }, - { Operator.after, dateStr2, dateStr1, true }, - { Operator.after, dateMs2, dateMs1, true }, - { Operator.after, dateStr1, dateStr1, false }, - { Operator.after, dateMs1, dateMs1, false }, - { Operator.after, dateStr1, invalidDate, false }, - - // semver - { 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 }, - { 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 } - }); - } - - @Test - public void parameterizedTestComparison() { - assertEquals(shouldBe, op.apply(aValue, bValue)); - } -} diff --git a/src/test/java/com/launchdarkly/client/OperatorTest.java b/src/test/java/com/launchdarkly/client/OperatorTest.java deleted file mode 100644 index 4b55d4c0b..000000000 --- a/src/test/java/com/launchdarkly/client/OperatorTest.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.launchdarkly.client; - -import com.launchdarkly.client.value.LDValue; - -import org.junit.Test; - -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(LDValue.of("hello world"), LDValue.of("***not a regex"))); - } -} 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/SegmentTest.java b/src/test/java/com/launchdarkly/client/SegmentTest.java deleted file mode 100644 index ccd9d5225..000000000 --- a/src/test/java/com/launchdarkly/client/SegmentTest.java +++ /dev/null @@ -1,142 +0,0 @@ -package com.launchdarkly.client; - -import com.launchdarkly.client.value.LDValue; - -import org.junit.Test; - -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; - - @Test - public void explicitIncludeUser() { - Segment s = new Segment.Builder("test") - .included(Arrays.asList("foo")) - .salt("abcdef") - .version(1) - .build(); - LDUser u = new LDUser.Builder("foo").build(); - - assertTrue(s.matchesUser(u)); - } - - @Test - public void explicitExcludeUser() { - Segment s = new Segment.Builder("test") - .excluded(Arrays.asList("foo")) - .salt("abcdef") - .version(1) - .build(); - LDUser u = new LDUser.Builder("foo").build(); - - assertFalse(s.matchesUser(u)); - } - - @Test - public void explicitIncludeHasPrecedence() { - Segment s = new Segment.Builder("test") - .included(Arrays.asList("foo")) - .excluded(Arrays.asList("foo")) - .salt("abcdef") - .version(1) - .build(); - LDUser u = new LDUser.Builder("foo").build(); - - assertTrue(s.matchesUser(u)); - } - - @Test - public void matchingRuleWithFullRollout() { - Clause clause = new Clause( - "email", - Operator.in, - Arrays.asList(LDValue.of("test@example.com")), - false); - SegmentRule rule = new SegmentRule( - Arrays.asList(clause), - maxWeight, - null); - Segment s = new Segment.Builder("test") - .salt("abcdef") - .rules(Arrays.asList(rule)) - .build(); - LDUser u = new LDUser.Builder("foo").email("test@example.com").build(); - - assertTrue(s.matchesUser(u)); - } - - @Test - public void matchingRuleWithZeroRollout() { - Clause clause = new Clause( - "email", - Operator.in, - Arrays.asList(LDValue.of("test@example.com")), - false); - SegmentRule rule = new SegmentRule(Arrays.asList(clause), - 0, - null); - Segment s = new Segment.Builder("test") - .salt("abcdef") - .rules(Arrays.asList(rule)) - .build(); - LDUser u = new LDUser.Builder("foo").email("test@example.com").build(); - - assertFalse(s.matchesUser(u)); - } - - @Test - public void matchingRuleWithMultipleClauses() { - Clause clause1 = new Clause( - "email", - Operator.in, - Arrays.asList(LDValue.of("test@example.com")), - false); - Clause clause2 = new Clause( - "name", - Operator.in, - Arrays.asList(LDValue.of("bob")), - false); - SegmentRule rule = new SegmentRule( - Arrays.asList(clause1, clause2), - null, - null); - Segment s = new Segment.Builder("test") - .salt("abcdef") - .rules(Arrays.asList(rule)) - .build(); - LDUser u = new LDUser.Builder("foo").email("test@example.com").name("bob").build(); - - assertTrue(s.matchesUser(u)); - } - - @Test - public void nonMatchingRuleWithMultipleClauses() { - Clause clause1 = new Clause( - "email", - Operator.in, - Arrays.asList(LDValue.of("test@example.com")), - false); - Clause clause2 = new Clause( - "name", - Operator.in, - Arrays.asList(LDValue.of("bill")), - false); - SegmentRule rule = new SegmentRule( - Arrays.asList(clause1, clause2), - null, - null); - Segment s = new Segment.Builder("test") - .salt("abcdef") - .rules(Arrays.asList(rule)) - .build(); - LDUser u = new LDUser.Builder("foo").email("test@example.com").name("bob").build(); - - assertFalse(s.matchesUser(u)); - } -} \ 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 deleted file mode 100644 index 98663931d..000000000 --- a/src/test/java/com/launchdarkly/client/TestUtil.java +++ /dev/null @@ -1,317 +0,0 @@ -package com.launchdarkly.client; - -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; -import com.launchdarkly.client.integrations.EventProcessorBuilder; -import com.launchdarkly.client.value.LDValue; - -import org.hamcrest.Description; -import org.hamcrest.Matcher; -import org.hamcrest.TypeSafeDiagnosingMatcher; - -import java.io.IOException; -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.Set; -import java.util.concurrent.Future; - -import static org.hamcrest.Matchers.equalTo; - -@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() { - return store; - } - }; - } - - 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) { - return ep; - } - }; - } - - public static UpdateProcessorFactory specificUpdateProcessor(final UpdateProcessor up) { - return new UpdateProcessorFactory() { - public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, FeatureStore featureStore) { - return up; - } - }; - } - - 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 - 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) { - throw e; - } - - @Override - public void delete(VersionedDataKind kind, String key, int version) { - throw e; - } - - @Override - public void upsert(VersionedDataKind kind, T item) { - throw e; - } - - @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<>(); - - @Override - public void close() throws IOException {} - - @Override - public void sendEvent(Event e) { - events.add(e); - } - - @Override - public void flush() {} - } - - public static JsonPrimitive js(String s) { - return new JsonPrimitive(s); - } - - public static JsonPrimitive jint(int n) { - return new JsonPrimitive(n); - } - - public static JsonPrimitive jdouble(double d) { - return new JsonPrimitive(d); - } - - public static JsonPrimitive jbool(boolean b) { - return new JsonPrimitive(b); - } - - public static VariationOrRollout fallthroughVariation(int variation) { - return new VariationOrRollout(variation, null); - } - - public static FeatureFlag booleanFlagWithClauses(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)) - .offVariation(0) - .variations(LDValue.of(false), LDValue.of(true)) - .build(); - } - - public static FeatureFlag flagWithValue(String key, LDValue value) { - return new FeatureFlagBuilder(key) - .on(false) - .offVariation(0) - .variations(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(LDValue.of("not-" + user.getKeyAsString())), false); - } - - 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; - } - - // 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) { - return EvaluationDetail.fromValue(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)); - } - - public static Matcher hasJsonProperty(final String name, int value) { - return hasJsonProperty(name, new JsonPrimitive(value)); - } - - public static Matcher hasJsonProperty(final String name, double value) { - return hasJsonProperty(name, new JsonPrimitive(value)); - } - - public static Matcher hasJsonProperty(final String name, boolean value) { - return hasJsonProperty(name, new JsonPrimitive(value)); - } - - public static Matcher hasJsonProperty(final String name, final Matcher matcher) { - return new TypeSafeDiagnosingMatcher() { - @Override - public void describeTo(Description description) { - description.appendText(name + ": "); - matcher.describeTo(description); - } - - @Override - protected boolean matchesSafely(JsonElement item, Description mismatchDescription) { - JsonElement value = item.getAsJsonObject().get(name); - if (!matcher.matches(value)) { - matcher.describeMismatch(value, mismatchDescription); - return false; - } - return true; - } - }; - } - - public static Matcher isJsonArray(final Matcher> matcher) { - return new TypeSafeDiagnosingMatcher() { - @Override - public void describeTo(Description description) { - description.appendText("array: "); - matcher.describeTo(description); - } - - @Override - protected boolean matchesSafely(JsonElement item, Description mismatchDescription) { - JsonArray value = item.getAsJsonArray(); - if (!matcher.matches(value)) { - matcher.describeMismatch(value, mismatchDescription); - return false; - } - return true; - } - }; - } - - static EventsConfiguration makeEventsConfig(boolean allAttributesPrivate, boolean inlineUsersInEvents, - Set privateAttrNames) { - return new EventsConfiguration( - allAttributesPrivate, - 0, null, 0, - inlineUsersInEvents, - privateAttrNames, - 0, 0, 0, EventProcessorBuilder.DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL_SECONDS); - } - - 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 deleted file mode 100644 index 84744dc9c..000000000 --- a/src/test/java/com/launchdarkly/client/UtilTest.java +++ /dev/null @@ -1,109 +0,0 @@ -package com.launchdarkly.client; - -import com.launchdarkly.client.value.LDValue; - -import org.joda.time.DateTime; -import org.joda.time.DateTimeZone; -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; - -@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(LDValue.of(validRFC3339String)); - Assert.assertEquals(expected, actual.toString()); - } - - @Test - public void testDateTimeConversionWithUtc() { - String validRFC3339String = "1970-01-01T00:00:01.001Z"; - - DateTime actual = Util.jsonPrimitiveToDateTime(LDValue.of(validRFC3339String)); - Assert.assertEquals(validRFC3339String, actual.toString()); - } - - @Test - public void testDateTimeConversionWithNoTimeZone() { - String validRFC3339String = "2016-04-16T17:09:12.759"; - String expected = "2016-04-16T17:09:12.759Z"; - - DateTime actual = Util.jsonPrimitiveToDateTime(LDValue.of(validRFC3339String)); - Assert.assertEquals(expected, actual.toString()); - } - - @Test - public void testDateTimeConversionTimestampWithNoMillis() { - String validRFC3339String = "2016-04-16T17:09:12"; - String expected = "2016-04-16T17:09:12.000Z"; - - DateTime actual = Util.jsonPrimitiveToDateTime(LDValue.of(validRFC3339String)); - Assert.assertEquals(expected, actual.toString()); - } - - @Test - public void testDateTimeConversionAsUnixMillis() { - long unixMillis = 1000; - String expected = "1970-01-01T00:00:01.000Z"; - DateTime actual = Util.jsonPrimitiveToDateTime(LDValue.of(unixMillis)); - Assert.assertEquals(expected, actual.withZone(DateTimeZone.UTC).toString()); - } - - @Test - public void testDateTimeConversionCompare() { - long aMillis = 1001; - String bStamp = "1970-01-01T00:00:01.001Z"; - 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(LDValue.of(unixMillis)); - Assert.assertEquals(unixMillis, actual.getMillis()); - } - - @Test - public void testDateTimeConversionInvalidString() { - String invalidTimestamp = "May 3, 1980"; - DateTime actual = Util.jsonPrimitiveToDateTime(LDValue.of(invalidTimestamp)); - Assert.assertNull(actual); - } - - @Test - 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(); - try { - assertEquals(3000, httpClient.connectTimeoutMillis()); - } finally { - shutdownHttpClient(httpClient); - } - } - - @Test - 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(); - try { - assertEquals(3000, httpClient.readTimeoutMillis()); - } finally { - shutdownHttpClient(httpClient); - } - } -} diff --git a/src/test/java/com/launchdarkly/client/integrations/ClientWithFileDataSourceTest.java b/src/test/java/com/launchdarkly/client/integrations/ClientWithFileDataSourceTest.java deleted file mode 100644 index f7c365b41..000000000 --- a/src/test/java/com/launchdarkly/client/integrations/ClientWithFileDataSourceTest.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.launchdarkly.client.integrations; - -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.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 { - FileDataSourceBuilder fdsb = FileData.dataSource() - .filePaths(resourceFilePath("all-properties.json")); - LDConfig config = new LDConfig.Builder() - .dataSource(fdsb) - .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/integrations/PersistentDataStoreTestBase.java b/src/test/java/com/launchdarkly/client/integrations/PersistentDataStoreTestBase.java deleted file mode 100644 index 569efb3d3..000000000 --- a/src/test/java/com/launchdarkly/client/integrations/PersistentDataStoreTestBase.java +++ /dev/null @@ -1,329 +0,0 @@ -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/RedisDataStoreBuilderTest.java b/src/test/java/com/launchdarkly/client/integrations/RedisDataStoreBuilderTest.java deleted file mode 100644 index 7d14ccdb8..000000000 --- a/src/test/java/com/launchdarkly/client/integrations/RedisDataStoreBuilderTest.java +++ /dev/null @@ -1,81 +0,0 @@ -package com.launchdarkly.client.integrations; - -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 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"); - 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/RedisDataStoreImplTest.java b/src/test/java/com/launchdarkly/client/integrations/RedisDataStoreImplTest.java deleted file mode 100644 index f217b3347..000000000 --- a/src/test/java/com/launchdarkly/client/integrations/RedisDataStoreImplTest.java +++ /dev/null @@ -1,51 +0,0 @@ -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/utils/CachingStoreWrapperTest.java b/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java deleted file mode 100644 index 306501bd9..000000000 --- a/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java +++ /dev/null @@ -1,599 +0,0 @@ -package com.launchdarkly.client.utils; - -import com.google.common.collect.ImmutableMap; -import com.launchdarkly.client.FeatureStoreCacheConfig; -import com.launchdarkly.client.VersionedData; -import com.launchdarkly.client.VersionedDataKind; -import com.launchdarkly.client.integrations.CacheMonitor; - -import org.junit.Assert; -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.LinkedHashMap; -import java.util.Map; - -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.Assert.fail; -import static org.junit.Assume.assumeThat; - -@SuppressWarnings({ "javadoc", "deprecation" }) -@RunWith(Parameterized.class) -public class CachingStoreWrapperTest { - - 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(CachingMode.values()); - } - - public CachingStoreWrapperTest(CachingMode cachingMode) { - this.cachingMode = cachingMode; - this.core = new MockCore(); - this.wrapper = new CachingStoreWrapper(core, cachingMode.toCacheConfig(), null); - } - - @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(cachingMode.isCached() ? 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, cachingMode.isCached() ? 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, cachingMode.isCached() ? nullValue(MockItem.class) : equalTo(item)); // the cache can retain a null result - } - - @Test - public void cachedGetUsesValuesFromInit() { - assumeThat(cachingMode.isCached(), is(true)); - - 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, item1.key); - - assertThat(wrapper.get(THINGS, item1.key), equalTo(item1)); - } - - @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 (cachingMode.isCached()) { - assertThat(items, equalTo(expected)); - } else { - Map expected1 = ImmutableMap.of(item1.key, item1); - 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() { - assumeThat(cachingMode.isCached(), is(true)); - - 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 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)); - } - - core.fakeError = null; - assertThat(wrapper.all(THINGS).size(), equalTo(0)); - } - - @Test - public void cachedStoreWithInfiniteTtlUpdatesCacheEvenIfCoreInitFails() { - assumeThat(cachingMode, is(CachingMode.CACHED_INDEFINITELY)); - - 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)); - } - - 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); - 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 (cachingMode.isCached()) { - MockItem item1v3 = new MockItem("flag", 3, false); - core.forceSet(THINGS, item1v3); - } - - assertThat(wrapper.get(THINGS, itemv1.key), equalTo(itemv2)); - } - - @Test - public void cachedUpsertUnsuccessful() { - 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 - // 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 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); - 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, cachingMode.isCached() ? nullValue(MockItem.class) : equalTo(itemv3)); - } - - @Test - public void initializedCallsInternalMethodOnlyIfNotAlreadyInited() { - assumeThat(cachingMode.isCached(), 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(cachingMode.isCached(), 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(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), null)) { - 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)); - } - } - - @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) { - 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; - RuntimeException fakeError; - - @Override - public void close() throws IOException { - } - - @Override - public VersionedData getInternal(VersionedDataKind kind, String key) { - maybeThrow(); - if (data.containsKey(kind)) { - return data.get(kind).get(key); - } - return null; - } - - @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())); - } - inited = true; - } - - @Override - public VersionedData upsertInternal(VersionedDataKind kind, VersionedData item) { - maybeThrow(); - if (!data.containsKey(kind)) { - data.put(kind, new HashMap()); - } - Map items = data.get(kind); - VersionedData oldItem = items.get(item.getKey()); - if (oldItem != null && oldItem.getVersion() >= item.getVersion()) { - return oldItem; - } - items.put(item.getKey(), item); - return item; - } - - @Override - public boolean initializedInternal() { - maybeThrow(); - initedQueryCount++; - return inited; - } - - public void forceSet(VersionedDataKind kind, VersionedData item) { - if (!data.containsKey(kind)) { - data.put(kind, new HashMap()); - } - Map items = data.get(kind); - items.put(item.getKey(), item); - } - - public void forceRemove(VersionedDataKind kind, String key) { - if (data.containsKey(kind)) { - data.get(kind).remove(key); - } - } - - private void maybeThrow() { - if (fakeError != null) { - throw fakeError; - } - } - } - - 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); - } - }; -} diff --git a/src/test/java/com/launchdarkly/client/value/LDValueTest.java b/src/test/java/com/launchdarkly/client/value/LDValueTest.java deleted file mode 100644 index e4397a676..000000000 --- a/src/test/java/com/launchdarkly/client/value/LDValueTest.java +++ /dev/null @@ -1,529 +0,0 @@ -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.JsonArray; -import com.google.gson.JsonObject; -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 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); - 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 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)); - private static final LDValue anArrayValueFromJsonElement = LDValue.fromJsonElement(anArrayValue.asJsonElement()); - private static final LDValue anObjectValueFromJsonElement = LDValue.fromJsonElement(anObjectValue.asJsonElement()); - - @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()); - } - - @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, - aLongValue, - aLongValueFromJsonElement, - 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, - aLongValue, - aLongValueFromJsonElement, - 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(3L), - 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()); - assertEquals(value.toString(), 3L, value.longValue()); - } - } - - @Test - public void canGetFloatValueOfAnyNumericType() { - LDValue[] values = new LDValue[] { - LDValue.of(3), - LDValue.of(3L), - 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(3L), - 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, - aLongValue, - 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, - aLongValue, - 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 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); - } - - @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()); - 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(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()); - 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, - aLongValue, - aLongValueFromJsonElement, - 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(someLong), aLongValue.asJsonElement()); - assertEquals(new JsonPrimitive(someFloat), aFloatValue.asJsonElement()); - 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.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")); - } - - 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)); - } -} diff --git a/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java b/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java new file mode 100644 index 000000000..731427f9e --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java @@ -0,0 +1,236 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.server.DataModel.Clause; +import com.launchdarkly.sdk.server.DataModel.FeatureFlag; +import com.launchdarkly.sdk.server.DataModel.Operator; +import com.launchdarkly.sdk.server.DataModel.Rule; +import com.launchdarkly.sdk.server.DataModel.Segment; +import com.launchdarkly.sdk.server.DataModel.SegmentRule; +import com.launchdarkly.sdk.server.DataModel.Target; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; + +import org.junit.Test; + +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.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; + +@SuppressWarnings("javadoc") +public class DataModelSerializationTest { + @Test + public void flagIsDeserializedWithAllProperties() { + String json0 = flagWithAllPropertiesJson().toJsonString(); + FeatureFlag flag0 = (FeatureFlag)FEATURES.deserialize(json0).getItem(); + assertFlagHasAllProperties(flag0); + + String json1 = FEATURES.serialize(new ItemDescriptor(flag0.getVersion(), flag0)); + FeatureFlag flag1 = (FeatureFlag)FEATURES.deserialize(json1).getItem(); + assertFlagHasAllProperties(flag1); + } + + @Test + public void flagIsDeserializedWithMinimalProperties() { + String json = LDValue.buildObject().put("key", "flag-key").put("version", 99).build().toJsonString(); + FeatureFlag flag = (FeatureFlag)FEATURES.deserialize(json).getItem(); + assertEquals("flag-key", flag.getKey()); + assertEquals(99, flag.getVersion()); + assertFalse(flag.isOn()); + assertNull(flag.getSalt()); + assertNotNull(flag.getTargets()); + assertEquals(0, flag.getTargets().size()); + assertNotNull(flag.getRules()); + assertEquals(0, flag.getRules().size()); + assertNull(flag.getFallthrough()); + assertNull(flag.getOffVariation()); + assertNotNull(flag.getVariations()); + assertEquals(0, flag.getVariations().size()); + assertFalse(flag.isClientSide()); + assertFalse(flag.isTrackEvents()); + assertFalse(flag.isTrackEventsFallthrough()); + assertNull(flag.getDebugEventsUntilDate()); + } + + @Test + public void segmentIsDeserializedWithAllProperties() { + String json0 = segmentWithAllPropertiesJson().toJsonString(); + Segment segment0 = (Segment)SEGMENTS.deserialize(json0).getItem(); + assertSegmentHasAllProperties(segment0); + + String json1 = SEGMENTS.serialize(new ItemDescriptor(segment0.getVersion(), segment0)); + Segment segment1 = (Segment)SEGMENTS.deserialize(json1).getItem(); + assertSegmentHasAllProperties(segment1); + } + + @Test + public void segmentIsDeserializedWithMinimalProperties() { + String json = LDValue.buildObject().put("key", "segment-key").put("version", 99).build().toJsonString(); + Segment segment = (Segment)SEGMENTS.deserialize(json).getItem(); + assertEquals("segment-key", segment.getKey()); + assertEquals(99, segment.getVersion()); + assertNotNull(segment.getIncluded()); + assertEquals(0, segment.getIncluded().size()); + assertNotNull(segment.getExcluded()); + assertEquals(0, segment.getExcluded().size()); + assertNotNull(segment.getRules()); + assertEquals(0, segment.getRules().size()); + } + + 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(UserAttribute.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(UserAttribute.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()); + } + + private LDValue segmentWithAllPropertiesJson() { + return LDValue.buildObject() + .put("key", "segment-key") + .put("version", 99) + .put("included", LDValue.buildArray().add("key1").add("key2").build()) + .put("excluded", LDValue.buildArray().add("key3").add("key4").build()) + .put("salt", "123") + .put("rules", LDValue.buildArray() + .add(LDValue.buildObject() + .put("weight", 50000) + .put("bucketBy", "email") + .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() + .build()) + .build()) + .build(); + } + + private void assertSegmentHasAllProperties(Segment segment) { + assertEquals("segment-key", segment.getKey()); + assertEquals(99, segment.getVersion()); + assertEquals("123", segment.getSalt()); + assertEquals(ImmutableSet.of("key1", "key2"), segment.getIncluded()); + assertEquals(ImmutableSet.of("key3", "key4"), segment.getExcluded()); + + assertNotNull(segment.getRules()); + assertEquals(2, segment.getRules().size()); + SegmentRule r0 = segment.getRules().get(0); + assertEquals(new Integer(50000), r0.getWeight()); + assertNotNull(r0.getClauses()); + assertEquals(1, r0.getClauses().size()); + Clause c0 = r0.getClauses().get(0); + assertEquals(UserAttribute.NAME, c0.getAttribute()); + assertEquals(Operator.in, c0.getOp()); + assertEquals(ImmutableList.of(LDValue.of("Lucy")), c0.getValues()); + assertTrue(c0.isNegate()); + SegmentRule r1 = segment.getRules().get(1); + assertNull(r1.getWeight()); + assertNull(r1.getBucketBy()); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/DataStoreTestBase.java b/src/test/java/com/launchdarkly/sdk/server/DataStoreTestBase.java new file mode 100644 index 000000000..38fef5927 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/DataStoreTestBase.java @@ -0,0 +1,168 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.DataStoreTestTypes.DataBuilder; +import com.launchdarkly.sdk.server.DataStoreTestTypes.TestItem; +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.util.Map; + +import static com.launchdarkly.sdk.server.DataStoreTestTypes.OTHER_TEST_ITEMS; +import static com.launchdarkly.sdk.server.DataStoreTestTypes.TEST_ITEMS; +import static com.launchdarkly.sdk.server.DataStoreTestTypes.toItemsMap; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** + * Basic tests for FeatureStore implementations. For database implementations, use the more + * comprehensive FeatureStoreDatabaseTestBase. + */ +@SuppressWarnings("javadoc") +public abstract class DataStoreTestBase { + + protected DataStore store; + + protected TestItem item1 = new TestItem("key1", "first", 10); + + protected TestItem item2 = new TestItem("key2", "second", 10); + + protected TestItem otherItem1 = new TestItem("key1", "other-first", 11); + + /** + * Test subclasses must override this method to create an instance of the feature store class. + * @return + */ + protected abstract DataStore makeStore(); + + @Before + public void setup() { + store = makeStore(); + } + + @After + public void teardown() throws Exception { + store.close(); + } + + @Test + public void storeNotInitializedBeforeInit() { + assertFalse(store.isInitialized()); + } + + @Test + public void storeInitializedAfterInit() { + store.init(new DataBuilder().build()); + assertTrue(store.isInitialized()); + } + + @Test + public void initCompletelyReplacesPreviousData() { + FullDataSet allData = + new DataBuilder().add(TEST_ITEMS, item1, item2).add(OTHER_TEST_ITEMS, otherItem1).build(); + store.init(allData); + + 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(TEST_ITEMS, item1.key)); + assertEquals(item2v2.toItemDescriptor(), store.get(TEST_ITEMS, item2.key)); + assertNull(store.get(OTHER_TEST_ITEMS, otherItem1.key)); + } + + @Test + public void getExistingItem() { + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).build()); + assertEquals(item1.toItemDescriptor(), store.get(TEST_ITEMS, item1.key)); + } + + @Test + 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(TEST_ITEMS, item1, item2).add(OTHER_TEST_ITEMS, otherItem1).build()); + Map items = toItemsMap(store.getAll(TEST_ITEMS)); + assertEquals(2, items.size()); + assertEquals(item1.toItemDescriptor(), items.get(item1.key)); + assertEquals(item2.toItemDescriptor(), items.get(item2.key)); + } + + @Test + public void getAllWithDeletedItem() { + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).build()); + ItemDescriptor deletedItem = ItemDescriptor.deletedItem(item1.getVersion() + 1); + store.upsert(TEST_ITEMS, item1.key, deletedItem); + Map items = toItemsMap(store.getAll(TEST_ITEMS)); + assertEquals(2, items.size()); + assertEquals(deletedItem, items.get(item1.key)); + assertEquals(item2.toItemDescriptor(), items.get(item2.key)); + } + + @Test + public void upsertWithNewerVersion() { + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).build()); + TestItem newVer = item1.withVersion(item1.version + 1); + store.upsert(TEST_ITEMS, item1.key, newVer.toItemDescriptor()); + assertEquals(newVer.toItemDescriptor(), store.get(TEST_ITEMS, item1.key)); + } + + @Test + public void upsertWithOlderVersion() { + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).build()); + TestItem oldVer = item1.withVersion(item1.version - 1); + store.upsert(TEST_ITEMS, item1.key, oldVer.toItemDescriptor()); + assertEquals(item1.toItemDescriptor(), store.get(TEST_ITEMS, item1.key)); + } + + @Test + 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.key, newItem.toItemDescriptor()); + assertEquals(newItem.toItemDescriptor(), store.get(TEST_ITEMS, newItem.key)); + } + + @Test + public void deleteWithNewerVersion() { + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).build()); + ItemDescriptor deletedItem = ItemDescriptor.deletedItem(item1.version + 1); + store.upsert(TEST_ITEMS, item1.key, deletedItem); + assertEquals(deletedItem, store.get(TEST_ITEMS, item1.key)); + } + + @Test + public void deleteWithOlderVersion() { + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).build()); + ItemDescriptor deletedItem = ItemDescriptor.deletedItem(item1.version - 1); + store.upsert(TEST_ITEMS, item1.key, deletedItem); + assertEquals(item1.toItemDescriptor(), store.get(TEST_ITEMS, item1.key)); + } + + @Test + public void deleteUnknownItem() { + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).build()); + ItemDescriptor deletedItem = ItemDescriptor.deletedItem(item1.version - 1); + store.upsert(TEST_ITEMS, "biz", deletedItem); + assertEquals(deletedItem, store.get(TEST_ITEMS, "biz")); + } + + @Test + public void upsertOlderVersionAfterDelete() { + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).build()); + ItemDescriptor deletedItem = ItemDescriptor.deletedItem(item1.version + 1); + store.upsert(TEST_ITEMS, item1.key, deletedItem); + store.upsert(TEST_ITEMS, item1.key, item1.toItemDescriptor()); + assertEquals(deletedItem, store.get(TEST_ITEMS, item1.key)); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/DataStoreTestTypes.java b/src/test/java/com/launchdarkly/sdk/server/DataStoreTestTypes.java new file mode 100644 index 000000000..e69eeb77d --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/DataStoreTestTypes.java @@ -0,0 +1,181 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Maps; +import com.launchdarkly.sdk.server.DataModel.VersionedData; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.SerializedItemDescriptor; + +import java.util.AbstractMap; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +import static com.google.common.collect.Iterables.transform; +import static com.launchdarkly.sdk.server.TestUtil.TEST_GSON_INSTANCE; + +@SuppressWarnings("javadoc") +public class DataStoreTestTypes { + public static Map> toDataMap(FullDataSet data) { + return ImmutableMap.copyOf(transform(data.getData(), e -> new AbstractMap.SimpleEntry<>(e.getKey(), toItemsMap(e.getValue())))); + } + + public static Map toItemsMap(KeyedItems data) { + return ImmutableMap.copyOf(data.getItems()); + } + + public static SerializedItemDescriptor toSerialized(DataKind kind, ItemDescriptor item) { + boolean isDeleted = item.getItem() == null; + return new SerializedItemDescriptor(item.getVersion(), isDeleted, kind.serialize(item)); + } + + public static class TestItem implements VersionedData { + public final String key; + public final String name; + public final int version; + public final boolean deleted; + + public TestItem(String key, String name, int version, boolean deleted) { + this.key = key; + this.name = name; + this.version = version; + this.deleted = deleted; + } + + public TestItem(String key, String name, int version) { + this(key, name, version, false); + } + + public TestItem(String key, int version) { + this(key, "", version); + } + + @Override + public String getKey() { + return key; + } + + @Override + public int getVersion() { + return version; + } + + public boolean isDeleted() { + return deleted; + } + + public TestItem withName(String newName) { + return new TestItem(key, newName, version); + } + + public TestItem withVersion(int newVersion) { + return new TestItem(key, name, newVersion); + } + + public ItemDescriptor toItemDescriptor() { + return new ItemDescriptor(version, this); + } + + public SerializedItemDescriptor toSerializedItemDescriptor() { + return toSerialized(TEST_ITEMS, toItemDescriptor()); + } + + @Override + public boolean equals(Object other) { + if (other instanceof TestItem) { + TestItem o = (TestItem)other; + return Objects.equals(name, o.name) && + Objects.equals(key, o.key) && + version == o.version; + } + return false; + } + + @Override + public int hashCode() { + return Objects.hash(name, key, version); + } + + @Override + public String toString() { + return "TestItem(" + name + "," + key + "," + version + ")"; + } + } + + public static final DataKind TEST_ITEMS = new DataKind("test-items", + DataStoreTestTypes::serializeTestItem, + DataStoreTestTypes::deserializeTestItem); + + public static final DataKind OTHER_TEST_ITEMS = new DataKind("other-test-items", + DataStoreTestTypes::serializeTestItem, + DataStoreTestTypes::deserializeTestItem); + + private static String serializeTestItem(ItemDescriptor item) { + if (item.getItem() == null) { + return "DELETED:" + item.getVersion(); + } + return TEST_GSON_INSTANCE.toJson(item.getItem()); + } + + private static ItemDescriptor deserializeTestItem(String s) { + if (s.startsWith("DELETED:")) { + return ItemDescriptor.deletedItem(Integer.parseInt(s.substring(8))); + } + TestItem ti = TEST_GSON_INSTANCE.fromJson(s, TestItem.class); + return new ItemDescriptor(ti.version, ti); + } + + public static class DataBuilder { + private Map> data = new HashMap<>(); + + public DataBuilder add(DataKind kind, TestItem... items) { + return addAny(kind, items); + } + + // This is defined separately because test code that's outside of this package can't see DataModel.VersionedData + public DataBuilder addAny(DataKind kind, VersionedData... items) { + Map itemsMap = data.get(kind); + if (itemsMap == null) { + itemsMap = new HashMap<>(); + data.put(kind, itemsMap); + } + for (VersionedData item: items) { + itemsMap.put(item.getKey(), new ItemDescriptor(item.getVersion(), item)); + } + return this; + } + + public DataBuilder remove(DataKind kind, String key) { + if (data.get(kind) != null) { + data.get(kind).remove(key); + } + return this; + } + + public FullDataSet build() { + return new FullDataSet<>( + ImmutableMap.copyOf( + Maps.transformValues(data, itemsMap -> + new KeyedItems<>(ImmutableList.copyOf(itemsMap.entrySet())) + )).entrySet() + ); + } + + public FullDataSet buildSerialized() { + return new FullDataSet<>( + ImmutableMap.copyOf( + Maps.transformEntries(data, (kind, itemsMap) -> + new KeyedItems<>( + ImmutableMap.copyOf( + Maps.transformValues(itemsMap, item -> DataStoreTestTypes.toSerialized(kind, item)) + ).entrySet() + ) + ) + ).entrySet()); + } + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/DataStoreUpdatesImplTest.java b/src/test/java/com/launchdarkly/sdk/server/DataStoreUpdatesImplTest.java new file mode 100644 index 000000000..4f041624c --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/DataStoreUpdatesImplTest.java @@ -0,0 +1,373 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.base.Joiner; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Iterables; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.DataModel.Operator; +import com.launchdarkly.sdk.server.DataStoreTestTypes.DataBuilder; +import com.launchdarkly.sdk.server.TestUtil.FlagChangeEventSink; +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; + +import org.easymock.Capture; +import org.easymock.EasyMock; +import org.easymock.EasyMockSupport; +import org.junit.Test; + +import java.util.List; +import java.util.Map; + +import static com.google.common.collect.Iterables.transform; +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; +import static com.launchdarkly.sdk.server.DataStoreTestTypes.toDataMap; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.prerequisite; +import static com.launchdarkly.sdk.server.ModelBuilders.ruleBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.segmentBuilder; +import static com.launchdarkly.sdk.server.TestComponents.inMemoryDataStore; +import static org.easymock.EasyMock.replay; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +@SuppressWarnings("javadoc") +public class DataStoreUpdatesImplTest extends EasyMockSupport { + // Note that these tests must use the actual data model types for flags and segments, rather than the + // TestItem type from DataStoreTestTypes, because the dependency behavior is based on the real data model. + + @Test + public void doesNotTryToSendEventsIfThereIsNoEventPublisher() { + DataStore store = inMemoryDataStore(); + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, null); + storeUpdates.upsert(DataModel.FEATURES, "key", new ItemDescriptor(1, flagBuilder("key").build())); + // the test is just that this doesn't cause an exception + } + + @Test + public void sendsEventsOnInitForNewlyAddedFlags() throws Exception { + DataStore store = inMemoryDataStore(); + DataBuilder builder = new DataBuilder() + .addAny(FEATURES, + flagBuilder("flag1").version(1).build()) + .addAny(SEGMENTS, + segmentBuilder("segment1").version(1).build()); + + try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); + + storeUpdates.init(builder.build()); + + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + publisher.register(eventSink); + + builder.addAny(FEATURES, flagBuilder("flag2").version(1).build()) + .addAny(SEGMENTS, segmentBuilder("segment2").version(1).build()); + // the new segment triggers no events since nothing is using it + + storeUpdates.init(builder.build()); + + eventSink.expectEvents("flag2"); + } + } + + @Test + public void sendsEventOnUpdateForNewlyAddedFlag() throws Exception { + DataStore store = inMemoryDataStore(); + DataBuilder builder = new DataBuilder() + .addAny(FEATURES, + flagBuilder("flag1").version(1).build()) + .addAny(SEGMENTS, + segmentBuilder("segment1").version(1).build()); + + try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); + + storeUpdates.init(builder.build()); + + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + publisher.register(eventSink); + + storeUpdates.upsert(FEATURES, "flag2", new ItemDescriptor(1, flagBuilder("flag2").version(1).build())); + + eventSink.expectEvents("flag2"); + } + } + + @Test + public void sendsEventsOnInitForUpdatedFlags() throws Exception { + DataStore store = inMemoryDataStore(); + DataBuilder builder = new DataBuilder() + .addAny(FEATURES, + flagBuilder("flag1").version(1).build(), + flagBuilder("flag2").version(1).build()) + .addAny(SEGMENTS, + segmentBuilder("segment1").version(1).build(), + segmentBuilder("segment2").version(1).build()); + + try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); + + storeUpdates.init(builder.build()); + + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + publisher.register(eventSink); + + builder.addAny(FEATURES, flagBuilder("flag2").version(2).build()) // modified flag + .addAny(SEGMENTS, segmentBuilder("segment2").version(2).build()); // modified segment, but it's irrelevant + storeUpdates.init(builder.build()); + + eventSink.expectEvents("flag2"); + } + } + + @Test + public void sendsEventOnUpdateForUpdatedFlag() throws Exception { + DataStore store = inMemoryDataStore(); + DataBuilder builder = new DataBuilder() + .addAny(FEATURES, + flagBuilder("flag1").version(1).build(), + flagBuilder("flag2").version(1).build()) + .addAny(SEGMENTS, + segmentBuilder("segment1").version(1).build()); + + try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); + + storeUpdates.init(builder.build()); + + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + publisher.register(eventSink); + + storeUpdates.upsert(FEATURES, "flag2", new ItemDescriptor(2, flagBuilder("flag2").version(2).build())); + + eventSink.expectEvents("flag2"); + } + } + + @Test + public void sendsEventsOnInitForDeletedFlags() throws Exception { + DataStore store = inMemoryDataStore(); + DataBuilder builder = new DataBuilder() + .addAny(FEATURES, + flagBuilder("flag1").version(1).build(), + flagBuilder("flag2").version(1).build()) + .addAny(SEGMENTS, + segmentBuilder("segment1").version(1).build()); + + try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); + + storeUpdates.init(builder.build()); + + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + publisher.register(eventSink); + + builder.remove(FEATURES, "flag2"); + builder.remove(SEGMENTS, "segment1"); // deleted segment isn't being used so it's irrelevant + // note that the full data set for init() will never include deleted item placeholders + + storeUpdates.init(builder.build()); + + eventSink.expectEvents("flag2"); + } + } + + @Test + public void sendsEventOnUpdateForDeletedFlag() throws Exception { + DataStore store = inMemoryDataStore(); + DataBuilder builder = new DataBuilder() + .addAny(FEATURES, + flagBuilder("flag1").version(1).build(), + flagBuilder("flag2").version(1).build()) + .addAny(SEGMENTS, + segmentBuilder("segment1").version(1).build()); + + try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); + + storeUpdates.init(builder.build()); + + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + publisher.register(eventSink); + + storeUpdates.upsert(FEATURES, "flag2", ItemDescriptor.deletedItem(2)); + + eventSink.expectEvents("flag2"); + } + } + + @Test + public void sendsEventsOnInitForFlagsWhosePrerequisitesChanged() throws Exception { + DataStore store = inMemoryDataStore(); + DataBuilder builder = new DataBuilder() + .addAny(FEATURES, + flagBuilder("flag1").version(1).build(), + flagBuilder("flag2").version(1).prerequisites(prerequisite("flag1", 0)).build(), + flagBuilder("flag3").version(1).build(), + flagBuilder("flag4").version(1).prerequisites(prerequisite("flag1", 0)).build(), + flagBuilder("flag5").version(1).prerequisites(prerequisite("flag4", 0)).build(), + flagBuilder("flag6").version(1).build()); + + try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); + + storeUpdates.init(builder.build()); + + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + publisher.register(eventSink); + + builder.addAny(FEATURES, flagBuilder("flag1").version(2).build()); + storeUpdates.init(builder.build()); + + eventSink.expectEvents("flag1", "flag2", "flag4", "flag5"); + } + } + + @Test + public void sendsEventsOnUpdateForFlagsWhosePrerequisitesChanged() throws Exception { + DataStore store = inMemoryDataStore(); + DataBuilder builder = new DataBuilder() + .addAny(FEATURES, + flagBuilder("flag1").version(1).build(), + flagBuilder("flag2").version(1).prerequisites(prerequisite("flag1", 0)).build(), + flagBuilder("flag3").version(1).build(), + flagBuilder("flag4").version(1).prerequisites(prerequisite("flag1", 0)).build(), + flagBuilder("flag5").version(1).prerequisites(prerequisite("flag4", 0)).build(), + flagBuilder("flag6").version(1).build()); + + try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); + + storeUpdates.init(builder.build()); + + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + publisher.register(eventSink); + + storeUpdates.upsert(FEATURES, "flag1", new ItemDescriptor(2, flagBuilder("flag1").version(2).build())); + + eventSink.expectEvents("flag1", "flag2", "flag4", "flag5"); + } + } + + @Test + public void sendsEventsOnInitForFlagsWhoseSegmentsChanged() throws Exception { + DataStore store = inMemoryDataStore(); + DataBuilder builder = new DataBuilder() + .addAny(FEATURES, + flagBuilder("flag1").version(1).build(), + flagBuilder("flag2").version(1).rules( + ruleBuilder().clauses( + ModelBuilders.clause(null, Operator.segmentMatch, LDValue.of("segment1")) + ).build() + ).build(), + flagBuilder("flag3").version(1).build(), + flagBuilder("flag4").version(1).prerequisites(prerequisite("flag2", 0)).build()) + .addAny(SEGMENTS, + segmentBuilder("segment1").version(1).build(), + segmentBuilder("segment2").version(1).build()); + + try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); + + storeUpdates.init(builder.build()); + + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + publisher.register(eventSink); + + storeUpdates.upsert(SEGMENTS, "segment1", new ItemDescriptor(2, segmentBuilder("segment1").version(2).build())); + + eventSink.expectEvents("flag2", "flag4"); + } + } + + @Test + public void sendsEventsOnUpdateForFlagsWhoseSegmentsChanged() throws Exception { + DataStore store = inMemoryDataStore(); + DataBuilder builder = new DataBuilder() + .addAny(FEATURES, + flagBuilder("flag1").version(1).build(), + flagBuilder("flag2").version(1).rules( + ruleBuilder().clauses( + ModelBuilders.clause(null, Operator.segmentMatch, LDValue.of("segment1")) + ).build() + ).build(), + flagBuilder("flag3").version(1).build(), + flagBuilder("flag4").version(1).prerequisites(prerequisite("flag2", 0)).build()) + .addAny(SEGMENTS, + segmentBuilder("segment1").version(1).build(), + segmentBuilder("segment2").version(1).build()); + + try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); + + storeUpdates.init(builder.build()); + + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + publisher.register(eventSink); + + builder.addAny(SEGMENTS, segmentBuilder("segment1").version(2).build()); + storeUpdates.init(builder.build()); + + eventSink.expectEvents("flag2", "flag4"); + } + } + + @Test + public void dataSetIsPassedToDataStoreInCorrectOrder() throws Exception { + // This verifies that the client is using DataStoreClientWrapper and that it is applying the + // correct ordering for flag prerequisites, etc. This should work regardless of what kind of + // DataSource we're using. + + Capture> captureData = Capture.newInstance(); + DataStore store = createStrictMock(DataStore.class); + store.init(EasyMock.capture(captureData)); + replay(store); + + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, null); + storeUpdates.init(DEPENDENCY_ORDERING_TEST_DATA); + + Map> dataMap = toDataMap(captureData.getValue()); + assertEquals(2, dataMap.size()); + Map> inputDataMap = toDataMap(DEPENDENCY_ORDERING_TEST_DATA); + + // Segments should always come first + assertEquals(SEGMENTS, Iterables.get(dataMap.keySet(), 0)); + assertEquals(inputDataMap.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(transform(map1.values(), d -> (DataModel.FeatureFlag)d.getItem())); + assertEquals(inputDataMap.get(FEATURES).size(), map1.size()); + for (int itemIndex = 0; itemIndex < list1.size(); itemIndex++) { + DataModel.FeatureFlag item = list1.get(itemIndex); + for (DataModel.Prerequisite prereq: item.getPrerequisites()) { + DataModel.FeatureFlag depFlag = (DataModel.FeatureFlag)map1.get(prereq.getKey()).getItem(); + int depIndex = list1.indexOf(depFlag); + if (depIndex > itemIndex) { + 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(map1.keySet()))); + } + } + } + } + + private static FullDataSet DEPENDENCY_ORDERING_TEST_DATA = + new DataBuilder() + .addAny(FEATURES, + flagBuilder("a") + .prerequisites(prerequisite("b", 0), prerequisite("c", 0)).build(), + flagBuilder("b") + .prerequisites(prerequisite("c", 0), prerequisite("e", 0)).build(), + flagBuilder("c").build(), + flagBuilder("d").build(), + flagBuilder("e").build(), + flagBuilder("f").build()) + .addAny(SEGMENTS, + segmentBuilder("o").build()) + .build(); +} diff --git a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java similarity index 77% rename from src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java rename to src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java index 3ec9aab0b..5ed29e9e0 100644 --- a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java @@ -1,11 +1,14 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.common.collect.ImmutableSet; 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 com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.server.integrations.EventProcessorBuilder; +import com.launchdarkly.sdk.server.interfaces.Event; +import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; import org.hamcrest.Matcher; import org.hamcrest.Matchers; @@ -13,16 +16,19 @@ import java.net.URI; import java.text.SimpleDateFormat; +import java.time.Duration; import java.util.Date; 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; -import static com.launchdarkly.client.TestUtil.isJsonArray; -import static com.launchdarkly.client.TestUtil.simpleEvaluation; +import static com.launchdarkly.sdk.server.Components.sendEvents; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.TestComponents.clientContext; +import static com.launchdarkly.sdk.server.TestHttpUtil.httpsServerWithSelfSignedCert; +import static com.launchdarkly.sdk.server.TestHttpUtil.makeStartedServer; +import static com.launchdarkly.sdk.server.TestUtil.hasJsonProperty; +import static com.launchdarkly.sdk.server.TestUtil.isJsonArray; +import static com.launchdarkly.sdk.server.TestUtil.simpleEvaluation; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.allOf; import static org.hamcrest.Matchers.contains; @@ -45,10 +51,9 @@ public class DefaultEventProcessorTest { private static final String SDK_KEY = "SDK_KEY"; private static final LDUser user = new LDUser.Builder("userkey").name("Red").build(); private static final Gson gson = new Gson(); - private static final JsonElement userJson = - gson.fromJson("{\"key\":\"userkey\",\"name\":\"Red\"}", JsonElement.class); - private static final JsonElement filteredUserJson = - gson.fromJson("{\"key\":\"userkey\",\"privateAttrs\":[\"name\"]}", JsonElement.class); + private static final LDValue userJson = LDValue.buildObject().put("key", "userkey").put("name", "Red").build(); + private static final LDValue filteredUserJson = LDValue.buildObject().put("key", "userkey") + .put("privateAttrs", LDValue.buildArray().add("name").build()).build(); 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(); @@ -65,29 +70,29 @@ private DefaultEventProcessor makeEventProcessor(EventProcessorBuilder ec) { } private DefaultEventProcessor makeEventProcessor(EventProcessorBuilder ec, LDConfig config) { - return (DefaultEventProcessor)ec.createEventProcessor(SDK_KEY, config); + return (DefaultEventProcessor)ec.createEventProcessor(clientContext(SDK_KEY, config)); } private DefaultEventProcessor makeEventProcessor(EventProcessorBuilder ec, DiagnosticAccumulator diagnosticAccumulator) { - return (DefaultEventProcessor)((EventProcessorFactoryWithDiagnostics)ec).createEventProcessor(SDK_KEY, - diagLDConfig, diagnosticAccumulator); + return (DefaultEventProcessor)ec.createEventProcessor( + clientContext(SDK_KEY, diagLDConfig, diagnosticAccumulator)); } @Test public void builderHasDefaultConfiguration() throws Exception { EventProcessorFactory epf = Components.sendEvents(); - try (DefaultEventProcessor ep = (DefaultEventProcessor)epf.createEventProcessor(SDK_KEY, LDConfig.DEFAULT)) { + try (DefaultEventProcessor ep = (DefaultEventProcessor)epf.createEventProcessor(clientContext(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.diagnosticRecordingInterval, equalTo(EventProcessorBuilder.DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL)); assertThat(ec.eventsUri, equalTo(LDConfig.DEFAULT_EVENTS_URI)); - assertThat(ec.flushIntervalSeconds, equalTo(EventProcessorBuilder.DEFAULT_FLUSH_INTERVAL_SECONDS)); + assertThat(ec.flushInterval, equalTo(EventProcessorBuilder.DEFAULT_FLUSH_INTERVAL)); assertThat(ec.inlineUsersInEvents, is(false)); - assertThat(ec.privateAttrNames, equalTo(ImmutableSet.of())); + assertThat(ec.privateAttributes, 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)); + assertThat(ec.userKeysFlushInterval, equalTo(EventProcessorBuilder.DEFAULT_USER_KEYS_FLUSH_INTERVAL)); } } @@ -98,96 +103,34 @@ public void builderCanSpecifyConfiguration() throws Exception { .allAttributesPrivate(true) .baseURI(uri) .capacity(3333) - .diagnosticRecordingIntervalSeconds(480) - .flushIntervalSeconds(99) - .privateAttributeNames("cats", "dogs") + .diagnosticRecordingInterval(Duration.ofSeconds(480)) + .flushInterval(Duration.ofSeconds(99)) + .privateAttributeNames("name", "dogs") .userKeysCapacity(555) - .userKeysFlushIntervalSeconds(101); - try (DefaultEventProcessor ep = (DefaultEventProcessor)epf.createEventProcessor(SDK_KEY, LDConfig.DEFAULT)) { + .userKeysFlushInterval(Duration.ofSeconds(101)); + try (DefaultEventProcessor ep = (DefaultEventProcessor)epf.createEventProcessor(clientContext(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.diagnosticRecordingInterval, equalTo(Duration.ofSeconds(480))); assertThat(ec.eventsUri, equalTo(uri)); - assertThat(ec.flushIntervalSeconds, equalTo(99)); + assertThat(ec.flushInterval, equalTo(Duration.ofSeconds(99))); assertThat(ec.inlineUsersInEvents, is(false)); // will test this separately below - assertThat(ec.privateAttrNames, equalTo(ImmutableSet.of("cats", "dogs"))); + assertThat(ec.privateAttributes, equalTo(ImmutableSet.of(UserAttribute.NAME, UserAttribute.forName("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)); + assertThat(ec.userKeysFlushInterval, equalTo(Duration.ofSeconds(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)) { + try (DefaultEventProcessor ep = (DefaultEventProcessor)epf1.createEventProcessor(clientContext(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); @@ -221,7 +164,7 @@ public void userIsFilteredInIdentifyEvent() throws Exception { @SuppressWarnings("unchecked") @Test public void individualFeatureEventIsQueuedWithIndexEvent() throws Exception { - FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true).build(); + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); @@ -241,7 +184,7 @@ public void individualFeatureEventIsQueuedWithIndexEvent() throws Exception { @SuppressWarnings("unchecked") @Test public void userIsFilteredInIndexEvent() throws Exception { - FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true).build(); + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); @@ -261,7 +204,7 @@ public void userIsFilteredInIndexEvent() throws Exception { @SuppressWarnings("unchecked") @Test public void featureEventCanContainInlineUser() throws Exception { - FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true).build(); + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); @@ -280,7 +223,7 @@ public void featureEventCanContainInlineUser() throws Exception { @SuppressWarnings("unchecked") @Test public void userIsFilteredInFeatureEvent() throws Exception { - FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true).build(); + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); @@ -300,10 +243,10 @@ public void userIsFilteredInFeatureEvent() throws Exception { @SuppressWarnings("unchecked") @Test public void featureEventCanContainReason() throws Exception { - FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true).build(); + DataModel.FeatureFlag flag = flagBuilder("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()); + new Evaluator.EvalResult(LDValue.of("value"), 1, reason), LDValue.ofNull()); try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server))) { @@ -321,7 +264,7 @@ public void featureEventCanContainReason() throws Exception { @SuppressWarnings("unchecked") @Test public void indexEventIsStillGeneratedIfInlineUsersIsTrueButFeatureEventIsNotTracked() throws Exception { - FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(false).build(); + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(false).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), null); @@ -341,7 +284,7 @@ public void indexEventIsStillGeneratedIfInlineUsersIsTrueButFeatureEventIsNotTra @Test public void eventKindIsDebugIfFlagIsTemporarilyInDebugMode() throws Exception { long futureTime = System.currentTimeMillis() + 1000000; - FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).debugEventsUntilDate(futureTime).build(); + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).debugEventsUntilDate(futureTime).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); @@ -362,7 +305,7 @@ public void eventKindIsDebugIfFlagIsTemporarilyInDebugMode() throws Exception { @Test public void eventCanBeBothTrackedAndDebugged() throws Exception { long futureTime = System.currentTimeMillis() + 1000000; - FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true) + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true) .debugEventsUntilDate(futureTime).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); @@ -390,7 +333,7 @@ public void debugModeExpiresBasedOnClientTimeIfClientTimeIsLaterThanServerTime() MockResponse resp2 = eventsSuccessResponse(); long debugUntil = serverTime + 1000; - FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).debugEventsUntilDate(debugUntil).build(); + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).debugEventsUntilDate(debugUntil).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); @@ -409,7 +352,7 @@ public void debugModeExpiresBasedOnClientTimeIfClientTimeIsLaterThanServerTime() assertThat(getEventsFromLastRequest(server), contains( isIndexEvent(fe, userJson), - isSummaryEvent(fe.creationDate, fe.creationDate) + isSummaryEvent(fe.getCreationDate(), fe.getCreationDate()) )); } } @@ -423,7 +366,7 @@ public void debugModeExpiresBasedOnServerTimeIfServerTimeIsLaterThanClientTime() MockResponse resp2 = eventsSuccessResponse(); long debugUntil = serverTime - 1000; - FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).debugEventsUntilDate(debugUntil).build(); + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).debugEventsUntilDate(debugUntil).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); @@ -443,7 +386,7 @@ public void debugModeExpiresBasedOnServerTimeIfServerTimeIsLaterThanClientTime() // Should get a summary event only, not a full feature event assertThat(getEventsFromLastRequest(server), contains( isIndexEvent(fe, userJson), - isSummaryEvent(fe.creationDate, fe.creationDate) + isSummaryEvent(fe.getCreationDate(), fe.getCreationDate()) )); } } @@ -451,8 +394,8 @@ public void debugModeExpiresBasedOnServerTimeIfServerTimeIsLaterThanClientTime() @SuppressWarnings("unchecked") @Test 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(); + DataModel.FeatureFlag flag1 = flagBuilder("flagkey1").version(11).trackEvents(true).build(); + DataModel.FeatureFlag flag2 = flagBuilder("flagkey2").version(22).trackEvents(true).build(); LDValue value = LDValue.of("value"); Event.FeatureRequest fe1 = EventFactory.DEFAULT.newFeatureRequestEvent(flag1, user, simpleEvaluation(1, value), LDValue.ofNull()); @@ -469,7 +412,7 @@ public void twoFeatureEventsForSameUserGenerateOnlyOneIndexEvent() throws Except isIndexEvent(fe1, userJson), isFeatureEvent(fe1, flag1, false, null), isFeatureEvent(fe2, flag2, false, null), - isSummaryEvent(fe1.creationDate, fe2.creationDate) + isSummaryEvent(fe1.getCreationDate(), fe2.getCreationDate()) )); } } @@ -478,7 +421,7 @@ public void twoFeatureEventsForSameUserGenerateOnlyOneIndexEvent() throws Except @Test public void identifyEventMakesIndexEventUnnecessary() throws Exception { Event ie = EventFactory.DEFAULT.newIdentifyEvent(user); - FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true).build(); + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), null); @@ -500,8 +443,8 @@ public void identifyEventMakesIndexEventUnnecessary() throws Exception { @SuppressWarnings("unchecked") @Test public void nonTrackedEventsAreSummarized() throws Exception { - FeatureFlag flag1 = new FeatureFlagBuilder("flagkey1").version(11).build(); - FeatureFlag flag2 = new FeatureFlagBuilder("flagkey2").version(22).build(); + DataModel.FeatureFlag flag1 = flagBuilder("flagkey1").version(11).build(); + DataModel.FeatureFlag flag2 = flagBuilder("flagkey2").version(22).build(); LDValue value1 = LDValue.of("value1"); LDValue value2 = LDValue.of("value2"); LDValue default1 = LDValue.of("default1"); @@ -526,7 +469,7 @@ public void nonTrackedEventsAreSummarized() throws Exception { assertThat(getEventsFromLastRequest(server), contains( isIndexEvent(fe1a, userJson), allOf( - isSummaryEvent(fe1a.creationDate, fe2.creationDate), + isSummaryEvent(fe1a.getCreationDate(), fe2.getCreationDate()), hasSummaryFlag(flag1.getKey(), default1, Matchers.containsInAnyOrder( isSummaryEventCounter(flag1, 1, value1, 2), @@ -676,8 +619,8 @@ public void periodicDiagnosticEventHasStatisticsBody() throws Exception { @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(); + DataModel.FeatureFlag flag1 = flagBuilder("flagkey1").version(11).trackEvents(true).build(); + DataModel.FeatureFlag flag2 = flagBuilder("flagkey2").version(22).trackEvents(true).build(); LDValue value = LDValue.of("value"); Event.FeatureRequest fe1 = EventFactory.DEFAULT.newFeatureRequestEvent(flag1, user, simpleEvaluation(1, value), LDValue.ofNull()); @@ -947,72 +890,66 @@ private MockResponse addDateHeader(MockResponse response, long timestamp) { return response.addHeader("Date", httpDateFormat.format(new Date(timestamp))); } - private JsonArray getEventsFromLastRequest(MockWebServer server) throws Exception { + private Iterable getEventsFromLastRequest(MockWebServer server) throws Exception { RecordedRequest req = server.takeRequest(0, TimeUnit.MILLISECONDS); assertNotNull(req); - return gson.fromJson(req.getBody().readUtf8(), JsonElement.class).getAsJsonArray(); + return gson.fromJson(req.getBody().readUtf8(), LDValue.class).values(); } - private Matcher isIdentifyEvent(Event sourceEvent, JsonElement user) { + private Matcher isIdentifyEvent(Event sourceEvent, LDValue user) { return allOf( hasJsonProperty("kind", "identify"), - hasJsonProperty("creationDate", (double)sourceEvent.creationDate), + hasJsonProperty("creationDate", (double)sourceEvent.getCreationDate()), hasJsonProperty("user", user) ); } - private Matcher isIndexEvent(Event sourceEvent, JsonElement user) { + private Matcher isIndexEvent(Event sourceEvent, LDValue user) { return allOf( hasJsonProperty("kind", "index"), - hasJsonProperty("creationDate", (double)sourceEvent.creationDate), + hasJsonProperty("creationDate", (double)sourceEvent.getCreationDate()), hasJsonProperty("user", user) ); } - private Matcher isFeatureEvent(Event.FeatureRequest sourceEvent, FeatureFlag flag, boolean debug, JsonElement inlineUser) { + private Matcher isFeatureEvent(Event.FeatureRequest sourceEvent, DataModel.FeatureFlag flag, boolean debug, LDValue inlineUser) { return isFeatureEvent(sourceEvent, flag, debug, inlineUser, null); } @SuppressWarnings("unchecked") - private Matcher isFeatureEvent(Event.FeatureRequest sourceEvent, FeatureFlag flag, boolean debug, JsonElement inlineUser, + private Matcher isFeatureEvent(Event.FeatureRequest sourceEvent, DataModel.FeatureFlag flag, boolean debug, LDValue inlineUser, EvaluationReason reason) { return allOf( hasJsonProperty("kind", debug ? "debug" : "feature"), - hasJsonProperty("creationDate", (double)sourceEvent.creationDate), + hasJsonProperty("creationDate", (double)sourceEvent.getCreationDate()), hasJsonProperty("key", flag.getKey()), hasJsonProperty("version", (double)flag.getVersion()), - hasJsonProperty("variation", sourceEvent.variation), - hasJsonProperty("value", sourceEvent.value), - (inlineUser != null) ? hasJsonProperty("userKey", nullValue(JsonElement.class)) : - hasJsonProperty("userKey", sourceEvent.user.getKeyAsString()), - (inlineUser != null) ? hasJsonProperty("user", inlineUser) : - hasJsonProperty("user", nullValue(JsonElement.class)), - (reason == null) ? hasJsonProperty("reason", nullValue(JsonElement.class)) : - hasJsonProperty("reason", gson.toJsonTree(reason)) + hasJsonProperty("variation", sourceEvent.getVariation()), + hasJsonProperty("value", sourceEvent.getValue()), + hasJsonProperty("userKey", inlineUser == null ? LDValue.of(sourceEvent.getUser().getKey()) : LDValue.ofNull()), + hasJsonProperty("user", inlineUser == null ? LDValue.ofNull() : inlineUser), + hasJsonProperty("reason", reason == null ? LDValue.ofNull() : LDValue.parse(gson.toJson(reason))) ); } @SuppressWarnings("unchecked") - private Matcher isCustomEvent(Event.Custom sourceEvent, JsonElement inlineUser) { + private Matcher isCustomEvent(Event.Custom sourceEvent, LDValue inlineUser) { return allOf( hasJsonProperty("kind", "custom"), - hasJsonProperty("creationDate", (double)sourceEvent.creationDate), + hasJsonProperty("creationDate", (double)sourceEvent.getCreationDate()), hasJsonProperty("key", "eventkey"), - (inlineUser != null) ? hasJsonProperty("userKey", nullValue(JsonElement.class)) : - hasJsonProperty("userKey", sourceEvent.user.getKeyAsString()), - (inlineUser != null) ? hasJsonProperty("user", inlineUser) : - hasJsonProperty("user", nullValue(JsonElement.class)), - hasJsonProperty("data", sourceEvent.data), - (sourceEvent.metricValue == null) ? hasJsonProperty("metricValue", nullValue(JsonElement.class)) : - hasJsonProperty("metricValue", sourceEvent.metricValue.doubleValue()) + hasJsonProperty("userKey", inlineUser == null ? LDValue.of(sourceEvent.getUser().getKey()) : LDValue.ofNull()), + hasJsonProperty("user", inlineUser == null ? LDValue.ofNull() : inlineUser), + hasJsonProperty("data", sourceEvent.getData()), + hasJsonProperty("metricValue", sourceEvent.getMetricValue() == null ? LDValue.ofNull() : LDValue.of(sourceEvent.getMetricValue())) ); } - private Matcher isSummaryEvent() { + private Matcher isSummaryEvent() { return hasJsonProperty("kind", "summary"); } - private Matcher isSummaryEvent(long startDate, long endDate) { + private Matcher isSummaryEvent(long startDate, long endDate) { return allOf( hasJsonProperty("kind", "summary"), hasJsonProperty("startDate", (double)startDate), @@ -1020,7 +957,7 @@ private Matcher isSummaryEvent(long startDate, long endDate) { ); } - private Matcher hasSummaryFlag(String key, LDValue defaultVal, Matcher> counters) { + private Matcher hasSummaryFlag(String key, LDValue defaultVal, Matcher> counters) { return hasJsonProperty("features", hasJsonProperty(key, allOf( hasJsonProperty("default", defaultVal), @@ -1028,7 +965,7 @@ private Matcher hasSummaryFlag(String key, LDValue defaultVal, Matc ))); } - private Matcher isSummaryEventCounter(FeatureFlag flag, Integer variation, LDValue value, int count) { + private Matcher isSummaryEventCounter(DataModel.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/DiagnosticAccumulatorTest.java b/src/test/java/com/launchdarkly/sdk/server/DiagnosticAccumulatorTest.java similarity index 91% rename from src/test/java/com/launchdarkly/client/DiagnosticAccumulatorTest.java rename to src/test/java/com/launchdarkly/sdk/server/DiagnosticAccumulatorTest.java index 1b19a24b7..06f9baa81 100644 --- a/src/test/java/com/launchdarkly/client/DiagnosticAccumulatorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DiagnosticAccumulatorTest.java @@ -1,4 +1,8 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.DiagnosticAccumulator; +import com.launchdarkly.sdk.server.DiagnosticEvent; +import com.launchdarkly.sdk.server.DiagnosticId; import org.junit.Test; @@ -6,8 +10,8 @@ import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertSame; +@SuppressWarnings("javadoc") public class DiagnosticAccumulatorTest { - @Test public void createsDiagnosticStatisticsEvent() { DiagnosticId diagnosticId = new DiagnosticId("SDK_KEY"); diff --git a/src/test/java/com/launchdarkly/client/DiagnosticEventTest.java b/src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java similarity index 70% rename from src/test/java/com/launchdarkly/client/DiagnosticEventTest.java rename to src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java index 81f847bf0..4da6cc4e5 100644 --- a/src/test/java/com/launchdarkly/client/DiagnosticEventTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java @@ -1,15 +1,19 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.gson.Gson; import com.google.gson.JsonArray; import com.google.gson.JsonObject; -import com.launchdarkly.client.integrations.Redis; -import com.launchdarkly.client.value.LDValue; -import com.launchdarkly.client.value.ObjectBuilder; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.ObjectBuilder; +import com.launchdarkly.sdk.server.interfaces.ClientContext; +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; +import com.launchdarkly.sdk.server.interfaces.DiagnosticDescription; import org.junit.Test; import java.net.URI; +import java.time.Duration; import java.util.Collections; import java.util.List; import java.util.UUID; @@ -89,7 +93,7 @@ public void testDefaultDiagnosticConfiguration() { @Test public void testCustomDiagnosticConfigurationGeneralProperties() { LDConfig ldConfig = new LDConfig.Builder() - .startWaitMillis(10_000) + .startWait(Duration.ofSeconds(10)) .build(); LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig); @@ -107,7 +111,7 @@ public void testCustomDiagnosticConfigurationForStreaming() { Components.streamingDataSource() .baseURI(URI.create("https://1.1.1.1")) .pollingBaseURI(URI.create("https://1.1.1.1")) - .initialReconnectDelayMillis(2_000) + .initialReconnectDelay(Duration.ofSeconds(2)) ) .build(); @@ -127,7 +131,7 @@ public void testCustomDiagnosticConfigurationForPolling() { .dataSource( Components.pollingDataSource() .baseURI(URI.create("https://1.1.1.1")) - .pollIntervalMillis(60_000) + .pollInterval(Duration.ofSeconds(60)) ) .build(); @@ -148,11 +152,11 @@ public void testCustomDiagnosticConfigurationForEvents() { Components.sendEvents() .allAttributesPrivate(true) .capacity(20_000) - .diagnosticRecordingIntervalSeconds(1_800) - .flushIntervalSeconds(10) + .diagnosticRecordingInterval(Duration.ofSeconds(1_800)) + .flushInterval(Duration.ofSeconds(10)) .inlineUsersInEvents(true) .userKeysCapacity(2_000) - .userKeysFlushIntervalSeconds(600) + .userKeysFlushInterval(Duration.ofSeconds(600)) ) .build(); @@ -174,12 +178,12 @@ public void testCustomDiagnosticConfigurationForEvents() { public void testCustomDiagnosticConfigurationForDaemonMode() { LDConfig ldConfig = new LDConfig.Builder() .dataSource(Components.externalUpdatesOnly()) - .dataStore(Components.persistentDataStore(Redis.dataStore())) + .dataStore(new DataStoreFactoryWithComponentName()) .build(); LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig); LDValue expected = expectedDefaultPropertiesWithoutStreaming() - .put("dataStoreType", "Redis") + .put("dataStoreType", "my-test-store") .put("usingRelayDaemon", true) .build(); @@ -199,14 +203,14 @@ public void testCustomDiagnosticConfigurationForOffline() { assertEquals(expected, diagnosticJson); } - + @Test public void testCustomDiagnosticConfigurationHttpProperties() { LDConfig ldConfig = new LDConfig.Builder() .http( Components.httpConfiguration() - .connectTimeoutMillis(5_000) - .socketTimeoutMillis(20_000) + .connectTimeout(Duration.ofSeconds(5)) + .socketTimeout(Duration.ofSeconds(20)) .proxyHostAndPort("localhost", 1234) .proxyAuth(Components.httpBasicAuthentication("username", "password")) ) @@ -223,81 +227,15 @@ public void testCustomDiagnosticConfigurationHttpProperties() { assertEquals(expected, diagnosticJson); } - @SuppressWarnings("deprecation") - @Test - public void testCustomDiagnosticConfigurationDeprecatedPropertiesForStreaming() { - LDConfig ldConfig = new LDConfig.Builder() - .baseURI(URI.create("https://1.1.1.1")) - .streamURI(URI.create("https://1.1.1.1")) - .reconnectTimeMs(2_000) - .build(); - - LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig); - LDValue expected = expectedDefaultPropertiesWithoutStreaming() - .put("customBaseURI", true) - .put("customStreamURI", true) - .put("reconnectTimeMillis", 2_000) - .build(); - - assertEquals(expected, diagnosticJson); - } - - @SuppressWarnings("deprecation") - @Test - public void testCustomDiagnosticConfigurationDeprecatedPropertiesForPolling() { - LDConfig ldConfig = new LDConfig.Builder() - .baseURI(URI.create("https://1.1.1.1")) - .pollingIntervalMillis(60_000) - .stream(false) - .build(); - - LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig); - LDValue expected = expectedDefaultPropertiesWithoutStreaming() - .put("customBaseURI", true) - .put("pollingIntervalMillis", 60_000) - .put("streamingDisabled", true) - .build(); - - assertEquals(expected, diagnosticJson); + private static class DataStoreFactoryWithComponentName implements DataStoreFactory, DiagnosticDescription { + @Override + public LDValue describeConfiguration(LDConfig config) { + return LDValue.of("my-test-store"); + } + + @Override + public DataStore createDataStore(ClientContext context) { + return null; + } } - - @SuppressWarnings("deprecation") - @Test - public void testCustomDiagnosticConfigurationDeprecatedPropertyForDaemonMode() { - LDConfig ldConfig = new LDConfig.Builder() - .featureStoreFactory(new RedisFeatureStoreBuilder()) - .useLdd(true) - .build(); - - LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig); - LDValue expected = expectedDefaultPropertiesWithoutStreaming() - .put("dataStoreType", "Redis") - .put("usingRelayDaemon", true) - .build(); - - 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/DiagnosticIdTest.java b/src/test/java/com/launchdarkly/sdk/server/DiagnosticIdTest.java similarity index 92% rename from src/test/java/com/launchdarkly/client/DiagnosticIdTest.java rename to src/test/java/com/launchdarkly/sdk/server/DiagnosticIdTest.java index 7c2402e42..a81bd95eb 100644 --- a/src/test/java/com/launchdarkly/client/DiagnosticIdTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DiagnosticIdTest.java @@ -1,7 +1,8 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.gson.Gson; import com.google.gson.JsonObject; +import com.launchdarkly.sdk.server.DiagnosticId; import org.junit.Test; @@ -10,8 +11,8 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +@SuppressWarnings("javadoc") public class DiagnosticIdTest { - private static final Gson gson = new Gson(); @Test diff --git a/src/test/java/com/launchdarkly/client/DiagnosticSdkTest.java b/src/test/java/com/launchdarkly/sdk/server/DiagnosticSdkTest.java similarity index 92% rename from src/test/java/com/launchdarkly/client/DiagnosticSdkTest.java rename to src/test/java/com/launchdarkly/sdk/server/DiagnosticSdkTest.java index f0c01184f..01271c472 100644 --- a/src/test/java/com/launchdarkly/client/DiagnosticSdkTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DiagnosticSdkTest.java @@ -1,8 +1,11 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.gson.Gson; import com.google.gson.JsonObject; -import com.launchdarkly.client.DiagnosticEvent.Init.DiagnosticSdk; +import com.launchdarkly.sdk.server.LDClient; +import com.launchdarkly.sdk.server.LDConfig; +import com.launchdarkly.sdk.server.DiagnosticEvent.Init.DiagnosticSdk; + import org.junit.Test; import static org.junit.Assert.assertEquals; diff --git a/src/test/java/com/launchdarkly/client/VariationOrRolloutTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorBucketingTest.java similarity index 64% rename from src/test/java/com/launchdarkly/client/VariationOrRolloutTest.java rename to src/test/java/com/launchdarkly/sdk/server/EvaluatorBucketingTest.java index e56543f35..7cafb3705 100644 --- a/src/test/java/com/launchdarkly/client/VariationOrRolloutTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorBucketingTest.java @@ -1,6 +1,10 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; -import com.launchdarkly.client.VariationOrRollout.WeightedVariation; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.server.DataModel.Rollout; +import com.launchdarkly.sdk.server.DataModel.VariationOrRollout; +import com.launchdarkly.sdk.server.DataModel.WeightedVariation; import org.hamcrest.Matchers; import org.junit.Test; @@ -13,7 +17,7 @@ import static org.junit.Assert.assertEquals; @SuppressWarnings("javadoc") -public class VariationOrRolloutTest { +public class EvaluatorBucketingTest { @Test public void variationIndexIsReturnedForBucket() { LDUser user = new LDUser.Builder("userkey").build(); @@ -22,7 +26,7 @@ public void variationIndexIsReturnedForBucket() { // 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); + int bucketValue = (int)(EvaluatorBucketing.bucketUser(user, flagKey, UserAttribute.KEY, salt) * 100000); assertThat(bucketValue, greaterThanOrEqualTo(1)); assertThat(bucketValue, Matchers.lessThan(100000)); @@ -31,9 +35,9 @@ public void variationIndexIsReturnedForBucket() { 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)); + VariationOrRollout vr = new VariationOrRollout(null, new Rollout(variations, null)); - Integer resultVariation = vr.variationIndexForUser(user, flagKey, salt); + Integer resultVariation = EvaluatorBucketing.variationIndexForUser(vr, user, flagKey, salt); assertEquals(Integer.valueOf(matchedVariation), resultVariation); } @@ -44,12 +48,12 @@ public void lastBucketIsUsedIfBucketValueEqualsTotalWeight() { 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); + int bucketValue = (int)(EvaluatorBucketing.bucketUser(user, flagKey, UserAttribute.KEY, salt) * 100000); List variations = Arrays.asList(new WeightedVariation(0, bucketValue)); - VariationOrRollout vr = new VariationOrRollout(null, new VariationOrRollout.Rollout(variations, null)); + VariationOrRollout vr = new VariationOrRollout(null, new Rollout(variations, null)); - Integer resultVariation = vr.variationIndexForUser(user, flagKey, salt); + Integer resultVariation = EvaluatorBucketing.variationIndexForUser(vr, user, flagKey, salt); assertEquals(Integer.valueOf(0), resultVariation); } @@ -59,8 +63,8 @@ public void canBucketByIntAttributeSameAsString() { .custom("stringattr", "33333") .custom("intattr", 33333) .build(); - float resultForString = VariationOrRollout.bucketUser(user, "key", "stringattr", "salt"); - float resultForInt = VariationOrRollout.bucketUser(user, "key", "intattr", "salt"); + float resultForString = EvaluatorBucketing.bucketUser(user, "key", UserAttribute.forName("stringattr"), "salt"); + float resultForInt = EvaluatorBucketing.bucketUser(user, "key", UserAttribute.forName("intattr"), "salt"); assertEquals(resultForString, resultForInt, Float.MIN_VALUE); } @@ -69,7 +73,7 @@ public void cannotBucketByFloatAttribute() { LDUser user = new LDUser.Builder("key") .custom("floatattr", 33.5f) .build(); - float result = VariationOrRollout.bucketUser(user, "key", "floatattr", "salt"); + float result = EvaluatorBucketing.bucketUser(user, "key", UserAttribute.forName("floatattr"), "salt"); assertEquals(0f, result, Float.MIN_VALUE); } @@ -78,7 +82,7 @@ public void cannotBucketByBooleanAttribute() { LDUser user = new LDUser.Builder("key") .custom("boolattr", true) .build(); - float result = VariationOrRollout.bucketUser(user, "key", "boolattr", "salt"); + float result = EvaluatorBucketing.bucketUser(user, "key", UserAttribute.forName("boolattr"), "salt"); assertEquals(0f, result, Float.MIN_VALUE); } } diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorClauseTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorClauseTest.java new file mode 100644 index 000000000..38b7e8454 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorClauseTest.java @@ -0,0 +1,135 @@ +package com.launchdarkly.sdk.server; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.UserAttribute; + +import org.junit.Test; + +import static com.launchdarkly.sdk.EvaluationDetail.fromValue; +import static com.launchdarkly.sdk.server.EvaluatorTestUtil.BASE_EVALUATOR; +import static com.launchdarkly.sdk.server.EvaluatorTestUtil.evaluatorBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.booleanFlagWithClauses; +import static com.launchdarkly.sdk.server.ModelBuilders.clause; +import static com.launchdarkly.sdk.server.ModelBuilders.fallthroughVariation; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.ruleBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.segmentBuilder; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +@SuppressWarnings("javadoc") +public class EvaluatorClauseTest { + @Test + public void clauseCanMatchBuiltInAttribute() throws Exception { + DataModel.Clause clause = clause(UserAttribute.NAME, DataModel.Operator.in, LDValue.of("Bob")); + DataModel.FeatureFlag f = booleanFlagWithClauses("flag", clause); + LDUser user = new LDUser.Builder("key").name("Bob").build(); + + assertEquals(LDValue.of(true), BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT).getDetails().getValue()); + } + + @Test + public void clauseCanMatchCustomAttribute() throws Exception { + DataModel.Clause clause = clause(UserAttribute.forName("legs"), DataModel.Operator.in, LDValue.of(4)); + DataModel.FeatureFlag f = booleanFlagWithClauses("flag", clause); + LDUser user = new LDUser.Builder("key").custom("legs", 4).build(); + + assertEquals(LDValue.of(true), BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT).getDetails().getValue()); + } + + @Test + public void clauseReturnsFalseForMissingAttribute() throws Exception { + DataModel.Clause clause = clause(UserAttribute.forName("legs"), DataModel.Operator.in, LDValue.of(4)); + DataModel.FeatureFlag f = booleanFlagWithClauses("flag", clause); + LDUser user = new LDUser.Builder("key").name("Bob").build(); + + assertEquals(LDValue.of(false), BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT).getDetails().getValue()); + } + + @Test + public void clauseCanBeNegated() throws Exception { + DataModel.Clause clause = clause(UserAttribute.NAME, DataModel.Operator.in, true, LDValue.of("Bob")); + DataModel.FeatureFlag f = booleanFlagWithClauses("flag", clause); + LDUser user = new LDUser.Builder("key").name("Bob").build(); + + assertEquals(LDValue.of(false), BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT).getDetails().getValue()); + } + + @Test + public void clauseWithUnsupportedOperatorStringIsUnmarshalledWithNullOperator() throws Exception { + // This just verifies that GSON will give us a null in this case instead of throwing an exception, + // so we fail as gracefully as possible if a new operator type has been added in the application + // and the SDK hasn't been upgraded yet. + String badClauseJson = "{\"attribute\":\"name\",\"operator\":\"doesSomethingUnsupported\",\"values\":[\"x\"]}"; + Gson gson = new Gson(); + DataModel.Clause clause = gson.fromJson(badClauseJson, DataModel.Clause.class); + assertNotNull(clause); + + JsonElement json = gson.toJsonTree(clause); + String expectedJson = "{\"attribute\":\"name\",\"values\":[\"x\"],\"negate\":false}"; + assertEquals(gson.fromJson(expectedJson, JsonElement.class), json); + } + + @Test + public void clauseWithNullOperatorDoesNotMatch() throws Exception { + DataModel.Clause badClause = clause(UserAttribute.NAME, null, LDValue.of("Bob")); + DataModel.FeatureFlag f = booleanFlagWithClauses("flag", badClause); + LDUser user = new LDUser.Builder("key").name("Bob").build(); + + assertEquals(LDValue.of(false), BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT).getDetails().getValue()); + } + + @Test + public void clauseWithNullOperatorDoesNotStopSubsequentRuleFromMatching() throws Exception { + DataModel.Clause badClause = clause(UserAttribute.NAME, null, LDValue.of("Bob")); + DataModel.Rule badRule = ruleBuilder().id("rule1").clauses(badClause).variation(1).build(); + DataModel.Clause goodClause = clause(UserAttribute.NAME, DataModel.Operator.in, LDValue.of("Bob")); + DataModel.Rule goodRule = ruleBuilder().id("rule2").clauses(goodClause).variation(1).build(); + DataModel.FeatureFlag f = flagBuilder("feature") + .on(true) + .rules(badRule, goodRule) + .fallthrough(fallthroughVariation(0)) + .offVariation(0) + .variations(LDValue.of(false), LDValue.of(true)) + .build(); + LDUser user = new LDUser.Builder("key").name("Bob").build(); + + EvaluationDetail details = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT).getDetails(); + assertEquals(fromValue(LDValue.of(true), 1, EvaluationReason.ruleMatch(1, "rule2")), details); + } + + @Test + public void testSegmentMatchClauseRetrievesSegmentFromStore() throws Exception { + DataModel.Segment segment = segmentBuilder("segkey") + .included("foo") + .version(1) + .build(); + Evaluator e = evaluatorBuilder().withStoredSegments(segment).build(); + + DataModel.FeatureFlag flag = segmentMatchBooleanFlag("segkey"); + LDUser user = new LDUser.Builder("foo").build(); + + Evaluator.EvalResult result = e.evaluate(flag, user, EventFactory.DEFAULT); + assertEquals(LDValue.of(true), result.getDetails().getValue()); + } + + @Test + public void testSegmentMatchClauseFallsThroughIfSegmentNotFound() throws Exception { + DataModel.FeatureFlag flag = segmentMatchBooleanFlag("segkey"); + LDUser user = new LDUser.Builder("foo").build(); + + Evaluator e = evaluatorBuilder().withNonexistentSegment("segkey").build(); + Evaluator.EvalResult result = e.evaluate(flag, user, EventFactory.DEFAULT); + assertEquals(LDValue.of(false), result.getDetails().getValue()); + } + + private DataModel.FeatureFlag segmentMatchBooleanFlag(String segmentKey) { + DataModel.Clause clause = clause(null, DataModel.Operator.segmentMatch, LDValue.of(segmentKey)); + return booleanFlagWithClauses("flag", clause); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorOperatorsParameterizedTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorOperatorsParameterizedTest.java new file mode 100644 index 000000000..240f70c27 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorOperatorsParameterizedTest.java @@ -0,0 +1,134 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.DataModel; +import com.launchdarkly.sdk.server.EvaluatorOperators; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import java.util.Arrays; + +import static org.junit.Assert.assertEquals; + +@SuppressWarnings("javadoc") +@RunWith(Parameterized.class) +public class EvaluatorOperatorsParameterizedTest { + 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 dateStrUtc1 = LDValue.of("2017-12-06T00:00:00.000Z"); + private static LDValue dateStrUtc2 = LDValue.of("2017-12-06T00:01:01.000Z"); + 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 DataModel.Operator op; + private final LDValue aValue; + private final LDValue bValue; + private final boolean shouldBe; + + public EvaluatorOperatorsParameterizedTest(DataModel.Operator op, LDValue aValue, LDValue bValue, boolean shouldBe) { + this.op = op; + this.aValue = aValue; + this.bValue = bValue; + this.shouldBe = shouldBe; + } + + @Parameterized.Parameters(name = "{1} {0} {2} should be {3}") + public static Iterable data() { + return Arrays.asList(new Object[][] { + // numeric comparisons + { DataModel.Operator.in, LDValue.of(99), LDValue.of(99), true }, + { DataModel.Operator.in, LDValue.of(99.0001), LDValue.of(99.0001), true }, + { DataModel.Operator.in, LDValue.of(99), LDValue.of(99.0001), false }, + { DataModel.Operator.in, LDValue.of(99.0001), LDValue.of(99), false }, + { DataModel.Operator.lessThan, LDValue.of(99), LDValue.of(99.0001), true }, + { DataModel.Operator.lessThan, LDValue.of(99.0001), LDValue.of(99), false }, + { DataModel.Operator.lessThan, LDValue.of(99), LDValue.of(99), false }, + { DataModel.Operator.lessThanOrEqual, LDValue.of(99), LDValue.of(99.0001), true }, + { DataModel.Operator.lessThanOrEqual, LDValue.of(99.0001), LDValue.of(99), false }, + { DataModel.Operator.lessThanOrEqual, LDValue.of(99), LDValue.of(99), true }, + { DataModel.Operator.greaterThan, LDValue.of(99.0001), LDValue.of(99), true }, + { DataModel.Operator.greaterThan, LDValue.of(99), LDValue.of(99.0001), false }, + { DataModel.Operator.greaterThan, LDValue.of(99), LDValue.of(99), false }, + { DataModel.Operator.greaterThanOrEqual, LDValue.of(99.0001), LDValue.of(99), true }, + { DataModel.Operator.greaterThanOrEqual, LDValue.of(99), LDValue.of(99.0001), false }, + { DataModel.Operator.greaterThanOrEqual, LDValue.of(99), LDValue.of(99), true }, + + // string comparisons + { DataModel.Operator.in, LDValue.of("x"), LDValue.of("x"), true }, + { DataModel.Operator.in, LDValue.of("x"), LDValue.of("xyz"), false }, + { DataModel.Operator.startsWith, LDValue.of("xyz"), LDValue.of("x"), true }, + { DataModel.Operator.startsWith, LDValue.of("x"), LDValue.of("xyz"), false }, + { DataModel.Operator.endsWith, LDValue.of("xyz"), LDValue.of("z"), true }, + { DataModel.Operator.endsWith, LDValue.of("z"), LDValue.of("xyz"), false }, + { DataModel.Operator.contains, LDValue.of("xyz"), LDValue.of("y"), true }, + { DataModel.Operator.contains, LDValue.of("y"), LDValue.of("xyz"), false }, + + // mixed strings and numbers + { DataModel.Operator.in, LDValue.of("99"), LDValue.of(99), false }, + { DataModel.Operator.in, LDValue.of(99), LDValue.of("99"), false }, + { DataModel.Operator.contains, LDValue.of("99"), LDValue.of(99), false }, + { DataModel.Operator.startsWith, LDValue.of("99"), LDValue.of(99), false }, + { DataModel.Operator.endsWith, LDValue.of("99"), LDValue.of(99), false }, + { DataModel.Operator.lessThanOrEqual, LDValue.of("99"), LDValue.of(99), false }, + { DataModel.Operator.lessThanOrEqual, LDValue.of(99), LDValue.of("99"), false }, + { DataModel.Operator.greaterThanOrEqual, LDValue.of("99"), LDValue.of(99), false }, + { DataModel.Operator.greaterThanOrEqual, LDValue.of(99), LDValue.of("99"), false }, + + // regex + { DataModel.Operator.matches, LDValue.of("hello world"), LDValue.of("hello.*rld"), true }, + { DataModel.Operator.matches, LDValue.of("hello world"), LDValue.of("hello.*orl"), true }, + { DataModel.Operator.matches, LDValue.of("hello world"), LDValue.of("l+"), true }, + { DataModel.Operator.matches, LDValue.of("hello world"), LDValue.of("(world|planet)"), true }, + { DataModel.Operator.matches, LDValue.of("hello world"), LDValue.of("aloha"), false }, + + // dates + { DataModel.Operator.before, dateStr1, dateStr2, true }, + { DataModel.Operator.before, dateStrUtc1, dateStrUtc2, true }, + { DataModel.Operator.before, dateMs1, dateMs2, true }, + { DataModel.Operator.before, dateStr2, dateStr1, false }, + { DataModel.Operator.before, dateStrUtc2, dateStrUtc1, false }, + { DataModel.Operator.before, dateMs2, dateMs1, false }, + { DataModel.Operator.before, dateStr1, dateStr1, false }, + { DataModel.Operator.before, dateMs1, dateMs1, false }, + { DataModel.Operator.before, dateStr1, invalidDate, false }, + { DataModel.Operator.after, dateStr1, dateStr2, false }, + { DataModel.Operator.after, dateStrUtc1, dateStrUtc2, false }, + { DataModel.Operator.after, dateMs1, dateMs2, false }, + { DataModel.Operator.after, dateStr2, dateStr1, true }, + { DataModel.Operator.after, dateStrUtc2, dateStrUtc1, true }, + { DataModel.Operator.after, dateMs2, dateMs1, true }, + { DataModel.Operator.after, dateStr1, dateStr1, false }, + { DataModel.Operator.after, dateMs1, dateMs1, false }, + { DataModel.Operator.after, dateStr1, invalidDate, false }, + + // semver + { DataModel.Operator.semVerEqual, LDValue.of("2.0.1"), LDValue.of("2.0.1"), true }, + { DataModel.Operator.semVerEqual, LDValue.of("2.0"), LDValue.of("2.0.0"), true }, + { DataModel.Operator.semVerEqual, LDValue.of("2"), LDValue.of("2.0.0"), true }, + { DataModel.Operator.semVerEqual, LDValue.of("2-rc1"), LDValue.of("2.0.0-rc1"), true }, + { DataModel.Operator.semVerEqual, LDValue.of("2+build2"), LDValue.of("2.0.0+build2"), true }, + { DataModel.Operator.semVerLessThan, LDValue.of("2.0.0"), LDValue.of("2.0.1"), true }, + { DataModel.Operator.semVerLessThan, LDValue.of("2.0"), LDValue.of("2.0.1"), true }, + { DataModel.Operator.semVerLessThan, LDValue.of("2.0.1"), LDValue.of("2.0.0"), false }, + { DataModel.Operator.semVerLessThan, LDValue.of("2.0.1"), LDValue.of("2.0"), false }, + { DataModel.Operator.semVerLessThan, LDValue.of("2.0.0-rc"), LDValue.of("2.0.0"), true }, + { DataModel.Operator.semVerLessThan, LDValue.of("2.0.0-rc"), LDValue.of("2.0.0-rc.beta"), true }, + { DataModel.Operator.semVerGreaterThan, LDValue.of("2.0.1"), LDValue.of("2.0.0"), true }, + { DataModel.Operator.semVerGreaterThan, LDValue.of("2.0.1"), LDValue.of("2.0"), true }, + { DataModel.Operator.semVerGreaterThan, LDValue.of("2.0.0"), LDValue.of("2.0.1"), false }, + { DataModel.Operator.semVerGreaterThan, LDValue.of("2.0"), LDValue.of("2.0.1"), false }, + { DataModel.Operator.semVerGreaterThan, LDValue.of("2.0.0-rc.1"), LDValue.of("2.0.0-rc.0"), true }, + { DataModel.Operator.semVerLessThan, LDValue.of("2.0.1"), invalidVer, false }, + { DataModel.Operator.semVerGreaterThan, LDValue.of("2.0.1"), invalidVer, false } + }); + } + + @Test + public void parameterizedTestComparison() { + assertEquals(shouldBe, EvaluatorOperators.apply(op, aValue, bValue)); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorOperatorsTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorOperatorsTest.java new file mode 100644 index 000000000..5a94e7b74 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorOperatorsTest.java @@ -0,0 +1,21 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.DataModel; +import com.launchdarkly.sdk.server.EvaluatorOperators; + +import org.junit.Test; + +import java.util.regex.PatternSyntaxException; + +import static org.junit.Assert.assertFalse; + +// Any special-case tests that can't be handled by EvaluatorOperatorsParameterizedTest. +@SuppressWarnings("javadoc") +public class EvaluatorOperatorsTest { + // This is probably not desired behavior, but it is the current behavior + @Test(expected = PatternSyntaxException.class) + public void testInvalidRegexThrowsException() { + assertFalse(EvaluatorOperators.apply(DataModel.Operator.matches, LDValue.of("hello world"), LDValue.of("***not a regex"))); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorRuleTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorRuleTest.java new file mode 100644 index 000000000..b15a21594 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorRuleTest.java @@ -0,0 +1,101 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.UserAttribute; + +import org.junit.Test; + +import static com.launchdarkly.sdk.server.EvaluatorTestUtil.BASE_EVALUATOR; +import static com.launchdarkly.sdk.server.ModelBuilders.clause; +import static com.launchdarkly.sdk.server.ModelBuilders.emptyRollout; +import static com.launchdarkly.sdk.server.ModelBuilders.fallthroughVariation; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.ruleBuilder; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.emptyIterable; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; + +@SuppressWarnings("javadoc") +public class EvaluatorRuleTest { + @Test + public void ruleMatchReasonInstanceIsReusedForSameRule() { + DataModel.Clause clause0 = clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of("wrongkey")); + DataModel.Clause clause1 = clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of("userkey")); + DataModel.Rule rule0 = ruleBuilder().id("ruleid0").clauses(clause0).variation(2).build(); + DataModel.Rule rule1 = ruleBuilder().id("ruleid1").clauses(clause1).variation(2).build(); + DataModel.FeatureFlag f = featureFlagWithRules("feature", rule0, rule1); + LDUser user = new LDUser.Builder("userkey").build(); + LDUser otherUser = new LDUser.Builder("wrongkey").build(); + + Evaluator.EvalResult sameResult0 = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); + Evaluator.EvalResult sameResult1 = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); + Evaluator.EvalResult otherResult = BASE_EVALUATOR.evaluate(f, otherUser, 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() { + DataModel.Clause clause = clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of("userkey")); + DataModel.Rule rule = ruleBuilder().id("ruleid").clauses(clause).variation(999).build(); + DataModel.FeatureFlag f = featureFlagWithRules("feature", rule); + LDUser user = new LDUser.Builder("userkey").build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + + @Test + public void ruleWithNegativeVariationReturnsMalformedFlagError() { + DataModel.Clause clause = clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of("userkey")); + DataModel.Rule rule = ruleBuilder().id("ruleid").clauses(clause).variation(-1).build(); + DataModel.FeatureFlag f = featureFlagWithRules("feature", rule); + LDUser user = new LDUser.Builder("userkey").build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + + @Test + public void ruleWithNoVariationOrRolloutReturnsMalformedFlagError() { + DataModel.Clause clause = clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of("userkey")); + DataModel.Rule rule = ruleBuilder().id("ruleid").clauses(clause).build(); + DataModel.FeatureFlag f = featureFlagWithRules("feature", rule); + LDUser user = new LDUser.Builder("userkey").build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + + @Test + public void ruleWithRolloutWithEmptyVariationsListReturnsMalformedFlagError() { + DataModel.Clause clause = clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of("userkey")); + DataModel.Rule rule = ruleBuilder().id("ruleid").clauses(clause).rollout(emptyRollout()).build(); + DataModel.FeatureFlag f = featureFlagWithRules("feature", rule); + LDUser user = new LDUser.Builder("userkey").build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + + private DataModel.FeatureFlag featureFlagWithRules(String flagKey, DataModel.Rule... rules) { + return flagBuilder(flagKey) + .on(true) + .rules(rules) + .fallthrough(fallthroughVariation(0)) + .offVariation(1) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .build(); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorSegmentMatchTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorSegmentMatchTest.java new file mode 100644 index 000000000..90b02a9bc --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorSegmentMatchTest.java @@ -0,0 +1,119 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.UserAttribute; + +import org.junit.Test; + +import static com.launchdarkly.sdk.server.EvaluatorTestUtil.evaluatorBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.booleanFlagWithClauses; +import static com.launchdarkly.sdk.server.ModelBuilders.clause; +import static com.launchdarkly.sdk.server.ModelBuilders.segmentBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.segmentRuleBuilder; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +@SuppressWarnings("javadoc") +public class EvaluatorSegmentMatchTest { + + private int maxWeight = 100000; + + @Test + public void explicitIncludeUser() { + DataModel.Segment s = segmentBuilder("test") + .included("foo") + .salt("abcdef") + .version(1) + .build(); + LDUser u = new LDUser.Builder("foo").build(); + + assertTrue(segmentMatchesUser(s, u)); + } + + @Test + public void explicitExcludeUser() { + DataModel.Segment s = segmentBuilder("test") + .excluded("foo") + .salt("abcdef") + .version(1) + .build(); + LDUser u = new LDUser.Builder("foo").build(); + + assertFalse(segmentMatchesUser(s, u)); + } + + @Test + public void explicitIncludeHasPrecedence() { + DataModel.Segment s = segmentBuilder("test") + .included("foo") + .excluded("foo") + .salt("abcdef") + .version(1) + .build(); + LDUser u = new LDUser.Builder("foo").build(); + + assertTrue(segmentMatchesUser(s, u)); + } + + @Test + public void matchingRuleWithFullRollout() { + DataModel.Clause clause = clause(UserAttribute.EMAIL, DataModel.Operator.in, LDValue.of("test@example.com")); + DataModel.SegmentRule rule = segmentRuleBuilder().clauses(clause).weight(maxWeight).build(); + DataModel.Segment s = segmentBuilder("test") + .salt("abcdef") + .rules(rule) + .build(); + LDUser u = new LDUser.Builder("foo").email("test@example.com").build(); + + assertTrue(segmentMatchesUser(s, u)); + } + + @Test + public void matchingRuleWithZeroRollout() { + DataModel.Clause clause = clause(UserAttribute.EMAIL, DataModel.Operator.in, LDValue.of("test@example.com")); + DataModel.SegmentRule rule = segmentRuleBuilder().clauses(clause).weight(0).build(); + DataModel.Segment s = segmentBuilder("test") + .salt("abcdef") + .rules(rule) + .build(); + LDUser u = new LDUser.Builder("foo").email("test@example.com").build(); + + assertFalse(segmentMatchesUser(s, u)); + } + + @Test + public void matchingRuleWithMultipleClauses() { + DataModel.Clause clause1 = clause(UserAttribute.EMAIL, DataModel.Operator.in, LDValue.of("test@example.com")); + DataModel.Clause clause2 = clause(UserAttribute.NAME, DataModel.Operator.in, LDValue.of("bob")); + DataModel.SegmentRule rule = segmentRuleBuilder().clauses(clause1, clause2).build(); + DataModel.Segment s = segmentBuilder("test") + .salt("abcdef") + .rules(rule) + .build(); + LDUser u = new LDUser.Builder("foo").email("test@example.com").name("bob").build(); + + assertTrue(segmentMatchesUser(s, u)); + } + + @Test + public void nonMatchingRuleWithMultipleClauses() { + DataModel.Clause clause1 = clause(UserAttribute.EMAIL, DataModel.Operator.in, LDValue.of("test@example.com")); + DataModel.Clause clause2 = clause(UserAttribute.NAME, DataModel.Operator.in, LDValue.of("bill")); + DataModel.SegmentRule rule = segmentRuleBuilder().clauses(clause1, clause2).build(); + DataModel.Segment s = segmentBuilder("test") + .salt("abcdef") + .rules(rule) + .build(); + LDUser u = new LDUser.Builder("foo").email("test@example.com").name("bob").build(); + + assertFalse(segmentMatchesUser(s, u)); + } + + private static boolean segmentMatchesUser(DataModel.Segment segment, LDUser user) { + DataModel.Clause clause = clause(null, DataModel.Operator.segmentMatch, LDValue.of(segment.getKey())); + DataModel.FeatureFlag flag = booleanFlagWithClauses("flag", clause); + Evaluator e = evaluatorBuilder().withStoredSegments(segment).build(); + return e.evaluate(flag, user, EventFactory.DEFAULT).getValue().booleanValue(); + } +} \ No newline at end of file diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java new file mode 100644 index 000000000..ffdf26ed8 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java @@ -0,0 +1,365 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.collect.Iterables; +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.server.interfaces.Event; + +import org.junit.Test; + +import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; +import static com.launchdarkly.sdk.EvaluationDetail.fromValue; +import static com.launchdarkly.sdk.server.EvaluatorTestUtil.BASE_EVALUATOR; +import static com.launchdarkly.sdk.server.EvaluatorTestUtil.evaluatorBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.clause; +import static com.launchdarkly.sdk.server.ModelBuilders.fallthroughVariation; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.prerequisite; +import static com.launchdarkly.sdk.server.ModelBuilders.ruleBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.target; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.emptyIterable; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; + +@SuppressWarnings("javadoc") +public class EvaluatorTest { + + private static LDUser BASE_USER = new LDUser.Builder("x").build(); + + @Test + public void flagReturnsOffVariationIfFlagIsOff() throws Exception { + DataModel.FeatureFlag f = flagBuilder("feature") + .on(false) + .offVariation(1) + .fallthrough(fallthroughVariation(0)) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + + assertEquals(fromValue(LDValue.of("off"), 1, EvaluationReason.off()), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + + @Test + public void flagReturnsNullIfFlagIsOffAndOffVariationIsUnspecified() throws Exception { + DataModel.FeatureFlag f = flagBuilder("feature") + .on(false) + .fallthrough(fallthroughVariation(0)) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + + assertEquals(fromValue(LDValue.ofNull(), NO_VARIATION, EvaluationReason.off()), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + + @Test + public void flagReturnsErrorIfFlagIsOffAndOffVariationIsTooHigh() throws Exception { + DataModel.FeatureFlag f = flagBuilder("feature") + .on(false) + .offVariation(999) + .fallthrough(fallthroughVariation(0)) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + + @Test + public void flagReturnsErrorIfFlagIsOffAndOffVariationIsNegative() throws Exception { + DataModel.FeatureFlag f = flagBuilder("feature") + .on(false) + .offVariation(-1) + .fallthrough(fallthroughVariation(0)) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + + @Test + public void flagReturnsFallthroughIfFlagIsOnAndThereAreNoRules() throws Exception { + DataModel.FeatureFlag f = flagBuilder("feature") + .on(true) + .offVariation(1) + .fallthrough(fallthroughVariation(0)) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + + assertEquals(fromValue(LDValue.of("fall"), 0, EvaluationReason.fallthrough()), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + + @Test + public void flagReturnsErrorIfFallthroughHasTooHighVariation() throws Exception { + DataModel.FeatureFlag f = flagBuilder("feature") + .on(true) + .offVariation(1) + .fallthrough(fallthroughVariation(999)) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + + @Test + public void flagReturnsErrorIfFallthroughHasNegativeVariation() throws Exception { + DataModel.FeatureFlag f = flagBuilder("feature") + .on(true) + .offVariation(1) + .fallthrough(fallthroughVariation(-1)) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + + @Test + public void flagReturnsErrorIfFallthroughHasNeitherVariationNorRollout() throws Exception { + DataModel.FeatureFlag f = flagBuilder("feature") + .on(true) + .offVariation(1) + .fallthrough(new DataModel.VariationOrRollout(null, null)) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + + @Test + public void flagReturnsErrorIfFallthroughHasEmptyRolloutVariationList() throws Exception { + DataModel.FeatureFlag f = flagBuilder("feature") + .on(true) + .offVariation(1) + .fallthrough(new DataModel.VariationOrRollout(null, ModelBuilders.emptyRollout())) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + + @Test + public void flagReturnsOffVariationIfPrerequisiteIsNotFound() throws Exception { + DataModel.FeatureFlag f0 = flagBuilder("feature0") + .on(true) + .prerequisites(prerequisite("feature1", 1)) + .fallthrough(fallthroughVariation(0)) + .offVariation(1) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .build(); + Evaluator e = evaluatorBuilder().withNonexistentFlag("feature1").build(); + Evaluator.EvalResult result = e.evaluate(f0, BASE_USER, EventFactory.DEFAULT); + + EvaluationReason expectedReason = EvaluationReason.prerequisiteFailed("feature1"); + assertEquals(fromValue(LDValue.of("off"), 1, expectedReason), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + + @Test + public void flagReturnsOffVariationAndEventIfPrerequisiteIsOff() throws Exception { + DataModel.FeatureFlag f0 = flagBuilder("feature0") + .on(true) + .prerequisites(prerequisite("feature1", 1)) + .fallthrough(fallthroughVariation(0)) + .offVariation(1) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .version(1) + .build(); + DataModel.FeatureFlag f1 = flagBuilder("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(LDValue.of("nogo"), LDValue.of("go")) + .version(2) + .build(); + Evaluator e = evaluatorBuilder().withStoredFlags(f1).build(); + Evaluator.EvalResult result = e.evaluate(f0, BASE_USER, EventFactory.DEFAULT); + + EvaluationReason expectedReason = EvaluationReason.prerequisiteFailed("feature1"); + assertEquals(fromValue(LDValue.of("off"), 1, expectedReason), result.getDetails()); + + assertEquals(1, Iterables.size(result.getPrerequisiteEvents())); + Event.FeatureRequest event = Iterables.get(result.getPrerequisiteEvents(), 0); + assertEquals(f1.getKey(), event.getKey()); + assertEquals(LDValue.of("go"), event.getValue()); + assertEquals(f1.getVersion(), event.getVersion()); + assertEquals(f0.getKey(), event.getPrereqOf()); + } + + @Test + public void flagReturnsOffVariationAndEventIfPrerequisiteIsNotMet() throws Exception { + DataModel.FeatureFlag f0 = flagBuilder("feature0") + .on(true) + .prerequisites(prerequisite("feature1", 1)) + .fallthrough(fallthroughVariation(0)) + .offVariation(1) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .version(1) + .build(); + DataModel.FeatureFlag f1 = flagBuilder("feature1") + .on(true) + .fallthrough(fallthroughVariation(0)) + .variations(LDValue.of("nogo"), LDValue.of("go")) + .version(2) + .build(); + Evaluator e = evaluatorBuilder().withStoredFlags(f1).build(); + Evaluator.EvalResult result = e.evaluate(f0, BASE_USER, EventFactory.DEFAULT); + + EvaluationReason expectedReason = EvaluationReason.prerequisiteFailed("feature1"); + assertEquals(fromValue(LDValue.of("off"), 1, expectedReason), result.getDetails()); + + assertEquals(1, Iterables.size(result.getPrerequisiteEvents())); + Event.FeatureRequest event = Iterables.get(result.getPrerequisiteEvents(), 0); + assertEquals(f1.getKey(), event.getKey()); + assertEquals(LDValue.of("nogo"), event.getValue()); + assertEquals(f1.getVersion(), event.getVersion()); + assertEquals(f0.getKey(), event.getPrereqOf()); + } + + @Test + public void prerequisiteFailedReasonInstanceIsReusedForSamePrerequisite() throws Exception { + DataModel.FeatureFlag f0 = flagBuilder("feature0") + .on(true) + .prerequisites(prerequisite("feature1", 1)) + .fallthrough(fallthroughVariation(0)) + .offVariation(1) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .build(); + Evaluator e = evaluatorBuilder().withNonexistentFlag("feature1").build(); + Evaluator.EvalResult result0 = e.evaluate(f0, BASE_USER, EventFactory.DEFAULT); + Evaluator.EvalResult result1 = e.evaluate(f0, BASE_USER, 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 { + DataModel.FeatureFlag f0 = flagBuilder("feature0") + .on(true) + .prerequisites(prerequisite("feature1", 1)) + .fallthrough(fallthroughVariation(0)) + .offVariation(1) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .version(1) + .build(); + DataModel.FeatureFlag f1 = flagBuilder("feature1") + .on(true) + .fallthrough(fallthroughVariation(1)) + .variations(LDValue.of("nogo"), LDValue.of("go")) + .version(2) + .build(); + Evaluator e = evaluatorBuilder().withStoredFlags(f1).build(); + Evaluator.EvalResult result = e.evaluate(f0, BASE_USER, EventFactory.DEFAULT); + + assertEquals(fromValue(LDValue.of("fall"), 0, EvaluationReason.fallthrough()), result.getDetails()); + + assertEquals(1, Iterables.size(result.getPrerequisiteEvents())); + Event.FeatureRequest event = Iterables.get(result.getPrerequisiteEvents(), 0); + assertEquals(f1.getKey(), event.getKey()); + assertEquals(LDValue.of("go"), event.getValue()); + assertEquals(f1.getVersion(), event.getVersion()); + assertEquals(f0.getKey(), event.getPrereqOf()); + } + + @Test + public void multipleLevelsOfPrerequisitesProduceMultipleEvents() throws Exception { + DataModel.FeatureFlag f0 = flagBuilder("feature0") + .on(true) + .prerequisites(prerequisite("feature1", 1)) + .fallthrough(fallthroughVariation(0)) + .offVariation(1) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .version(1) + .build(); + DataModel.FeatureFlag f1 = flagBuilder("feature1") + .on(true) + .prerequisites(prerequisite("feature2", 1)) + .fallthrough(fallthroughVariation(1)) + .variations(LDValue.of("nogo"), LDValue.of("go")) + .version(2) + .build(); + DataModel.FeatureFlag f2 = flagBuilder("feature2") + .on(true) + .fallthrough(fallthroughVariation(1)) + .variations(LDValue.of("nogo"), LDValue.of("go")) + .version(3) + .build(); + Evaluator e = evaluatorBuilder().withStoredFlags(f1, f2).build(); + Evaluator.EvalResult result = e.evaluate(f0, BASE_USER, EventFactory.DEFAULT); + + assertEquals(fromValue(LDValue.of("fall"), 0, EvaluationReason.fallthrough()), result.getDetails()); + assertEquals(2, Iterables.size(result.getPrerequisiteEvents())); + + Event.FeatureRequest event0 = Iterables.get(result.getPrerequisiteEvents(), 0); + assertEquals(f2.getKey(), event0.getKey()); + assertEquals(LDValue.of("go"), event0.getValue()); + assertEquals(f2.getVersion(), event0.getVersion()); + assertEquals(f1.getKey(), event0.getPrereqOf()); + + Event.FeatureRequest event1 = Iterables.get(result.getPrerequisiteEvents(), 1); + assertEquals(f1.getKey(), event1.getKey()); + assertEquals(LDValue.of("go"), event1.getValue()); + assertEquals(f1.getVersion(), event1.getVersion()); + assertEquals(f0.getKey(), event1.getPrereqOf()); + } + + @Test + public void flagMatchesUserFromTargets() throws Exception { + DataModel.FeatureFlag f = flagBuilder("feature") + .on(true) + .targets(target(2, "whoever", "userkey")) + .fallthrough(fallthroughVariation(0)) + .offVariation(1) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .build(); + LDUser user = new LDUser.Builder("userkey").build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); + + assertEquals(fromValue(LDValue.of("on"), 2, EvaluationReason.targetMatch()), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + + @Test + public void flagMatchesUserFromRules() { + DataModel.Clause clause0 = clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of("wrongkey")); + DataModel.Clause clause1 = clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of("userkey")); + DataModel.Rule rule0 = ruleBuilder().id("ruleid0").clauses(clause0).variation(2).build(); + DataModel.Rule rule1 = ruleBuilder().id("ruleid1").clauses(clause1).variation(2).build(); + DataModel.FeatureFlag f = featureFlagWithRules("feature", rule0, rule1); + LDUser user = new LDUser.Builder("userkey").build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); + + assertEquals(fromValue(LDValue.of("on"), 2, EvaluationReason.ruleMatch(1, "ruleid1")), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + + private DataModel.FeatureFlag featureFlagWithRules(String flagKey, DataModel.Rule... rules) { + return flagBuilder(flagKey) + .on(true) + .rules(rules) + .fallthrough(fallthroughVariation(0)) + .offVariation(1) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .build(); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorTestUtil.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTestUtil.java new file mode 100644 index 000000000..dcb9372f4 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTestUtil.java @@ -0,0 +1,105 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.DataModel; +import com.launchdarkly.sdk.server.Evaluator; + +@SuppressWarnings("javadoc") +public abstract class EvaluatorTestUtil { + public static Evaluator BASE_EVALUATOR = evaluatorBuilder().build(); + + public static EvaluatorBuilder evaluatorBuilder() { + return new EvaluatorBuilder(); + } + + public static class EvaluatorBuilder { + private Evaluator.Getters getters; + + EvaluatorBuilder() { + getters = new Evaluator.Getters() { + public DataModel.FeatureFlag getFlag(String key) { + throw new IllegalStateException("Evaluator unexpectedly tried to query flag: " + key); + } + + public DataModel.Segment getSegment(String key) { + throw new IllegalStateException("Evaluator unexpectedly tried to query segment: " + key); + } + }; + } + + public Evaluator build() { + return new Evaluator(getters); + } + + public EvaluatorBuilder withStoredFlags(final DataModel.FeatureFlag... flags) { + final Evaluator.Getters baseGetters = getters; + getters = new Evaluator.Getters() { + public DataModel.FeatureFlag getFlag(String key) { + for (DataModel.FeatureFlag f: flags) { + if (f.getKey().equals(key)) { + return f; + } + } + return baseGetters.getFlag(key); + } + + public DataModel.Segment getSegment(String key) { + return baseGetters.getSegment(key); + } + }; + return this; + } + + public EvaluatorBuilder withNonexistentFlag(final String nonexistentFlagKey) { + final Evaluator.Getters baseGetters = getters; + getters = new Evaluator.Getters() { + public DataModel.FeatureFlag getFlag(String key) { + if (key.equals(nonexistentFlagKey)) { + return null; + } + return baseGetters.getFlag(key); + } + + public DataModel.Segment getSegment(String key) { + return baseGetters.getSegment(key); + } + }; + return this; + } + + public EvaluatorBuilder withStoredSegments(final DataModel.Segment... segments) { + final Evaluator.Getters baseGetters = getters; + getters = new Evaluator.Getters() { + public DataModel.FeatureFlag getFlag(String key) { + return baseGetters.getFlag(key); + } + + public DataModel.Segment getSegment(String key) { + for (DataModel.Segment s: segments) { + if (s.getKey().equals(key)) { + return s; + } + } + return baseGetters.getSegment(key); + } + }; + return this; + } + + public EvaluatorBuilder withNonexistentSegment(final String nonexistentSegmentKey) { + final Evaluator.Getters baseGetters = getters; + getters = new Evaluator.Getters() { + public DataModel.FeatureFlag getFlag(String key) { + return baseGetters.getFlag(key); + } + + public DataModel.Segment getSegment(String key) { + if (key.equals(nonexistentSegmentKey)) { + return null; + } + return baseGetters.getSegment(key); + } + }; + return this; + } + } +} diff --git a/src/test/java/com/launchdarkly/client/EventOutputTest.java b/src/test/java/com/launchdarkly/sdk/server/EventOutputTest.java similarity index 85% rename from src/test/java/com/launchdarkly/client/EventOutputTest.java rename to src/test/java/com/launchdarkly/sdk/server/EventOutputTest.java index 232258e51..c254855e2 100644 --- a/src/test/java/com/launchdarkly/client/EventOutputTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EventOutputTest.java @@ -1,11 +1,15 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; 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 com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.ObjectBuilder; +import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.server.EventSummarizer.EventSummary; +import com.launchdarkly.sdk.server.interfaces.Event; +import com.launchdarkly.sdk.server.interfaces.Event.FeatureRequest; import org.junit.Test; @@ -13,6 +17,10 @@ import java.io.StringWriter; import java.util.Set; +import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.TestComponents.defaultEventsConfig; +import static com.launchdarkly.sdk.server.TestComponents.makeEventsConfig; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.containsInAnyOrder; @@ -54,30 +62,30 @@ public class EventOutputTest { @Test public void allUserAttributesAreSerialized() throws Exception { testInlineUserSerialization(userBuilderWithAllAttributes.build(), userJsonWithAllAttributes, - TestUtil.defaultEventsConfig()); + defaultEventsConfig()); } @Test public void unsetUserAttributesAreNotSerialized() throws Exception { LDUser user = new LDUser("userkey"); LDValue userJson = parseValue("{\"key\":\"userkey\"}"); - testInlineUserSerialization(user, userJson, TestUtil.defaultEventsConfig()); + testInlineUserSerialization(user, userJson, 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(TestUtil.defaultEventsConfig()); + EventOutputFormatter f = new EventOutputFormatter(defaultEventsConfig()); Event.FeatureRequest featureEvent = EventFactory.DEFAULT.newFeatureRequestEvent( - new FeatureFlagBuilder("flag").build(), + flagBuilder("flag").build(), user, - new EvaluationDetail(EvaluationReason.off(), null, LDValue.ofNull()), + new Evaluator.EvalResult(LDValue.ofNull(), NO_VARIATION, EvaluationReason.off()), LDValue.ofNull()); LDValue outputEvent = getSingleOutputEvent(f, featureEvent); assertEquals(LDValue.ofNull(), outputEvent.get("user")); - assertEquals(user.getKey(), outputEvent.get("userKey")); + assertEquals(LDValue.of(user.getKey()), outputEvent.get("userKey")); Event.Identify identifyEvent = EventFactory.DEFAULT.newIdentifyEvent(user); outputEvent = getSingleOutputEvent(f, identifyEvent); @@ -87,7 +95,7 @@ public void userKeyIsSetInsteadOfUserWhenNotInlined() throws Exception { 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")); + assertEquals(LDValue.of(user.getKey()), outputEvent.get("userKey")); Event.Index indexEvent = new Event.Index(0, user); outputEvent = getSingleOutputEvent(f, indexEvent); @@ -98,7 +106,7 @@ public void userKeyIsSetInsteadOfUserWhenNotInlined() throws Exception { @Test public void allAttributesPrivateMakesAttributesPrivate() throws Exception { LDUser user = userBuilderWithAllAttributes.build(); - EventsConfiguration config = TestUtil.makeEventsConfig(true, false, null); + EventsConfiguration config = makeEventsConfig(true, false, null); testPrivateAttributes(config, user, attributesThatCanBePrivate); } @@ -106,7 +114,7 @@ public void allAttributesPrivateMakesAttributesPrivate() throws Exception { public void globalPrivateAttributeNamesMakeAttributesPrivate() throws Exception { LDUser user = userBuilderWithAllAttributes.build(); for (String attrName: attributesThatCanBePrivate) { - EventsConfiguration config = TestUtil.makeEventsConfig(false, false, ImmutableSet.of(attrName)); + EventsConfiguration config = makeEventsConfig(false, false, ImmutableSet.of(UserAttribute.forName(attrName))); testPrivateAttributes(config, user, attrName); } } @@ -114,7 +122,7 @@ public void globalPrivateAttributeNamesMakeAttributesPrivate() throws Exception @Test public void perUserPrivateAttributesMakeAttributePrivate() throws Exception { LDUser baseUser = userBuilderWithAllAttributes.build(); - EventsConfiguration config = TestUtil.defaultEventsConfig(); + EventsConfiguration config = defaultEventsConfig(); testPrivateAttributes(config, new LDUser.Builder(baseUser).privateAvatar("x").build(), "avatar"); testPrivateAttributes(config, new LDUser.Builder(baseUser).privateCountry("US").build(), "country"); @@ -162,12 +170,12 @@ private void testPrivateAttributes(EventsConfiguration config, LDUser user, Stri public void featureEventIsSerialized() throws Exception { EventFactory factory = eventFactoryWithTimestamp(100000, false); EventFactory factoryWithReason = eventFactoryWithTimestamp(100000, true); - FeatureFlag flag = new FeatureFlagBuilder("flag").version(11).build(); + DataModel.FeatureFlag flag = flagBuilder("flag").version(11).build(); LDUser user = new LDUser.Builder("userkey").name("me").build(); - EventOutputFormatter f = new EventOutputFormatter(TestUtil.defaultEventsConfig()); + EventOutputFormatter f = new EventOutputFormatter(defaultEventsConfig()); FeatureRequest feWithVariation = factory.newFeatureRequestEvent(flag, user, - new EvaluationDetail(EvaluationReason.off(), 1, LDValue.of("flagvalue")), + new Evaluator.EvalResult(LDValue.of("flagvalue"), 1, EvaluationReason.off()), LDValue.of("defaultvalue")); LDValue feJson1 = parseValue("{" + "\"kind\":\"feature\"," + @@ -182,7 +190,7 @@ public void featureEventIsSerialized() throws Exception { assertEquals(feJson1, getSingleOutputEvent(f, feWithVariation)); FeatureRequest feWithoutVariationOrDefault = factory.newFeatureRequestEvent(flag, user, - new EvaluationDetail(EvaluationReason.off(), null, LDValue.of("flagvalue")), + new Evaluator.EvalResult(LDValue.of("flagvalue"), NO_VARIATION, EvaluationReason.off()), LDValue.ofNull()); LDValue feJson2 = parseValue("{" + "\"kind\":\"feature\"," + @@ -195,7 +203,7 @@ public void featureEventIsSerialized() throws Exception { assertEquals(feJson2, getSingleOutputEvent(f, feWithoutVariationOrDefault)); FeatureRequest feWithReason = factoryWithReason.newFeatureRequestEvent(flag, user, - new EvaluationDetail(EvaluationReason.ruleMatch(1, "id"), 1, LDValue.of("flagvalue")), + new Evaluator.EvalResult(LDValue.of("flagvalue"), 1, EvaluationReason.ruleMatch(1, "id")), LDValue.of("defaultvalue")); LDValue feJson3 = parseValue("{" + "\"kind\":\"feature\"," + @@ -241,7 +249,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(TestUtil.defaultEventsConfig()); + EventOutputFormatter f = new EventOutputFormatter(defaultEventsConfig()); Event.Identify ie = factory.newIdentifyEvent(user); LDValue ieJson = parseValue("{" + @@ -257,7 +265,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(TestUtil.defaultEventsConfig()); + EventOutputFormatter f = new EventOutputFormatter(defaultEventsConfig()); Event.Custom ceWithoutData = factory.newCustomEvent("customkey", user, LDValue.ofNull(), null); LDValue ceJson1 = parseValue("{" + @@ -316,14 +324,14 @@ public void summaryEventIsSerialized() throws Exception { 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("second"), -1, 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.incrementCounter(new String("third"), -1, -1, LDValue.of("default3"), LDValue.of("default3")); // flag doesn't exist (no version) summary.noteTimestamp(1000); summary.noteTimestamp(1002); - EventOutputFormatter f = new EventOutputFormatter(TestUtil.defaultEventsConfig()); + EventOutputFormatter f = new EventOutputFormatter(defaultEventsConfig()); StringWriter w = new StringWriter(); int count = f.writeOutputEvents(new Event[0], summary, w); assertEquals(1, count); @@ -382,13 +390,13 @@ private LDValue getSingleOutputEvent(EventOutputFormatter f, Event event) throws } private void testInlineUserSerialization(LDUser user, LDValue expectedJsonValue, EventsConfiguration baseConfig) throws IOException { - EventsConfiguration config = TestUtil.makeEventsConfig(baseConfig.allAttributesPrivate, true, baseConfig.privateAttrNames); + EventsConfiguration config = makeEventsConfig(baseConfig.allAttributesPrivate, true, baseConfig.privateAttributes); EventOutputFormatter f = new EventOutputFormatter(config); Event.FeatureRequest featureEvent = EventFactory.DEFAULT.newFeatureRequestEvent( - new FeatureFlagBuilder("flag").build(), + flagBuilder("flag").build(), user, - new EvaluationDetail(EvaluationReason.off(), null, LDValue.ofNull()), + new Evaluator.EvalResult(LDValue.ofNull(), NO_VARIATION, EvaluationReason.off()), LDValue.ofNull()); LDValue outputEvent = getSingleOutputEvent(f, featureEvent); assertEquals(LDValue.ofNull(), outputEvent.get("userKey")); diff --git a/src/test/java/com/launchdarkly/client/EventSummarizerTest.java b/src/test/java/com/launchdarkly/sdk/server/EventSummarizerTest.java similarity index 83% rename from src/test/java/com/launchdarkly/client/EventSummarizerTest.java rename to src/test/java/com/launchdarkly/sdk/server/EventSummarizerTest.java index c0e6f0aed..c8294de9d 100644 --- a/src/test/java/com/launchdarkly/client/EventSummarizerTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EventSummarizerTest.java @@ -1,18 +1,25 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.DataModel; +import com.launchdarkly.sdk.server.EventFactory; +import com.launchdarkly.sdk.server.EventSummarizer; +import com.launchdarkly.sdk.server.interfaces.Event; import org.junit.Test; import java.util.HashMap; import java.util.Map; -import static com.launchdarkly.client.TestUtil.js; -import static com.launchdarkly.client.TestUtil.simpleEvaluation; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.TestUtil.simpleEvaluation; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.junit.Assert.assertEquals; +@SuppressWarnings("javadoc") public class EventSummarizerTest { private static final LDUser user = new LDUser.Builder("key").build(); @@ -50,7 +57,7 @@ public void summarizeEventDoesNothingForCustomEvent() { @Test public void summarizeEventSetsStartAndEndDates() { EventSummarizer es = new EventSummarizer(); - FeatureFlag flag = new FeatureFlagBuilder("key").build(); + DataModel.FeatureFlag flag = flagBuilder("key").build(); eventTimestamp = 2000; Event event1 = eventFactory.newFeatureRequestEvent(flag, user, null, null); eventTimestamp = 1000; @@ -69,8 +76,8 @@ public void summarizeEventSetsStartAndEndDates() { @Test public void summarizeEventIncrementsCounters() { EventSummarizer es = new EventSummarizer(); - FeatureFlag flag1 = new FeatureFlagBuilder("key1").version(11).build(); - FeatureFlag flag2 = new FeatureFlagBuilder("key2").version(22).build(); + DataModel.FeatureFlag flag1 = flagBuilder("key1").version(11).build(); + DataModel.FeatureFlag flag2 = flagBuilder("key2").version(22).build(); String unknownFlagKey = "badkey"; Event event1 = eventFactory.newFeatureRequestEvent(flag1, user, simpleEvaluation(1, LDValue.of("value1")), LDValue.of("default1")); @@ -95,7 +102,7 @@ public void summarizeEventIncrementsCounters() { new EventSummarizer.CounterValue(1, LDValue.of("value2"), LDValue.of("default1"))); expected.put(new EventSummarizer.CounterKey(flag2.getKey(), 1, flag2.getVersion()), new EventSummarizer.CounterValue(1, LDValue.of("value99"), LDValue.of("default2"))); - expected.put(new EventSummarizer.CounterKey(unknownFlagKey, null, null), + expected.put(new EventSummarizer.CounterKey(unknownFlagKey, -1, -1), new EventSummarizer.CounterValue(1, LDValue.of("default3"), LDValue.of("default3"))); assertThat(data.counters, equalTo(expected)); } diff --git a/src/test/java/com/launchdarkly/sdk/server/EventUserSerializationTest.java b/src/test/java/com/launchdarkly/sdk/server/EventUserSerializationTest.java new file mode 100644 index 000000000..3fcb78e39 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/EventUserSerializationTest.java @@ -0,0 +1,148 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.reflect.TypeToken; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.UserAttribute; + +import org.junit.Test; + +import java.lang.reflect.Type; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import static com.launchdarkly.sdk.server.JsonHelpers.gsonInstanceForEventsSerialization; +import static com.launchdarkly.sdk.server.TestComponents.defaultEventsConfig; +import static com.launchdarkly.sdk.server.TestComponents.makeEventsConfig; +import static com.launchdarkly.sdk.server.TestUtil.TEST_GSON_INSTANCE; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +@SuppressWarnings("javadoc") +public class EventUserSerializationTest { + + @Test + public void testAllPropertiesInPrivateAttributeEncoding() { + for (Map.Entry e: getUserPropertiesJsonMap().entrySet()) { + JsonElement expected = TEST_GSON_INSTANCE.fromJson(e.getValue(), JsonElement.class); + JsonElement actual = TEST_GSON_INSTANCE.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("value").build(), + "{\"key\":\"userkey\",\"country\":\"value\"}"); + 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").build(); + String expected = "{\"key\":\"userkey\",\"name\":\"x\",\"privateAttributeNames\":[\"name\"]}"; + assertEquals(TEST_GSON_INSTANCE.fromJson(expected, JsonElement.class), TEST_GSON_INSTANCE.toJsonTree(user)); + } + + @Test + public void privateAttributeEncodingRedactsAllPrivateAttributes() { + EventsConfiguration config = makeEventsConfig(true, false, null); + LDUser user = new LDUser.Builder("userkey") + .secondary("s") + .ip("i") + .email("e") + .name("n") + .avatar("a") + .firstName("f") + .lastName("l") + .anonymous(true) + .country("USA") + .custom("thing", "value") + .build(); + Set redacted = ImmutableSet.of("secondary", "ip", "email", "name", "avatar", "firstName", "lastName", "country", "thing"); + + JsonObject o = gsonInstanceForEventsSerialization(config).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 privateAttributeEncodingRedactsSpecificPerUserPrivateAttributes() { + LDUser user = new LDUser.Builder("userkey") + .email("e") + .privateName("n") + .custom("bar", 43) + .privateCustom("foo", 42) + .build(); + + 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()); + assertNull(o.get("custom").getAsJsonObject().get("foo")); + assertEquals(ImmutableSet.of("name", "foo"), getPrivateAttrs(o)); + } + + @Test + public void privateAttributeEncodingRedactsSpecificGlobalPrivateAttributes() { + EventsConfiguration config = makeEventsConfig(false, false, + ImmutableSet.of(UserAttribute.NAME, UserAttribute.forName("foo"))); + LDUser user = new LDUser.Builder("userkey") + .email("e") + .name("n") + .custom("bar", 43) + .custom("foo", 42) + .build(); + + 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()); + assertNull(o.get("custom").getAsJsonObject().get("foo")); + assertEquals(ImmutableSet.of("name", "foo"), getPrivateAttrs(o)); + } + + @Test + public void privateAttributeEncodingWorksForMinimalUser() { + EventsConfiguration config = makeEventsConfig(true, false, null); + LDUser user = new LDUser("userkey"); + + JsonObject o = gsonInstanceForEventsSerialization(config).toJsonTree(user).getAsJsonObject(); + JsonObject expected = new JsonObject(); + expected.addProperty("key", "userkey"); + assertEquals(expected, o); + } + + private Set getPrivateAttrs(JsonObject o) { + Type type = new TypeToken>(){}.getType(); + return TEST_GSON_INSTANCE.>fromJson(o.get("privateAttrs"), type); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java b/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java new file mode 100644 index 000000000..d442141b6 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java @@ -0,0 +1,141 @@ +package com.launchdarkly.sdk.server; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.collect.ImmutableMap; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.json.JsonSerialization; +import com.launchdarkly.sdk.json.LDJackson; +import com.launchdarkly.sdk.json.SerializationException; + +import org.junit.Test; + +import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; +import static com.launchdarkly.sdk.EvaluationReason.ErrorKind.MALFORMED_FLAG; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +@SuppressWarnings("javadoc") +public class FeatureFlagsStateTest { + @Test + public void canGetFlagValue() { + Evaluator.EvalResult eval = new Evaluator.EvalResult(LDValue.of("value"), 1, EvaluationReason.off()); + DataModel.FeatureFlag flag = flagBuilder("key").build(); + FeatureFlagsState state = new FeatureFlagsState.Builder().addFlag(flag, eval).build(); + + assertEquals(LDValue.of("value"), state.getFlagValue("key")); + } + + @Test + public void unknownFlagReturnsNullValue() { + FeatureFlagsState state = new FeatureFlagsState.Builder().build(); + + assertNull(state.getFlagValue("key")); + } + + @Test + public void canGetFlagReason() { + Evaluator.EvalResult eval = new Evaluator.EvalResult(LDValue.of("value1"), 1, EvaluationReason.off()); + DataModel.FeatureFlag flag = flagBuilder("key").build(); + FeatureFlagsState state = new FeatureFlagsState.Builder(FlagsStateOption.WITH_REASONS) + .addFlag(flag, eval).build(); + + assertEquals(EvaluationReason.off(), state.getFlagReason("key")); + } + + @Test + public void unknownFlagReturnsNullReason() { + FeatureFlagsState state = new FeatureFlagsState.Builder().build(); + + assertNull(state.getFlagReason("key")); + } + + @Test + public void reasonIsNullIfReasonsWereNotRecorded() { + Evaluator.EvalResult eval = new Evaluator.EvalResult(LDValue.of("value1"), 1, EvaluationReason.off()); + DataModel.FeatureFlag flag = flagBuilder("key").build(); + FeatureFlagsState state = new FeatureFlagsState.Builder().addFlag(flag, eval).build(); + + assertNull(state.getFlagReason("key")); + } + + @Test + public void flagCanHaveNullValue() { + Evaluator.EvalResult eval = new Evaluator.EvalResult(LDValue.ofNull(), 1, null); + DataModel.FeatureFlag flag = flagBuilder("key").build(); + FeatureFlagsState state = new FeatureFlagsState.Builder().addFlag(flag, eval).build(); + + assertEquals(LDValue.ofNull(), state.getFlagValue("key")); + } + + @Test + public void canConvertToValuesMap() { + Evaluator.EvalResult eval1 = new Evaluator.EvalResult(LDValue.of("value1"), 0, EvaluationReason.off()); + DataModel.FeatureFlag flag1 = flagBuilder("key1").build(); + Evaluator.EvalResult eval2 = new Evaluator.EvalResult(LDValue.of("value2"), 1, EvaluationReason.fallthrough()); + DataModel.FeatureFlag flag2 = flagBuilder("key2").build(); + FeatureFlagsState state = new FeatureFlagsState.Builder() + .addFlag(flag1, eval1).addFlag(flag2, eval2).build(); + + ImmutableMap expected = ImmutableMap.of("key1", LDValue.of("value1"), "key2", LDValue.of("value2")); + assertEquals(expected, state.toValuesMap()); + } + + @Test + public void canConvertToJson() { + String actualJsonString = JsonSerialization.serialize(makeInstanceForSerialization()); + assertEquals(LDValue.parse(makeExpectedJsonSerialization()), LDValue.parse(actualJsonString)); + } + + @Test + public void canConvertFromJson() throws SerializationException { + FeatureFlagsState state = JsonSerialization.deserialize(makeExpectedJsonSerialization(), FeatureFlagsState.class); + assertEquals(makeInstanceForSerialization(), state); + } + + private static FeatureFlagsState makeInstanceForSerialization() { + Evaluator.EvalResult eval1 = new Evaluator.EvalResult(LDValue.of("value1"), 0, EvaluationReason.off()); + DataModel.FeatureFlag flag1 = flagBuilder("key1").version(100).trackEvents(false).build(); + Evaluator.EvalResult eval2 = new Evaluator.EvalResult(LDValue.of("value2"), 1, EvaluationReason.fallthrough()); + DataModel.FeatureFlag flag2 = flagBuilder("key2").version(200).trackEvents(true).debugEventsUntilDate(1000L).build(); + Evaluator.EvalResult eval3 = new Evaluator.EvalResult(LDValue.of("default"), NO_VARIATION, EvaluationReason.error(MALFORMED_FLAG)); + DataModel.FeatureFlag flag3 = flagBuilder("key3").version(300).build(); + return new FeatureFlagsState.Builder(FlagsStateOption.WITH_REASONS) + .addFlag(flag1, eval1).addFlag(flag2, eval2).addFlag(flag3, eval3).build(); + } + + private static String makeExpectedJsonSerialization() { + return "{\"key1\":\"value1\",\"key2\":\"value2\",\"key3\":\"default\"," + + "\"$flagsState\":{" + + "\"key1\":{" + + "\"variation\":0,\"version\":100,\"reason\":{\"kind\":\"OFF\"}" + // note, "trackEvents: false" is omitted + "},\"key2\":{" + + "\"variation\":1,\"version\":200,\"reason\":{\"kind\":\"FALLTHROUGH\"},\"trackEvents\":true,\"debugEventsUntilDate\":1000" + + "},\"key3\":{" + + "\"version\":300,\"reason\":{\"kind\":\"ERROR\",\"errorKind\":\"MALFORMED_FLAG\"}" + + "}" + + "}," + + "\"$valid\":true" + + "}"; + } + + @Test + public void canSerializeAndDeserializeWithJackson() throws Exception { + // FeatureFlagsState, being a JsonSerializable, should get the same custom serialization/deserialization + // support that is provided by java-sdk-common for Gson and Jackson. Our Gson interoperability just relies + // on the same Gson annotations that we use internally, but the Jackson adapter will only work if the + // java-server-sdk and java-sdk-common packages are configured together correctly. So we'll test that here. + // If it fails, the symptom will be something like Jackson complaining that it doesn't know how to + // instantiate the FeatureFlagsState class. + + ObjectMapper jacksonMapper = new ObjectMapper(); + jacksonMapper.registerModule(LDJackson.module()); + + String actualJsonString = jacksonMapper.writeValueAsString(makeInstanceForSerialization()); + assertEquals(LDValue.parse(makeExpectedJsonSerialization()), LDValue.parse(actualJsonString)); + + FeatureFlagsState state = jacksonMapper.readValue(makeExpectedJsonSerialization(), FeatureFlagsState.class); + assertEquals(makeInstanceForSerialization(), state); + } +} diff --git a/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java b/src/test/java/com/launchdarkly/sdk/server/FeatureRequestorTest.java similarity index 87% rename from src/test/java/com/launchdarkly/client/FeatureRequestorTest.java rename to src/test/java/com/launchdarkly/sdk/server/FeatureRequestorTest.java index dacb9b0a2..99a37daad 100644 --- a/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/FeatureRequestorTest.java @@ -1,4 +1,11 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.DataModel; +import com.launchdarkly.sdk.server.DefaultFeatureRequestor; +import com.launchdarkly.sdk.server.FeatureRequestor; +import com.launchdarkly.sdk.server.HttpErrorException; +import com.launchdarkly.sdk.server.LDClient; +import com.launchdarkly.sdk.server.LDConfig; import org.junit.Assert; import org.junit.Test; @@ -8,9 +15,9 @@ import javax.net.ssl.SSLHandshakeException; -import static com.launchdarkly.client.TestHttpUtil.httpsServerWithSelfSignedCert; -import static com.launchdarkly.client.TestHttpUtil.jsonResponse; -import static com.launchdarkly.client.TestHttpUtil.makeStartedServer; +import static com.launchdarkly.sdk.server.TestHttpUtil.httpsServerWithSelfSignedCert; +import static com.launchdarkly.sdk.server.TestHttpUtil.jsonResponse; +import static com.launchdarkly.sdk.server.TestHttpUtil.makeStartedServer; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; @@ -72,8 +79,8 @@ public void requestFlag() throws Exception { try (MockWebServer server = makeStartedServer(resp)) { try (DefaultFeatureRequestor r = makeRequestor(server)) { - FeatureFlag flag = r.getFlag(flag1Key); - + DataModel.FeatureFlag flag = r.getFlag(flag1Key); + RecordedRequest req = server.takeRequest(); assertEquals("/sdk/latest-flags/" + flag1Key, req.getPath()); verifyHeaders(req); @@ -89,8 +96,8 @@ public void requestSegment() throws Exception { try (MockWebServer server = makeStartedServer(resp)) { try (DefaultFeatureRequestor r = makeRequestor(server)) { - Segment segment = r.getSegment(segment1Key); - + DataModel.Segment segment = r.getSegment(segment1Key); + RecordedRequest req = server.takeRequest(); assertEquals("/sdk/latest-segments/" + segment1Key, req.getPath()); verifyHeaders(req); @@ -140,15 +147,15 @@ public void requestsAreCached() throws Exception { try (MockWebServer server = makeStartedServer(cacheableResp)) { try (DefaultFeatureRequestor r = makeRequestor(server)) { - FeatureFlag flag1a = r.getFlag(flag1Key); - + DataModel.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); + DataModel.FeatureFlag flag1b = r.getFlag(flag1Key); verifyFlag(flag1b, flag1Key); assertNull(server.takeRequest(0, TimeUnit.SECONDS)); // there was no second request, due to the cache hit } @@ -183,7 +190,7 @@ public void httpClientCanUseCustomTlsConfig() throws Exception { .build(); try (DefaultFeatureRequestor r = makeRequestor(serverWithCert.server, config)) { - FeatureFlag flag = r.getFlag(flag1Key); + DataModel.FeatureFlag flag = r.getFlag(flag1Key); verifyFlag(flag, flag1Key); } } @@ -199,7 +206,7 @@ public void httpClientCanUseProxyConfig() throws Exception { .build(); try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(sdkKey, config.httpConfig, fakeBaseUri, true)) { - FeatureFlag flag = r.getFlag(flag1Key); + DataModel.FeatureFlag flag = r.getFlag(flag1Key); verifyFlag(flag, flag1Key); assertEquals(1, server.getRequestCount()); @@ -212,12 +219,12 @@ private void verifyHeaders(RecordedRequest req) { assertEquals("JavaClient/" + LDClient.CLIENT_VERSION, req.getHeader("User-Agent")); } - private void verifyFlag(FeatureFlag flag, String key) { + private void verifyFlag(DataModel.FeatureFlag flag, String key) { assertNotNull(flag); assertEquals(key, flag.getKey()); } - private void verifySegment(Segment segment, String key) { + private void verifySegment(DataModel.Segment segment, String key) { assertNotNull(segment); assertEquals(key, segment.getKey()); } diff --git a/src/test/java/com/launchdarkly/client/FlagModelDeserializationTest.java b/src/test/java/com/launchdarkly/sdk/server/FlagModelDeserializationTest.java similarity index 80% rename from src/test/java/com/launchdarkly/client/FlagModelDeserializationTest.java rename to src/test/java/com/launchdarkly/sdk/server/FlagModelDeserializationTest.java index 90b2d9c50..83d0dea83 100644 --- a/src/test/java/com/launchdarkly/client/FlagModelDeserializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/FlagModelDeserializationTest.java @@ -1,6 +1,8 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.gson.Gson; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.server.DataModel; import org.junit.Test; @@ -14,7 +16,7 @@ public class FlagModelDeserializationTest { @Test public void precomputedReasonsAreAddedToPrerequisites() { String flagJson = "{\"key\":\"flagkey\",\"prerequisites\":[{\"key\":\"prereq0\"},{\"key\":\"prereq1\"}]}"; - FeatureFlag flag = gson.fromJson(flagJson, FeatureFlag.class); + DataModel.FeatureFlag flag = gson.fromJson(flagJson, DataModel.FeatureFlag.class); assertNotNull(flag.getPrerequisites()); assertEquals(2, flag.getPrerequisites().size()); assertEquals(EvaluationReason.prerequisiteFailed("prereq0"), flag.getPrerequisites().get(0).getPrerequisiteFailedReason()); @@ -24,7 +26,7 @@ public void precomputedReasonsAreAddedToPrerequisites() { @Test public void precomputedReasonsAreAddedToRules() { String flagJson = "{\"key\":\"flagkey\",\"rules\":[{\"id\":\"ruleid0\"},{\"id\":\"ruleid1\"}]}"; - FeatureFlag flag = gson.fromJson(flagJson, FeatureFlag.class); + DataModel.FeatureFlag flag = gson.fromJson(flagJson, DataModel.FeatureFlag.class); assertNotNull(flag.getRules()); assertEquals(2, flag.getRules().size()); assertEquals(EvaluationReason.ruleMatch(0, "ruleid0"), flag.getRules().get(0).getRuleMatchReason()); diff --git a/src/test/java/com/launchdarkly/sdk/server/InMemoryDataStoreTest.java b/src/test/java/com/launchdarkly/sdk/server/InMemoryDataStoreTest.java new file mode 100644 index 000000000..9e2b0d1ff --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/InMemoryDataStoreTest.java @@ -0,0 +1,13 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.InMemoryDataStore; +import com.launchdarkly.sdk.server.interfaces.DataStore; + +@SuppressWarnings("javadoc") +public class InMemoryDataStoreTest extends DataStoreTestBase { + + @Override + protected DataStore makeStore() { + return new InMemoryDataStore(); + } +} diff --git a/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientEndToEndTest.java similarity index 85% rename from src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java rename to src/test/java/com/launchdarkly/sdk/server/LDClientEndToEndTest.java index 355090516..d75975bd9 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientEndToEndTest.java @@ -1,17 +1,22 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.gson.Gson; import com.google.gson.JsonObject; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.DataModel; +import com.launchdarkly.sdk.server.LDClient; +import com.launchdarkly.sdk.server.LDConfig; 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; -import static com.launchdarkly.client.TestHttpUtil.jsonResponse; -import static com.launchdarkly.client.TestHttpUtil.makeStartedServer; +import static com.launchdarkly.sdk.server.Components.noEvents; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.TestHttpUtil.basePollingConfig; +import static com.launchdarkly.sdk.server.TestHttpUtil.baseStreamingConfig; +import static com.launchdarkly.sdk.server.TestHttpUtil.httpsServerWithSelfSignedCert; +import static com.launchdarkly.sdk.server.TestHttpUtil.jsonResponse; +import static com.launchdarkly.sdk.server.TestHttpUtil.makeStartedServer; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -23,7 +28,7 @@ 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) + private static final DataModel.FeatureFlag flag = flagBuilder(flagKey) .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/sdk/server/LDClientEvaluationTest.java similarity index 64% rename from src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java rename to src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java index 98403deb7..ed745ad25 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java @@ -1,25 +1,36 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.common.collect.ImmutableMap; import com.google.gson.Gson; import com.google.gson.JsonElement; -import com.google.gson.JsonPrimitive; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import org.junit.Test; -import java.util.Arrays; +import java.time.Duration; import java.util.Map; -import static com.launchdarkly.client.TestUtil.booleanFlagWithClauses; -import static com.launchdarkly.client.TestUtil.failedUpdateProcessor; -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.specificFeatureStore; -import static com.launchdarkly.client.TestUtil.specificUpdateProcessor; -import static com.launchdarkly.client.VersionedDataKind.FEATURES; -import static com.launchdarkly.client.VersionedDataKind.SEGMENTS; +import static com.google.common.collect.Iterables.getFirst; +import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.ModelBuilders.booleanFlagWithClauses; +import static com.launchdarkly.sdk.server.ModelBuilders.clause; +import static com.launchdarkly.sdk.server.ModelBuilders.fallthroughVariation; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.flagWithValue; +import static com.launchdarkly.sdk.server.ModelBuilders.segmentBuilder; +import static com.launchdarkly.sdk.server.TestComponents.dataStoreThatThrowsException; +import static com.launchdarkly.sdk.server.TestComponents.failedDataSource; +import static com.launchdarkly.sdk.server.TestComponents.initedDataStore; +import static com.launchdarkly.sdk.server.TestComponents.specificDataSource; +import static com.launchdarkly.sdk.server.TestComponents.specificDataStore; +import static com.launchdarkly.sdk.server.TestUtil.upsertFlag; +import static com.launchdarkly.sdk.server.TestUtil.upsertSegment; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; @@ -31,10 +42,10 @@ public class LDClientEvaluationTest { private static final LDUser userWithNullKey = new LDUser.Builder((String)null).build(); private static final Gson gson = new Gson(); - private FeatureStore featureStore = TestUtil.initedFeatureStore(); + private DataStore dataStore = initedDataStore(); private LDConfig config = new LDConfig.Builder() - .dataStore(specificFeatureStore(featureStore)) + .dataStore(specificDataStore(dataStore)) .events(Components.noEvents()) .dataSource(Components.externalUpdatesOnly()) .build(); @@ -42,7 +53,7 @@ public class LDClientEvaluationTest { @Test public void boolVariationReturnsFlagValue() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(true))); + upsertFlag(dataStore, flagWithValue("key", LDValue.of(true))); assertTrue(client.boolVariation("key", user, false)); } @@ -54,31 +65,31 @@ public void boolVariationReturnsDefaultValueForUnknownFlag() throws Exception { @Test public void boolVariationReturnsDefaultValueForWrongType() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of("wrong"))); + upsertFlag(dataStore, flagWithValue("key", LDValue.of("wrong"))); assertFalse(client.boolVariation("key", user, false)); } @Test public void intVariationReturnsFlagValue() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(2))); + upsertFlag(dataStore, 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", LDValue.of(2.0))); + upsertFlag(dataStore, 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", 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))); + upsertFlag(dataStore, flagWithValue("flag1", LDValue.of(2.25))); + upsertFlag(dataStore, flagWithValue("flag2", LDValue.of(2.75))); + upsertFlag(dataStore, flagWithValue("flag3", LDValue.of(-2.25))); + upsertFlag(dataStore, flagWithValue("flag4", LDValue.of(-2.75))); assertEquals(new Integer(2), client.intVariation("flag1", user, 1)); assertEquals(new Integer(2), client.intVariation("flag2", user, 1)); @@ -93,21 +104,21 @@ public void intVariationReturnsDefaultValueForUnknownFlag() throws Exception { @Test public void intVariationReturnsDefaultValueForWrongType() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of("wrong"))); + upsertFlag(dataStore, 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", LDValue.of(2.5d))); + upsertFlag(dataStore, 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", LDValue.of(2))); + upsertFlag(dataStore, flagWithValue("key", LDValue.of(2))); assertEquals(new Double(2.0d), client.doubleVariation("key", user, 1.0d)); } @@ -119,50 +130,46 @@ public void doubleVariationReturnsDefaultValueForUnknownFlag() throws Exception @Test public void doubleVariationReturnsDefaultValueForWrongType() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of("wrong"))); + upsertFlag(dataStore, 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", LDValue.of("b"))); + upsertFlag(dataStore, flagWithValue("key", LDValue.of("b"))); assertEquals("b", client.stringVariation("key", user, "a")); } + @Test + public void stringVariationWithNullDefaultReturnsFlagValue() throws Exception { + upsertFlag(dataStore, flagWithValue("key", LDValue.of("b"))); + + assertEquals("b", client.stringVariation("key", user, null)); + } + @Test public void stringVariationReturnsDefaultValueForUnknownFlag() throws Exception { assertEquals("a", client.stringVariation("key", user, "a")); } + @Test + public void stringVariationWithNullDefaultReturnsDefaultValueForUnknownFlag() throws Exception { + assertNull(client.stringVariation("key", user, null)); + } + @Test public void stringVariationReturnsDefaultValueForWrongType() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(true))); + upsertFlag(dataStore, flagWithValue("key", LDValue.of(true))); assertEquals("a", client.stringVariation("key", user, "a")); } - @SuppressWarnings("deprecation") - @Test - public void deprecatedJsonVariationReturnsFlagValue() throws Exception { - LDValue data = LDValue.buildObject().put("thing", LDValue.of("stuff")).build(); - featureStore.upsert(FEATURES, flagWithValue("key", data)); - - assertEquals(data.asJsonElement(), client.jsonVariation("key", user, new JsonPrimitive(42))); - } - - @SuppressWarnings("deprecation") - @Test - 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)); + upsertFlag(dataStore, flagWithValue("key", data)); assertEquals(data, client.jsonValueVariation("key", user, LDValue.of(42))); } @@ -176,22 +183,22 @@ public void jsonValueVariationReturnsDefaultValueForUnknownFlag() throws Excepti @Test public void canMatchUserBySegment() throws Exception { // This is similar to one of the tests in FeatureFlagTest, but more end-to-end - Segment segment = new Segment.Builder("segment1") + DataModel.Segment segment = segmentBuilder("segment1") .version(1) - .included(Arrays.asList(user.getKeyAsString())) + .included(user.getKey()) .build(); - featureStore.upsert(SEGMENTS, segment); + upsertSegment(dataStore, segment); - Clause clause = new Clause("", Operator.segmentMatch, Arrays.asList(LDValue.of("segment1")), false); - FeatureFlag feature = booleanFlagWithClauses("feature", clause); - featureStore.upsert(FEATURES, feature); + DataModel.Clause clause = clause(null, DataModel.Operator.segmentMatch, LDValue.of("segment1")); + DataModel.FeatureFlag feature = booleanFlagWithClauses("feature", clause); + upsertFlag(dataStore, feature); assertTrue(client.boolVariation("feature", user, false)); } @Test public void canGetDetailsForSuccessfulEvaluation() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(true))); + upsertFlag(dataStore, flagWithValue("key", LDValue.of(true))); EvaluationDetail expectedResult = EvaluationDetail.fromValue(true, 0, EvaluationReason.off()); @@ -200,19 +207,19 @@ public void canGetDetailsForSuccessfulEvaluation() throws Exception { @Test public void variationReturnsDefaultIfFlagEvaluatesToNull() { - FeatureFlag flag = new FeatureFlagBuilder("key").on(false).offVariation(null).build(); - featureStore.upsert(FEATURES, flag); + DataModel.FeatureFlag flag = flagBuilder("key").on(false).offVariation(null).build(); + upsertFlag(dataStore, 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); + DataModel.FeatureFlag flag = flagBuilder("key").on(false).offVariation(null).build(); + upsertFlag(dataStore, flag); EvaluationDetail expected = EvaluationDetail.fromValue("default", - null, EvaluationReason.off()); + NO_VARIATION, EvaluationReason.off()); EvaluationDetail actual = client.stringVariationDetail("key", user, "default"); assertEquals(expected, actual); assertTrue(actual.isDefaultValue()); @@ -220,15 +227,15 @@ public void variationDetailReturnsDefaultIfFlagEvaluatesToNull() { @Test public void appropriateErrorIfClientNotInitialized() throws Exception { - FeatureStore badFeatureStore = new InMemoryFeatureStore(); + DataStore badDataStore = new InMemoryDataStore(); LDConfig badConfig = new LDConfig.Builder() - .dataStore(specificFeatureStore(badFeatureStore)) + .dataStore(specificDataStore(badDataStore)) .events(Components.noEvents()) - .dataSource(specificUpdateProcessor(failedUpdateProcessor())) - .startWaitMillis(0) + .dataSource(specificDataSource(failedDataSource())) + .startWait(Duration.ZERO) .build(); try (LDClientInterface badClient = new LDClient("SDK_KEY", badConfig)) { - EvaluationDetail expectedResult = EvaluationDetail.fromValue(false, null, + EvaluationDetail expectedResult = EvaluationDetail.fromValue(false, NO_VARIATION, EvaluationReason.error(EvaluationReason.ErrorKind.CLIENT_NOT_READY)); assertEquals(expectedResult, badClient.boolVariationDetail("key", user, false)); } @@ -236,25 +243,25 @@ public void appropriateErrorIfClientNotInitialized() throws Exception { @Test public void appropriateErrorIfFlagDoesNotExist() throws Exception { - EvaluationDetail expectedResult = EvaluationDetail.fromValue("default", null, + EvaluationDetail expectedResult = EvaluationDetail.fromValue("default", NO_VARIATION, 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", LDValue.of(true))); + upsertFlag(dataStore, flagWithValue("key", LDValue.of(true))); - EvaluationDetail expectedResult = EvaluationDetail.fromValue("default", null, + EvaluationDetail expectedResult = EvaluationDetail.fromValue("default", NO_VARIATION, 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", LDValue.of(true))); + upsertFlag(dataStore, flagWithValue("key", LDValue.of(true))); - EvaluationDetail expectedResult = EvaluationDetail.fromValue(3, null, + EvaluationDetail expectedResult = EvaluationDetail.fromValue(3, NO_VARIATION, EvaluationReason.error(EvaluationReason.ErrorKind.WRONG_TYPE)); assertEquals(expectedResult, client.intVariationDetail("key", user, 3)); } @@ -262,55 +269,29 @@ public void appropriateErrorIfValueWrongType() throws Exception { @Test public void appropriateErrorForUnexpectedException() throws Exception { RuntimeException exception = new RuntimeException("sorry"); - FeatureStore badFeatureStore = featureStoreThatThrowsException(exception); + DataStore badDataStore = dataStoreThatThrowsException(exception); LDConfig badConfig = new LDConfig.Builder() - .dataStore(specificFeatureStore(badFeatureStore)) + .dataStore(specificDataStore(badDataStore)) .events(Components.noEvents()) .dataSource(Components.externalUpdatesOnly()) .build(); try (LDClientInterface badClient = new LDClient("SDK_KEY", badConfig)) { - EvaluationDetail expectedResult = EvaluationDetail.fromValue(false, null, + EvaluationDetail expectedResult = EvaluationDetail.fromValue(false, NO_VARIATION, EvaluationReason.exception(exception)); assertEquals(expectedResult, badClient.boolVariationDetail("key", user, false)); } } - @SuppressWarnings("deprecation") - @Test - public void allFlagsReturnsFlagValues() throws Exception { - 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", new JsonPrimitive("value1"), "key2", new JsonPrimitive("value2")), result); - } - - @SuppressWarnings("deprecation") - @Test - public void allFlagsReturnsNullForNullUser() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of("value"))); - - assertNull(client.allFlags(null)); - } - - @SuppressWarnings("deprecation") - @Test - public void allFlagsReturnsNullForNullUserKey() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of("value"))); - - assertNull(client.allFlags(userWithNullKey)); - } - @Test public void allFlagsStateReturnsState() throws Exception { - FeatureFlag flag1 = new FeatureFlagBuilder("key1") + DataModel.FeatureFlag flag1 = flagBuilder("key1") .version(100) .trackEvents(false) .on(false) .offVariation(0) .variations(LDValue.of("value1")) .build(); - FeatureFlag flag2 = new FeatureFlagBuilder("key2") + DataModel.FeatureFlag flag2 = flagBuilder("key2") .version(200) .trackEvents(true) .debugEventsUntilDate(1000L) @@ -318,8 +299,8 @@ public void allFlagsStateReturnsState() throws Exception { .fallthrough(fallthroughVariation(1)) .variations(LDValue.of("off"), LDValue.of("value2")) .build(); - featureStore.upsert(FEATURES, flag1); - featureStore.upsert(FEATURES, flag2); + upsertFlag(dataStore, flag1); + upsertFlag(dataStore, flag2); FeatureFlagsState state = client.allFlagsState(user); assertTrue(state.isValid()); @@ -340,34 +321,34 @@ public void allFlagsStateReturnsState() throws Exception { @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) + DataModel.FeatureFlag flag1 = flagBuilder("server-side-1").build(); + DataModel.FeatureFlag flag2 = flagBuilder("server-side-2").build(); + DataModel.FeatureFlag flag3 = flagBuilder("client-side-1").clientSide(true) .variations(LDValue.of("value1")).offVariation(0).build(); - FeatureFlag flag4 = new FeatureFlagBuilder("client-side-2").clientSide(true) + DataModel.FeatureFlag flag4 = flagBuilder("client-side-2").clientSide(true) .variations(LDValue.of("value2")).offVariation(0).build(); - featureStore.upsert(FEATURES, flag1); - featureStore.upsert(FEATURES, flag2); - featureStore.upsert(FEATURES, flag3); - featureStore.upsert(FEATURES, flag4); + upsertFlag(dataStore, flag1); + upsertFlag(dataStore, flag2); + upsertFlag(dataStore, flag3); + upsertFlag(dataStore, flag4); FeatureFlagsState state = client.allFlagsState(user, FlagsStateOption.CLIENT_SIDE_ONLY); assertTrue(state.isValid()); - Map allValues = state.toValuesMap(); - assertEquals(ImmutableMap.of("client-side-1", new JsonPrimitive("value1"), "client-side-2", new JsonPrimitive("value2")), allValues); + Map allValues = state.toValuesMap(); + assertEquals(ImmutableMap.of("client-side-1", LDValue.of("value1"), "client-side-2", LDValue.of("value2")), allValues); } @Test public void allFlagsStateReturnsStateWithReasons() { - FeatureFlag flag1 = new FeatureFlagBuilder("key1") + DataModel.FeatureFlag flag1 = flagBuilder("key1") .version(100) .trackEvents(false) .on(false) .offVariation(0) .variations(LDValue.of("value1")) .build(); - FeatureFlag flag2 = new FeatureFlagBuilder("key2") + DataModel.FeatureFlag flag2 = flagBuilder("key2") .version(200) .trackEvents(true) .debugEventsUntilDate(1000L) @@ -375,8 +356,8 @@ public void allFlagsStateReturnsStateWithReasons() { .fallthrough(fallthroughVariation(1)) .variations(LDValue.of("off"), LDValue.of("value2")) .build(); - featureStore.upsert(FEATURES, flag1); - featureStore.upsert(FEATURES, flag2); + upsertFlag(dataStore, flag1); + upsertFlag(dataStore, flag2); FeatureFlagsState state = client.allFlagsState(user, FlagsStateOption.WITH_REASONS); assertTrue(state.isValid()); @@ -398,21 +379,21 @@ public void allFlagsStateReturnsStateWithReasons() { @Test public void allFlagsStateCanOmitDetailsForUntrackedFlags() { long futureTime = System.currentTimeMillis() + 1000000; - FeatureFlag flag1 = new FeatureFlagBuilder("key1") + DataModel.FeatureFlag flag1 = flagBuilder("key1") .version(100) .trackEvents(false) .on(false) .offVariation(0) .variations(LDValue.of("value1")) .build(); - FeatureFlag flag2 = new FeatureFlagBuilder("key2") + DataModel.FeatureFlag flag2 = flagBuilder("key2") .version(200) .trackEvents(true) .on(true) .fallthrough(fallthroughVariation(1)) .variations(LDValue.of("off"), LDValue.of("value2")) .build(); - FeatureFlag flag3 = new FeatureFlagBuilder("key3") + DataModel.FeatureFlag flag3 = flagBuilder("key3") .version(300) .trackEvents(false) .debugEventsUntilDate(futureTime) // event tracking is turned on temporarily even though trackEvents is false @@ -420,9 +401,9 @@ public void allFlagsStateCanOmitDetailsForUntrackedFlags() { .offVariation(0) .variations(LDValue.of("value3")) .build(); - featureStore.upsert(FEATURES, flag1); - featureStore.upsert(FEATURES, flag2); - featureStore.upsert(FEATURES, flag3); + upsertFlag(dataStore, flag1); + upsertFlag(dataStore, flag2); + upsertFlag(dataStore, flag3); FeatureFlagsState state = client.allFlagsState(user, FlagsStateOption.WITH_REASONS, FlagsStateOption.DETAILS_ONLY_FOR_TRACKED_FLAGS); assertTrue(state.isValid()); @@ -443,9 +424,25 @@ public void allFlagsStateCanOmitDetailsForUntrackedFlags() { assertEquals(expected, gson.toJsonTree(state)); } + @Test + public void allFlagsStateFiltersOutDeletedFlags() throws Exception { + DataModel.FeatureFlag flag1 = flagBuilder("key1").version(1).build(); + DataModel.FeatureFlag flag2 = flagBuilder("key2").version(1).build(); + upsertFlag(dataStore, flag1); + upsertFlag(dataStore, flag2); + dataStore.upsert(FEATURES, flag2.getKey(), ItemDescriptor.deletedItem(flag2.getVersion() + 1)); + + FeatureFlagsState state = client.allFlagsState(user); + assertTrue(state.isValid()); + + Map valuesMap = state.toValuesMap(); + assertEquals(1, valuesMap.size()); + assertEquals(flag1.getKey(), getFirst(valuesMap.keySet(), null)); + } + @Test public void allFlagsStateReturnsEmptyStateForNullUser() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of("value"))); + upsertFlag(dataStore, flagWithValue("key", LDValue.of("value"))); FeatureFlagsState state = client.allFlagsState(null); assertFalse(state.isValid()); @@ -454,7 +451,7 @@ public void allFlagsStateReturnsEmptyStateForNullUser() throws Exception { @Test public void allFlagsStateReturnsEmptyStateForNullUserKey() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of("value"))); + upsertFlag(dataStore, 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/sdk/server/LDClientEventTest.java similarity index 61% rename from src/test/java/com/launchdarkly/client/LDClientEventTest.java rename to src/test/java/com/launchdarkly/sdk/server/LDClientEventTest.java index d15453fab..c0cd0c61b 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEventTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientEventTest.java @@ -1,21 +1,25 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; -import com.google.gson.JsonElement; -import com.google.gson.JsonPrimitive; -import com.launchdarkly.client.EvaluationReason.ErrorKind; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.EvaluationReason.ErrorKind; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.Event; import org.junit.Test; -import java.util.Arrays; - -import static com.launchdarkly.client.TestUtil.fallthroughVariation; -import static com.launchdarkly.client.TestUtil.flagWithValue; -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 com.launchdarkly.sdk.server.ModelBuilders.clauseMatchingUser; +import static com.launchdarkly.sdk.server.ModelBuilders.clauseNotMatchingUser; +import static com.launchdarkly.sdk.server.ModelBuilders.fallthroughVariation; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.flagWithValue; +import static com.launchdarkly.sdk.server.ModelBuilders.prerequisite; +import static com.launchdarkly.sdk.server.ModelBuilders.ruleBuilder; +import static com.launchdarkly.sdk.server.TestComponents.initedDataStore; +import static com.launchdarkly.sdk.server.TestComponents.specificDataStore; +import static com.launchdarkly.sdk.server.TestComponents.specificEventProcessor; +import static com.launchdarkly.sdk.server.TestUtil.upsertFlag; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; @@ -26,10 +30,10 @@ 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(); + private DataStore dataStore = initedDataStore(); + private TestComponents.TestEventProcessor eventSink = new TestComponents.TestEventProcessor(); private LDConfig config = new LDConfig.Builder() - .dataStore(specificFeatureStore(featureStore)) + .dataStore(specificDataStore(dataStore)) .events(specificEventProcessor(eventSink)) .dataSource(Components.externalUpdatesOnly()) .build(); @@ -43,7 +47,7 @@ public void identifySendsEvent() throws Exception { Event e = eventSink.events.get(0); assertEquals(Event.Identify.class, e.getClass()); Event.Identify ie = (Event.Identify)e; - assertEquals(user.getKey(), ie.user.getKey()); + assertEquals(user.getKey(), ie.getUser().getKey()); } @Test @@ -66,9 +70,9 @@ public void trackSendsEventWithoutData() throws Exception { 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(LDValue.ofNull(), ce.data); + assertEquals(user.getKey(), ce.getUser().getKey()); + assertEquals("eventkey", ce.getKey()); + assertEquals(LDValue.ofNull(), ce.getData()); } @Test @@ -80,9 +84,9 @@ public void trackSendsEventWithData() throws Exception { 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(user.getKey(), ce.getUser().getKey()); + assertEquals("eventkey", ce.getKey()); + assertEquals(data, ce.getData()); } @Test @@ -95,42 +99,10 @@ public void trackSendsEventWithDataAndMetricValue() throws Exception { 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); - } - - @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); + assertEquals(user.getKey(), ce.getUser().getKey()); + assertEquals("eventkey", ce.getKey()); + assertEquals(data, ce.getData()); + assertEquals(new Double(metricValue), ce.getMetricValue()); } @Test @@ -147,8 +119,8 @@ public void trackWithUserWithNoKeyDoesNotSendEvent() { @Test public void boolVariationSendsEvent() throws Exception { - FeatureFlag flag = flagWithValue("key", LDValue.of(true)); - featureStore.upsert(FEATURES, flag); + DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of(true)); + upsertFlag(dataStore, flag); client.boolVariation("key", user, false); assertEquals(1, eventSink.events.size()); @@ -164,8 +136,8 @@ public void boolVariationSendsEventForUnknownFlag() throws Exception { @Test public void boolVariationDetailSendsEvent() throws Exception { - FeatureFlag flag = flagWithValue("key", LDValue.of(true)); - featureStore.upsert(FEATURES, flag); + DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of(true)); + upsertFlag(dataStore, flag); client.boolVariationDetail("key", user, false); assertEquals(1, eventSink.events.size()); @@ -182,8 +154,8 @@ public void boolVariationDetailSendsEventForUnknownFlag() throws Exception { @Test public void intVariationSendsEvent() throws Exception { - FeatureFlag flag = flagWithValue("key", LDValue.of(2)); - featureStore.upsert(FEATURES, flag); + DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of(2)); + upsertFlag(dataStore, flag); client.intVariation("key", user, 1); assertEquals(1, eventSink.events.size()); @@ -199,8 +171,8 @@ public void intVariationSendsEventForUnknownFlag() throws Exception { @Test public void intVariationDetailSendsEvent() throws Exception { - FeatureFlag flag = flagWithValue("key", LDValue.of(2)); - featureStore.upsert(FEATURES, flag); + DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of(2)); + upsertFlag(dataStore, flag); client.intVariationDetail("key", user, 1); assertEquals(1, eventSink.events.size()); @@ -217,8 +189,8 @@ public void intVariationDetailSendsEventForUnknownFlag() throws Exception { @Test public void doubleVariationSendsEvent() throws Exception { - FeatureFlag flag = flagWithValue("key", LDValue.of(2.5d)); - featureStore.upsert(FEATURES, flag); + DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of(2.5d)); + upsertFlag(dataStore, flag); client.doubleVariation("key", user, 1.0d); assertEquals(1, eventSink.events.size()); @@ -234,8 +206,8 @@ public void doubleVariationSendsEventForUnknownFlag() throws Exception { @Test public void doubleVariationDetailSendsEvent() throws Exception { - FeatureFlag flag = flagWithValue("key", LDValue.of(2.5d)); - featureStore.upsert(FEATURES, flag); + DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of(2.5d)); + upsertFlag(dataStore, flag); client.doubleVariationDetail("key", user, 1.0d); assertEquals(1, eventSink.events.size()); @@ -252,8 +224,8 @@ public void doubleVariationDetailSendsEventForUnknownFlag() throws Exception { @Test public void stringVariationSendsEvent() throws Exception { - FeatureFlag flag = flagWithValue("key", LDValue.of("b")); - featureStore.upsert(FEATURES, flag); + DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of("b")); + upsertFlag(dataStore, flag); client.stringVariation("key", user, "a"); assertEquals(1, eventSink.events.size()); @@ -269,8 +241,8 @@ public void stringVariationSendsEventForUnknownFlag() throws Exception { @Test public void stringVariationDetailSendsEvent() throws Exception { - FeatureFlag flag = flagWithValue("key", LDValue.of("b")); - featureStore.upsert(FEATURES, flag); + DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of("b")); + upsertFlag(dataStore, flag); client.stringVariationDetail("key", user, "a"); assertEquals(1, eventSink.events.size()); @@ -285,58 +257,11 @@ public void stringVariationDetailSendsEventForUnknownFlag() throws Exception { EvaluationReason.error(ErrorKind.FLAG_NOT_FOUND)); } - @SuppressWarnings("deprecation") - @Test - public void jsonVariationSendsEvent() 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.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 { - LDValue defaultVal = LDValue.of(42); - - 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 { - 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.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 { - LDValue defaultVal = LDValue.of(42); - - 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); + DataModel.FeatureFlag flag = flagWithValue("key", data); + upsertFlag(dataStore, flag); LDValue defaultVal = LDValue.of(42); client.jsonValueVariationDetail("key", user, defaultVal); @@ -356,15 +281,15 @@ public void jsonValueVariationDetailSendsEventForUnknownFlag() throws Exception @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") + DataModel.Clause clause = clauseMatchingUser(user); + DataModel.Rule rule = ruleBuilder().id("id").clauses(clause).variation(1).trackEvents(true).build(); + DataModel.FeatureFlag flag = flagBuilder("flag") .on(true) - .rules(Arrays.asList(rule)) + .rules(rule) .offVariation(0) .variations(LDValue.of("off"), LDValue.of("on")) .build(); - featureStore.upsert(FEATURES, flag); + upsertFlag(dataStore, flag); client.stringVariation("flag", user, "default"); @@ -373,23 +298,23 @@ public void eventTrackingAndReasonCanBeForcedForRule() throws Exception { assertEquals(1, eventSink.events.size()); Event.FeatureRequest event = (Event.FeatureRequest)eventSink.events.get(0); - assertTrue(event.trackEvents); - assertEquals(EvaluationReason.ruleMatch(0, "id"), event.reason); + assertTrue(event.isTrackEvents()); + assertEquals(EvaluationReason.ruleMatch(0, "id"), event.getReason()); } @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") + DataModel.Clause clause0 = clauseNotMatchingUser(user); + DataModel.Clause clause1 = clauseMatchingUser(user); + DataModel.Rule rule0 = ruleBuilder().id("id0").clauses(clause0).variation(1).trackEvents(true).build(); + DataModel.Rule rule1 = ruleBuilder().id("id1").clauses(clause1).variation(1).trackEvents(false).build(); + DataModel.FeatureFlag flag = flagBuilder("flag") .on(true) - .rules(Arrays.asList(rule0, rule1)) + .rules(rule0, rule1) .offVariation(0) .variations(LDValue.of("off"), LDValue.of("on")) .build(); - featureStore.upsert(FEATURES, flag); + upsertFlag(dataStore, flag); client.stringVariation("flag", user, "default"); @@ -397,19 +322,19 @@ public void eventTrackingAndReasonAreNotForcedIfFlagIsNotSetForMatchingRule() th assertEquals(1, eventSink.events.size()); Event.FeatureRequest event = (Event.FeatureRequest)eventSink.events.get(0); - assertFalse(event.trackEvents); - assertNull(event.reason); + assertFalse(event.isTrackEvents()); + assertNull(event.getReason()); } @Test public void eventTrackingAndReasonCanBeForcedForFallthrough() throws Exception { - FeatureFlag flag = new FeatureFlagBuilder("flag") + DataModel.FeatureFlag flag = flagBuilder("flag") .on(true) - .fallthrough(new VariationOrRollout(0, null)) + .fallthrough(new DataModel.VariationOrRollout(0, null)) .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .trackEventsFallthrough(true) .build(); - featureStore.upsert(FEATURES, flag); + upsertFlag(dataStore, flag); client.stringVariation("flag", user, "default"); @@ -418,65 +343,65 @@ public void eventTrackingAndReasonCanBeForcedForFallthrough() throws Exception { assertEquals(1, eventSink.events.size()); Event.FeatureRequest event = (Event.FeatureRequest)eventSink.events.get(0); - assertTrue(event.trackEvents); - assertEquals(EvaluationReason.fallthrough(), event.reason); + assertTrue(event.isTrackEvents()); + assertEquals(EvaluationReason.fallthrough(), event.getReason()); } @Test public void eventTrackingAndReasonAreNotForcedForFallthroughIfFlagIsNotSet() throws Exception { - FeatureFlag flag = new FeatureFlagBuilder("flag") + DataModel.FeatureFlag flag = flagBuilder("flag") .on(true) - .fallthrough(new VariationOrRollout(0, null)) + .fallthrough(new DataModel.VariationOrRollout(0, null)) .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .trackEventsFallthrough(false) .build(); - featureStore.upsert(FEATURES, flag); + upsertFlag(dataStore, 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); + assertFalse(event.isTrackEvents()); + assertNull(event.getReason()); } @Test public void eventTrackingAndReasonAreNotForcedForFallthroughIfReasonIsNotFallthrough() throws Exception { - FeatureFlag flag = new FeatureFlagBuilder("flag") + DataModel.FeatureFlag flag = flagBuilder("flag") .on(false) // so the evaluation reason will be OFF, not FALLTHROUGH .offVariation(1) - .fallthrough(new VariationOrRollout(0, null)) + .fallthrough(new DataModel.VariationOrRollout(0, null)) .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .trackEventsFallthrough(true) .build(); - featureStore.upsert(FEATURES, flag); + upsertFlag(dataStore, 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); + assertFalse(event.isTrackEvents()); + assertNull(event.getReason()); } @Test public void eventIsSentForExistingPrererequisiteFlag() throws Exception { - FeatureFlag f0 = new FeatureFlagBuilder("feature0") + DataModel.FeatureFlag f0 = flagBuilder("feature0") .on(true) - .prerequisites(Arrays.asList(new Prerequisite("feature1", 1))) + .prerequisites(prerequisite("feature1", 1)) .fallthrough(fallthroughVariation(0)) .offVariation(1) .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .version(1) .build(); - FeatureFlag f1 = new FeatureFlagBuilder("feature1") + DataModel.FeatureFlag f1 = flagBuilder("feature1") .on(true) .fallthrough(fallthroughVariation(1)) .variations(LDValue.of("nogo"), LDValue.of("go")) .version(2) .build(); - featureStore.upsert(FEATURES, f0); - featureStore.upsert(FEATURES, f1); + upsertFlag(dataStore, f0); + upsertFlag(dataStore, f1); client.stringVariation("feature0", user, "default"); @@ -487,22 +412,22 @@ public void eventIsSentForExistingPrererequisiteFlag() throws Exception { @Test public void eventIsSentWithReasonForExistingPrererequisiteFlag() throws Exception { - FeatureFlag f0 = new FeatureFlagBuilder("feature0") + DataModel.FeatureFlag f0 = flagBuilder("feature0") .on(true) - .prerequisites(Arrays.asList(new Prerequisite("feature1", 1))) + .prerequisites(prerequisite("feature1", 1)) .fallthrough(fallthroughVariation(0)) .offVariation(1) .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .version(1) .build(); - FeatureFlag f1 = new FeatureFlagBuilder("feature1") + DataModel.FeatureFlag f1 = flagBuilder("feature1") .on(true) .fallthrough(fallthroughVariation(1)) .variations(LDValue.of("nogo"), LDValue.of("go")) .version(2) .build(); - featureStore.upsert(FEATURES, f0); - featureStore.upsert(FEATURES, f1); + upsertFlag(dataStore, f0); + upsertFlag(dataStore, f1); client.stringVariationDetail("feature0", user, "default"); @@ -513,15 +438,15 @@ public void eventIsSentWithReasonForExistingPrererequisiteFlag() throws Exceptio @Test public void eventIsNotSentForUnknownPrererequisiteFlag() throws Exception { - FeatureFlag f0 = new FeatureFlagBuilder("feature0") + DataModel.FeatureFlag f0 = flagBuilder("feature0") .on(true) - .prerequisites(Arrays.asList(new Prerequisite("feature1", 1))) + .prerequisites(prerequisite("feature1", 1)) .fallthrough(fallthroughVariation(0)) .offVariation(1) .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .version(1) .build(); - featureStore.upsert(FEATURES, f0); + upsertFlag(dataStore, f0); client.stringVariation("feature0", user, "default"); @@ -531,15 +456,15 @@ public void eventIsNotSentForUnknownPrererequisiteFlag() throws Exception { @Test public void failureReasonIsGivenForUnknownPrererequisiteFlagIfDetailsWereRequested() throws Exception { - FeatureFlag f0 = new FeatureFlagBuilder("feature0") + DataModel.FeatureFlag f0 = flagBuilder("feature0") .on(true) - .prerequisites(Arrays.asList(new Prerequisite("feature1", 1))) + .prerequisites(prerequisite("feature1", 1)) .fallthrough(fallthroughVariation(0)) .offVariation(1) .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .version(1) .build(); - featureStore.upsert(FEATURES, f0); + upsertFlag(dataStore, f0); client.stringVariationDetail("feature0", user, "default"); @@ -548,33 +473,34 @@ public void failureReasonIsGivenForUnknownPrererequisiteFlagIfDetailsWereRequest EvaluationReason.prerequisiteFailed("feature1")); } - private void checkFeatureEvent(Event e, FeatureFlag flag, LDValue value, LDValue defaultVal, + private void checkFeatureEvent(Event e, DataModel.FeatureFlag flag, LDValue value, LDValue defaultVal, String prereqOf, EvaluationReason reason) { assertEquals(Event.FeatureRequest.class, e.getClass()); Event.FeatureRequest fe = (Event.FeatureRequest)e; - assertEquals(flag.getKey(), fe.key); - assertEquals(user.getKey(), fe.user.getKey()); - assertEquals(new Integer(flag.getVersion()), fe.version); - assertEquals(value, fe.value); - assertEquals(defaultVal, fe.defaultVal); - assertEquals(prereqOf, fe.prereqOf); - assertEquals(reason, fe.reason); - assertEquals(flag.isTrackEvents(), fe.trackEvents); - assertEquals(flag.getDebugEventsUntilDate(), fe.debugEventsUntilDate); + assertEquals(flag.getKey(), fe.getKey()); + assertEquals(user.getKey(), fe.getUser().getKey()); + assertEquals(flag.getVersion(), fe.getVersion()); + assertEquals(value, fe.getValue()); + assertEquals(defaultVal, fe.getDefaultVal()); + assertEquals(prereqOf, fe.getPrereqOf()); + assertEquals(reason, fe.getReason()); + assertEquals(flag.isTrackEvents(), fe.isTrackEvents()); + assertEquals(flag.getDebugEventsUntilDate() == null ? 0L : flag.getDebugEventsUntilDate().longValue(), fe.getDebugEventsUntilDate()); } 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; - assertEquals(key, fe.key); - assertEquals(user.getKey(), fe.user.getKey()); - assertNull(fe.version); - assertEquals(defaultVal, fe.value); - assertEquals(defaultVal, fe.defaultVal); - assertEquals(prereqOf, fe.prereqOf); - assertEquals(reason, fe.reason); - assertFalse(fe.trackEvents); - assertNull(fe.debugEventsUntilDate); + assertEquals(key, fe.getKey()); + assertEquals(user.getKey(), fe.getUser().getKey()); + assertEquals(-1, fe.getVersion()); + assertEquals(-1, fe.getVariation()); + assertEquals(defaultVal, fe.getValue()); + assertEquals(defaultVal, fe.getDefaultVal()); + assertEquals(prereqOf, fe.getPrereqOf()); + assertEquals(reason, fe.getReason()); + assertFalse(fe.isTrackEvents()); + assertEquals(0L, fe.getDebugEventsUntilDate()); } } diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientExternalUpdatesOnlyTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientExternalUpdatesOnlyTest.java new file mode 100644 index 000000000..089b3ec12 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientExternalUpdatesOnlyTest.java @@ -0,0 +1,63 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.interfaces.DataStore; + +import org.junit.Test; + +import java.io.IOException; + +import static com.launchdarkly.sdk.server.ModelBuilders.flagWithValue; +import static com.launchdarkly.sdk.server.TestComponents.initedDataStore; +import static com.launchdarkly.sdk.server.TestComponents.specificDataStore; +import static com.launchdarkly.sdk.server.TestUtil.upsertFlag; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +@SuppressWarnings("javadoc") +public class LDClientExternalUpdatesOnlyTest { + @Test + public void externalUpdatesOnlyClientHasNullDataSource() throws Exception { + LDConfig config = new LDConfig.Builder() + .dataSource(Components.externalUpdatesOnly()) + .build(); + try (LDClient client = new LDClient("SDK_KEY", config)) { + assertEquals(Components.NullDataSource.class, client.dataSource.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 externalUpdatesOnlyClientGetsFlagFromDataStore() throws IOException { + DataStore testDataStore = initedDataStore(); + LDConfig config = new LDConfig.Builder() + .dataSource(Components.externalUpdatesOnly()) + .dataStore(specificDataStore(testDataStore)) + .build(); + DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of(true)); + upsertFlag(testDataStore, flag); + try (LDClient client = new LDClient("SDK_KEY", config)) { + assertTrue(client.boolVariation("key", new LDUser("user"), false)); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientOfflineTest.java similarity index 56% rename from src/test/java/com/launchdarkly/client/LDClientOfflineTest.java rename to src/test/java/com/launchdarkly/sdk/server/LDClientOfflineTest.java index c725f25ef..3565554d7 100644 --- a/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientOfflineTest.java @@ -1,19 +1,23 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import com.google.common.collect.ImmutableMap; -import com.google.gson.JsonElement; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.FeatureFlagsState; +import com.launchdarkly.sdk.server.LDClient; +import com.launchdarkly.sdk.server.LDClientInterface; +import com.launchdarkly.sdk.server.LDConfig; +import com.launchdarkly.sdk.server.interfaces.DataStore; import org.junit.Test; 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 com.launchdarkly.sdk.server.ModelBuilders.flagWithValue; +import static com.launchdarkly.sdk.server.TestComponents.initedDataStore; +import static com.launchdarkly.sdk.server.TestComponents.specificDataStore; +import static com.launchdarkly.sdk.server.TestUtil.upsertFlag; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; @@ -22,12 +26,12 @@ public class LDClientOfflineTest { private static final LDUser user = new LDUser("user"); @Test - public void offlineClientHasNullUpdateProcessor() throws IOException { + public void offlineClientHasNullDataSource() throws IOException { LDConfig config = new LDConfig.Builder() .offline(true) .build(); try (LDClient client = new LDClient("SDK_KEY", config)) { - assertEquals(Components.NullUpdateProcessor.class, client.updateProcessor.getClass()); + assertEquals(Components.NullDataSource.class, client.dataSource.getClass()); } } @@ -62,31 +66,17 @@ public void offlineClientReturnsDefaultValue() throws IOException { } @Test - public void offlineClientGetsAllFlagsFromFeatureStore() throws IOException { - FeatureStore testFeatureStore = initedFeatureStore(); + public void offlineClientGetsFlagsStateFromDataStore() throws IOException { + DataStore testDataStore = initedDataStore(); LDConfig config = new LDConfig.Builder() .offline(true) - .dataStore(specificFeatureStore(testFeatureStore)) + .dataStore(specificDataStore(testDataStore)) .build(); - 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); - } - } - - @Test - public void offlineClientGetsFlagsStateFromFeatureStore() throws IOException { - FeatureStore testFeatureStore = initedFeatureStore(); - LDConfig config = new LDConfig.Builder() - .offline(true) - .dataStore(specificFeatureStore(testFeatureStore)) - .build(); - testFeatureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(true))); + upsertFlag(testDataStore, flagWithValue("key", LDValue.of(true))); try (LDClient client = new LDClient("SDK_KEY", config)) { FeatureFlagsState state = client.allFlagsState(user); assertTrue(state.isValid()); - assertEquals(ImmutableMap.of("key", jbool(true)), state.toValuesMap()); + assertEquals(ImmutableMap.of("key", LDValue.of(true)), state.toValuesMap()); } } diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java new file mode 100644 index 000000000..d8440d7a2 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java @@ -0,0 +1,484 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.DataModel.FeatureFlag; +import com.launchdarkly.sdk.server.DataStoreTestTypes.DataBuilder; +import com.launchdarkly.sdk.server.TestComponents.DataSourceFactoryThatExposesUpdater; +import com.launchdarkly.sdk.server.TestUtil.FlagChangeEventSink; +import com.launchdarkly.sdk.server.TestUtil.FlagValueChangeEventSink; +import com.launchdarkly.sdk.server.interfaces.ClientContext; +import com.launchdarkly.sdk.server.interfaces.DataSource; +import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; +import com.launchdarkly.sdk.server.interfaces.Event; +import com.launchdarkly.sdk.server.interfaces.EventProcessor; +import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; +import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; +import com.launchdarkly.sdk.server.interfaces.FlagValueChangeEvent; + +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.time.Duration; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.flagWithValue; +import static com.launchdarkly.sdk.server.TestComponents.failedDataSource; +import static com.launchdarkly.sdk.server.TestComponents.initedDataStore; +import static com.launchdarkly.sdk.server.TestComponents.specificDataSource; +import static com.launchdarkly.sdk.server.TestComponents.specificDataStore; +import static com.launchdarkly.sdk.server.TestComponents.specificEventProcessor; +import static com.launchdarkly.sdk.server.TestUtil.upsertFlag; +import static org.easymock.EasyMock.anyObject; +import static org.easymock.EasyMock.capture; +import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.expectLastCall; +import static org.easymock.EasyMock.isA; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +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; +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 DataSource dataSource; + private EventProcessor eventProcessor; + private Future initFuture; + private LDClientInterface client; + + @SuppressWarnings("unchecked") + @Before + public void before() { + dataSource = createStrictMock(DataSource.class); + eventProcessor = createStrictMock(EventProcessor.class); + 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 clientHasDefaultEventProcessorWithDefaultConfig() 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 clientHasDefaultEventProcessorWithSendEvents() throws Exception { + LDConfig config = new LDConfig.Builder() + .dataSource(Components.externalUpdatesOnly()) + .events(Components.sendEvents()) + .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()) + .build(); + try (LDClient client = new LDClient("SDK_KEY", config)) { + assertEquals(Components.NullEventProcessor.class, client.eventProcessor.getClass()); + } + } + + @Test + public void streamingClientHasStreamProcessor() throws Exception { + LDConfig config = new LDConfig.Builder() + .dataSource(Components.streamingDataSource().baseURI(URI.create("http://fake"))) + .startWait(Duration.ZERO) + .build(); + try (LDClient client = new LDClient(SDK_KEY, config)) { + assertEquals(StreamProcessor.class, client.dataSource.getClass()); + } + } + + @Test + public void pollingClientHasPollingProcessor() throws IOException { + LDConfig config = new LDConfig.Builder() + .dataSource(Components.pollingDataSource().baseURI(URI.create("http://fake"))) + .startWait(Duration.ZERO) + .build(); + try (LDClient client = new LDClient(SDK_KEY, config)) { + assertEquals(PollingProcessor.class, client.dataSource.getClass()); + } + } + + @Test + public void sameDiagnosticAccumulatorPassedToFactoriesWhenSupported() throws IOException { + DataSourceFactory mockDataSourceFactory = createStrictMock(DataSourceFactory.class); + + LDConfig config = new LDConfig.Builder() + .dataSource(mockDataSourceFactory) + .events(Components.sendEvents().baseURI(URI.create("fake-host"))) // event processor will try to send a diagnostic event here + .startWait(Duration.ZERO) + .build(); + + Capture capturedDataSourceContext = Capture.newInstance(); + expect(mockDataSourceFactory.createDataSource(capture(capturedDataSourceContext), + isA(DataStoreUpdates.class))).andReturn(failedDataSource()); + + replayAll(); + + try (LDClient client = new LDClient(SDK_KEY, config)) { + verifyAll(); + DiagnosticAccumulator acc = ((DefaultEventProcessor)client.eventProcessor).dispatcher.diagnosticAccumulator; + assertNotNull(acc); + assertSame(acc, ClientContextImpl.getDiagnosticAccumulator(capturedDataSourceContext.getValue())); + } + } + + @Test + public void nullDiagnosticAccumulatorPassedToFactoriesWhenOptedOut() throws IOException { + DataSourceFactory mockDataSourceFactory = createStrictMock(DataSourceFactory.class); + + LDConfig config = new LDConfig.Builder() + .dataSource(mockDataSourceFactory) + .diagnosticOptOut(true) + .startWait(Duration.ZERO) + .build(); + + Capture capturedDataSourceContext = Capture.newInstance(); + expect(mockDataSourceFactory.createDataSource(capture(capturedDataSourceContext), + isA(DataStoreUpdates.class))).andReturn(failedDataSource()); + + replayAll(); + + try (LDClient client = new LDClient(SDK_KEY, config)) { + verifyAll(); + assertNull(((DefaultEventProcessor)client.eventProcessor).dispatcher.diagnosticAccumulator); + assertNull(ClientContextImpl.getDiagnosticAccumulator(capturedDataSourceContext.getValue())); + } + } + + @Test + public void nullDiagnosticAccumulatorPassedToUpdateFactoryWhenEventProcessorDoesNotSupportDiagnostics() throws IOException { + EventProcessor mockEventProcessor = createStrictMock(EventProcessor.class); + mockEventProcessor.close(); + EasyMock.expectLastCall().anyTimes(); + EventProcessorFactory mockEventProcessorFactory = createStrictMock(EventProcessorFactory.class); + DataSourceFactory mockDataSourceFactory = createStrictMock(DataSourceFactory.class); + + LDConfig config = new LDConfig.Builder() + .events(mockEventProcessorFactory) + .dataSource(mockDataSourceFactory) + .startWait(Duration.ZERO) + .build(); + + Capture capturedEventContext = Capture.newInstance(); + Capture capturedDataSourceContext = Capture.newInstance(); + expect(mockEventProcessorFactory.createEventProcessor(capture(capturedEventContext))).andReturn(mockEventProcessor); + expect(mockDataSourceFactory.createDataSource(capture(capturedDataSourceContext), + isA(DataStoreUpdates.class))).andReturn(failedDataSource()); + + replayAll(); + + try (LDClient client = new LDClient(SDK_KEY, config)) { + verifyAll(); + assertNull(ClientContextImpl.getDiagnosticAccumulator(capturedEventContext.getValue())); + assertNull(ClientContextImpl.getDiagnosticAccumulator(capturedDataSourceContext.getValue())); + } + } + + @Test + public void noWaitForDataSourceIfWaitMillisIsZero() throws Exception { + LDConfig.Builder config = new LDConfig.Builder() + .startWait(Duration.ZERO); + + expect(dataSource.start()).andReturn(initFuture); + expect(dataSource.isInitialized()).andReturn(false); + replayAll(); + + client = createMockClient(config); + assertFalse(client.initialized()); + + verifyAll(); + } + + @Test + public void willWaitForDataSourceIfWaitMillisIsNonZero() throws Exception { + LDConfig.Builder config = new LDConfig.Builder() + .startWait(Duration.ofMillis(10)); + + expect(dataSource.start()).andReturn(initFuture); + expect(initFuture.get(10L, TimeUnit.MILLISECONDS)).andReturn(null); + expect(dataSource.isInitialized()).andReturn(false).anyTimes(); + replayAll(); + + client = createMockClient(config); + assertFalse(client.initialized()); + + verifyAll(); + } + + @Test + public void dataSourceCanTimeOut() throws Exception { + LDConfig.Builder config = new LDConfig.Builder() + .startWait(Duration.ofMillis(10)); + + expect(dataSource.start()).andReturn(initFuture); + expect(initFuture.get(10L, TimeUnit.MILLISECONDS)).andThrow(new TimeoutException()); + expect(dataSource.isInitialized()).andReturn(false).anyTimes(); + replayAll(); + + client = createMockClient(config); + assertFalse(client.initialized()); + + verifyAll(); + } + + @Test + public void clientCatchesRuntimeExceptionFromDataSource() throws Exception { + LDConfig.Builder config = new LDConfig.Builder() + .startWait(Duration.ofMillis(10)); + + expect(dataSource.start()).andReturn(initFuture); + expect(initFuture.get(10L, TimeUnit.MILLISECONDS)).andThrow(new RuntimeException()); + expect(dataSource.isInitialized()).andReturn(false).anyTimes(); + replayAll(); + + client = createMockClient(config); + assertFalse(client.initialized()); + + verifyAll(); + } + + @Test + public void isFlagKnownReturnsTrueForExistingFlag() throws Exception { + DataStore testDataStore = initedDataStore(); + LDConfig.Builder config = new LDConfig.Builder() + .startWait(Duration.ZERO) + .dataStore(specificDataStore(testDataStore)); + expect(dataSource.start()).andReturn(initFuture); + expect(dataSource.isInitialized()).andReturn(true).times(1); + replayAll(); + + client = createMockClient(config); + + upsertFlag(testDataStore, flagWithValue("key", LDValue.of(1))); + assertTrue(client.isFlagKnown("key")); + verifyAll(); + } + + @Test + public void isFlagKnownReturnsFalseForUnknownFlag() throws Exception { + DataStore testDataStore = initedDataStore(); + LDConfig.Builder config = new LDConfig.Builder() + .startWait(Duration.ZERO) + .dataStore(specificDataStore(testDataStore)); + expect(dataSource.start()).andReturn(initFuture); + expect(dataSource.isInitialized()).andReturn(true).times(1); + replayAll(); + + client = createMockClient(config); + + assertFalse(client.isFlagKnown("key")); + verifyAll(); + } + + @Test + public void isFlagKnownReturnsFalseIfStoreAndClientAreNotInitialized() throws Exception { + DataStore testDataStore = new InMemoryDataStore(); + LDConfig.Builder config = new LDConfig.Builder() + .startWait(Duration.ZERO) + .dataStore(specificDataStore(testDataStore)); + expect(dataSource.start()).andReturn(initFuture); + expect(dataSource.isInitialized()).andReturn(false).times(1); + replayAll(); + + client = createMockClient(config); + + upsertFlag(testDataStore, flagWithValue("key", LDValue.of(1))); + assertFalse(client.isFlagKnown("key")); + verifyAll(); + } + + @Test + public void isFlagKnownUsesStoreIfStoreIsInitializedButClientIsNot() throws Exception { + DataStore testDataStore = initedDataStore(); + LDConfig.Builder config = new LDConfig.Builder() + .startWait(Duration.ZERO) + .dataStore(specificDataStore(testDataStore)); + expect(dataSource.start()).andReturn(initFuture); + expect(dataSource.isInitialized()).andReturn(false).times(1); + replayAll(); + + client = createMockClient(config); + + upsertFlag(testDataStore, flagWithValue("key", LDValue.of(1))); + assertTrue(client.isFlagKnown("key")); + verifyAll(); + } + + @Test + public void evaluationUsesStoreIfStoreIsInitializedButClientIsNot() throws Exception { + DataStore testDataStore = initedDataStore(); + LDConfig.Builder config = new LDConfig.Builder() + .dataStore(specificDataStore(testDataStore)) + .startWait(Duration.ZERO); + expect(dataSource.start()).andReturn(initFuture); + expect(dataSource.isInitialized()).andReturn(false); + expectEventsSent(1); + replayAll(); + + client = createMockClient(config); + + upsertFlag(testDataStore, flagWithValue("key", LDValue.of(1))); + assertEquals(new Integer(1), client.intVariation("key", new LDUser("user"), 0)); + + verifyAll(); + } + + @Test + public void clientSendsFlagChangeEvents() throws Exception { + // The logic for sending change events is tested in detail in DataStoreUpdatesImplTest, but here we'll + // verify that the client is actually telling DataStoreUpdatesImpl about updates, and managing the + // listener list. + DataStore testDataStore = initedDataStore(); + DataBuilder initialData = new DataBuilder().addAny(DataModel.FEATURES, + flagBuilder("flagkey").version(1).build()); + DataSourceFactoryThatExposesUpdater updatableSource = new DataSourceFactoryThatExposesUpdater(initialData.build()); + LDConfig config = new LDConfig.Builder() + .dataStore(specificDataStore(testDataStore)) + .dataSource(updatableSource) + .events(Components.noEvents()) + .build(); + + client = new LDClient(SDK_KEY, config); + + FlagChangeEventSink eventSink1 = new FlagChangeEventSink(); + FlagChangeEventSink eventSink2 = new FlagChangeEventSink(); + client.registerFlagChangeListener(eventSink1); + client.registerFlagChangeListener(eventSink2); + + eventSink1.expectNoEvents(); + eventSink2.expectNoEvents(); + + updatableSource.updateFlag(flagBuilder("flagkey").version(2).build()); + + FlagChangeEvent event1 = eventSink1.awaitEvent(); + FlagChangeEvent event2 = eventSink2.awaitEvent(); + assertThat(event1.getKey(), equalTo("flagkey")); + assertThat(event2.getKey(), equalTo("flagkey")); + eventSink1.expectNoEvents(); + eventSink2.expectNoEvents(); + + client.unregisterFlagChangeListener(eventSink1); + + updatableSource.updateFlag(flagBuilder("flagkey").version(3).build()); + + FlagChangeEvent event3 = eventSink2.awaitEvent(); + assertThat(event3.getKey(), equalTo("flagkey")); + eventSink1.expectNoEvents(); + eventSink2.expectNoEvents(); + } + + @Test + public void clientSendsFlagValueChangeEvents() throws Exception { + String flagKey = "important-flag"; + LDUser user = new LDUser("important-user"); + LDUser otherUser = new LDUser("unimportant-user"); + DataStore testDataStore = initedDataStore(); + + FeatureFlag alwaysFalseFlag = flagBuilder(flagKey).version(1).on(true).variations(false, true) + .fallthroughVariation(0).build(); + DataBuilder initialData = new DataBuilder().addAny(DataModel.FEATURES, alwaysFalseFlag); + + DataSourceFactoryThatExposesUpdater updatableSource = new DataSourceFactoryThatExposesUpdater(initialData.build()); + LDConfig config = new LDConfig.Builder() + .dataStore(specificDataStore(testDataStore)) + .dataSource(updatableSource) + .events(Components.noEvents()) + .build(); + + client = new LDClient(SDK_KEY, config); + FlagValueChangeEventSink eventSink1 = new FlagValueChangeEventSink(); + FlagValueChangeEventSink eventSink2 = new FlagValueChangeEventSink(); + client.registerFlagChangeListener(Components.flagValueMonitoringListener(client, flagKey, user, eventSink1)); + client.registerFlagChangeListener(Components.flagValueMonitoringListener(client, flagKey, otherUser, eventSink2)); + + eventSink1.expectNoEvents(); + eventSink2.expectNoEvents(); + + FeatureFlag flagIsTrueForMyUserOnly = flagBuilder(flagKey).version(2).on(true).variations(false, true) + .targets(ModelBuilders.target(1, user.getKey())).fallthroughVariation(0).build(); + updatableSource.updateFlag(flagIsTrueForMyUserOnly); + + // eventSink1 receives a value change event; eventSink2 doesn't because the flag's value hasn't changed for otherUser + FlagValueChangeEvent event1 = eventSink1.awaitEvent(); + assertThat(event1.getKey(), equalTo(flagKey)); + assertThat(event1.getOldValue(), equalTo(LDValue.of(false))); + assertThat(event1.getNewValue(), equalTo(LDValue.of(true))); + eventSink1.expectNoEvents(); + + eventSink2.expectNoEvents(); + } + + private void expectEventsSent(int count) { + eventProcessor.sendEvent(anyObject(Event.class)); + if (count > 0) { + expectLastCall().times(count); + } else { + expectLastCall().andThrow(new AssertionFailedError("should not have queued an event")).anyTimes(); + } + } + + private LDClientInterface createMockClient(LDConfig.Builder config) { + config.dataSource(specificDataSource(dataSource)); + config.events(specificEventProcessor(eventProcessor)); + return new LDClient(SDK_KEY, config.build()); + } +} \ No newline at end of file diff --git a/src/test/java/com/launchdarkly/sdk/server/LDConfigTest.java b/src/test/java/com/launchdarkly/sdk/server/LDConfigTest.java new file mode 100644 index 000000000..b0649573c --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/LDConfigTest.java @@ -0,0 +1,67 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +@SuppressWarnings("javadoc") +public class LDConfigTest { + @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 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(); + HttpConfiguration hc = config.httpConfig; + HttpConfiguration defaults = Components.httpConfiguration().createHttpConfiguration(); + assertEquals(defaults.getConnectTimeout(), hc.getConnectTimeout()); + assertNull(hc.getProxy()); + assertNull(hc.getProxyAuthentication()); + assertEquals(defaults.getSocketTimeout(), hc.getSocketTimeout()); + assertNull(hc.getSslSocketFactory()); + assertNull(hc.getTrustManager()); + assertNull(hc.getWrapperIdentifier()); + } +} \ No newline at end of file diff --git a/src/test/java/com/launchdarkly/sdk/server/ModelBuilders.java b/src/test/java/com/launchdarkly/sdk/server/ModelBuilders.java new file mode 100644 index 000000000..33f6ed218 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/ModelBuilders.java @@ -0,0 +1,347 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.server.DataModel.FeatureFlag; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@SuppressWarnings("javadoc") +public abstract class ModelBuilders { + public static FlagBuilder flagBuilder(String key) { + return new FlagBuilder(key); + } + + public static FlagBuilder flagBuilder(DataModel.FeatureFlag fromFlag) { + return new FlagBuilder(fromFlag); + } + + public static DataModel.FeatureFlag booleanFlagWithClauses(String key, DataModel.Clause... clauses) { + DataModel.Rule rule = ruleBuilder().variation(1).clauses(clauses).build(); + return flagBuilder(key) + .on(true) + .rules(rule) + .fallthrough(fallthroughVariation(0)) + .offVariation(0) + .variations(LDValue.of(false), LDValue.of(true)) + .build(); + } + + public static DataModel.FeatureFlag flagWithValue(String key, LDValue value) { + return flagBuilder(key) + .on(false) + .offVariation(0) + .variations(value) + .build(); + } + + public static DataModel.VariationOrRollout fallthroughVariation(int variation) { + return new DataModel.VariationOrRollout(variation, null); + } + + public static RuleBuilder ruleBuilder() { + return new RuleBuilder(); + } + + public static DataModel.Clause clause(UserAttribute attribute, DataModel.Operator op, boolean negate, LDValue... values) { + return new DataModel.Clause(attribute, op, Arrays.asList(values), negate); + } + + public static DataModel.Clause clause(UserAttribute attribute, DataModel.Operator op, LDValue... values) { + return clause(attribute, op, false, values); + } + + public static DataModel.Clause clauseMatchingUser(LDUser user) { + return clause(UserAttribute.KEY, DataModel.Operator.in, user.getAttribute(UserAttribute.KEY)); + } + + public static DataModel.Clause clauseNotMatchingUser(LDUser user) { + return clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of("not-" + user.getKey())); + } + + public static DataModel.Target target(int variation, String... userKeys) { + return new DataModel.Target(ImmutableSet.copyOf(userKeys), variation); + } + + public static DataModel.Prerequisite prerequisite(String key, int variation) { + return new DataModel.Prerequisite(key, variation); + } + + public static DataModel.Rollout emptyRollout() { + return new DataModel.Rollout(ImmutableList.of(), null); + } + + public static SegmentBuilder segmentBuilder(String key) { + return new SegmentBuilder(key); + } + + public static SegmentRuleBuilder segmentRuleBuilder() { + return new SegmentRuleBuilder(); + } + + public static class FlagBuilder { + private String key; + private int version; + private boolean on; + private List prerequisites = new ArrayList<>(); + private String salt; + private List targets = new ArrayList<>(); + private List rules = new ArrayList<>(); + private DataModel.VariationOrRollout fallthrough; + private Integer offVariation; + private List variations = new ArrayList<>(); + private boolean clientSide; + private boolean trackEvents; + private boolean trackEventsFallthrough; + private Long debugEventsUntilDate; + private boolean deleted; + + private FlagBuilder(String key) { + this.key = key; + } + + private FlagBuilder(DataModel.FeatureFlag f) { + if (f != null) { + this.key = f.getKey(); + this.version = f.getVersion(); + this.on = f.isOn(); + this.prerequisites = f.getPrerequisites(); + this.salt = f.getSalt(); + this.targets = f.getTargets(); + this.rules = f.getRules(); + this.fallthrough = f.getFallthrough(); + this.offVariation = f.getOffVariation(); + this.variations = f.getVariations(); + this.clientSide = f.isClientSide(); + this.trackEvents = f.isTrackEvents(); + this.trackEventsFallthrough = f.isTrackEventsFallthrough(); + this.debugEventsUntilDate = f.getDebugEventsUntilDate(); + this.deleted = f.isDeleted(); + } + } + + FlagBuilder version(int version) { + this.version = version; + return this; + } + + FlagBuilder on(boolean on) { + this.on = on; + return this; + } + + FlagBuilder prerequisites(DataModel.Prerequisite... prerequisites) { + this.prerequisites = Arrays.asList(prerequisites); + return this; + } + + FlagBuilder salt(String salt) { + this.salt = salt; + return this; + } + + FlagBuilder targets(DataModel.Target... targets) { + this.targets = Arrays.asList(targets); + return this; + } + + FlagBuilder rules(DataModel.Rule... rules) { + this.rules = Arrays.asList(rules); + return this; + } + + FlagBuilder fallthroughVariation(int fallthroughVariation) { + this.fallthrough = new DataModel.VariationOrRollout(fallthroughVariation, null); + return this; + } + + FlagBuilder fallthrough(DataModel.VariationOrRollout fallthrough) { + this.fallthrough = fallthrough; + return this; + } + + FlagBuilder offVariation(Integer offVariation) { + this.offVariation = offVariation; + return this; + } + + FlagBuilder variations(LDValue... variations) { + this.variations = Arrays.asList(variations); + return this; + } + + FlagBuilder variations(boolean... variations) { + List values = new ArrayList<>(); + for (boolean v: variations) { + values.add(LDValue.of(v)); + } + this.variations = values; + return this; + } + + FlagBuilder clientSide(boolean clientSide) { + this.clientSide = clientSide; + return this; + } + + FlagBuilder trackEvents(boolean trackEvents) { + this.trackEvents = trackEvents; + return this; + } + + FlagBuilder trackEventsFallthrough(boolean trackEventsFallthrough) { + this.trackEventsFallthrough = trackEventsFallthrough; + return this; + } + + FlagBuilder debugEventsUntilDate(Long debugEventsUntilDate) { + this.debugEventsUntilDate = debugEventsUntilDate; + return this; + } + + FlagBuilder deleted(boolean deleted) { + this.deleted = deleted; + return this; + } + + DataModel.FeatureFlag build() { + FeatureFlag flag = new DataModel.FeatureFlag(key, version, on, prerequisites, salt, targets, rules, fallthrough, offVariation, variations, + clientSide, trackEvents, trackEventsFallthrough, debugEventsUntilDate, deleted); + flag.afterDeserialized(); + return flag; + } + } + + public static class RuleBuilder { + private String id; + private List clauses = new ArrayList<>(); + private Integer variation; + private DataModel.Rollout rollout; + private boolean trackEvents; + + private RuleBuilder() { + } + + public DataModel.Rule build() { + return new DataModel.Rule(id, clauses, variation, rollout, trackEvents); + } + + public RuleBuilder id(String id) { + this.id = id; + return this; + } + + public RuleBuilder clauses(DataModel.Clause... clauses) { + this.clauses = ImmutableList.copyOf(clauses); + return this; + } + + public RuleBuilder variation(Integer variation) { + this.variation = variation; + return this; + } + + public RuleBuilder rollout(DataModel.Rollout rollout) { + this.rollout = rollout; + return this; + } + + public RuleBuilder trackEvents(boolean trackEvents) { + this.trackEvents = trackEvents; + return this; + } + } + + public static class SegmentBuilder { + private String key; + private Set included = new HashSet<>(); + private Set excluded = new HashSet<>(); + private String salt = ""; + private List rules = new ArrayList<>(); + private int version = 0; + private boolean deleted; + + private SegmentBuilder(String key) { + this.key = key; + } + + private SegmentBuilder(DataModel.Segment from) { + this.key = from.getKey(); + this.included = ImmutableSet.copyOf(from.getIncluded()); + this.excluded = ImmutableSet.copyOf(from.getExcluded()); + this.salt = from.getSalt(); + this.rules = ImmutableList.copyOf(from.getRules()); + this.version = from.getVersion(); + this.deleted = from.isDeleted(); + } + + public DataModel.Segment build() { + return new DataModel.Segment(key, included, excluded, salt, rules, version, deleted); + } + + public SegmentBuilder included(String... included) { + this.included = ImmutableSet.copyOf(included); + return this; + } + + public SegmentBuilder excluded(String... excluded) { + this.excluded = ImmutableSet.copyOf(excluded); + return this; + } + + public SegmentBuilder salt(String salt) { + this.salt = salt; + return this; + } + + public SegmentBuilder rules(DataModel.SegmentRule... rules) { + this.rules = Arrays.asList(rules); + return this; + } + + public SegmentBuilder version(int version) { + this.version = version; + return this; + } + + public SegmentBuilder deleted(boolean deleted) { + this.deleted = deleted; + return this; + } + } + + public static class SegmentRuleBuilder { + private List clauses = new ArrayList<>(); + private Integer weight; + private UserAttribute bucketBy; + + private SegmentRuleBuilder() { + } + + public DataModel.SegmentRule build() { + return new DataModel.SegmentRule(clauses, weight, bucketBy); + } + + public SegmentRuleBuilder clauses(DataModel.Clause... clauses) { + this.clauses = ImmutableList.copyOf(clauses); + return this; + } + + public SegmentRuleBuilder weight(Integer weight) { + this.weight = weight; + return this; + } + + public SegmentRuleBuilder bucketBy(UserAttribute bucketBy) { + this.bucketBy = bucketBy; + return this; + } + } +} diff --git a/src/test/java/com/launchdarkly/client/PollingProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java similarity index 59% rename from src/test/java/com/launchdarkly/client/PollingProcessorTest.java rename to src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java index 8a9eff959..b43a2e2c5 100644 --- a/src/test/java/com/launchdarkly/client/PollingProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java @@ -1,16 +1,29 @@ -package com.launchdarkly.client; - -import com.launchdarkly.client.integrations.PollingDataSourceBuilder; +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.DataModel; +import com.launchdarkly.sdk.server.DefaultFeatureRequestor; +import com.launchdarkly.sdk.server.FeatureRequestor; +import com.launchdarkly.sdk.server.HttpErrorException; +import com.launchdarkly.sdk.server.InMemoryDataStore; +import com.launchdarkly.sdk.server.LDConfig; +import com.launchdarkly.sdk.server.PollingProcessor; +import com.launchdarkly.sdk.server.integrations.PollingDataSourceBuilder; +import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; +import com.launchdarkly.sdk.server.interfaces.DataStore; import org.junit.Test; import java.io.IOException; import java.net.URI; +import java.time.Duration; import java.util.HashMap; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import static com.launchdarkly.sdk.server.TestComponents.clientContext; +import static com.launchdarkly.sdk.server.TestComponents.dataStoreUpdates; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.junit.Assert.assertFalse; @@ -20,71 +33,40 @@ @SuppressWarnings("javadoc") public class PollingProcessorTest { private static final String SDK_KEY = "sdk-key"; - private static final long LENGTHY_INTERVAL = 60000; + private static final Duration LENGTHY_INTERVAL = Duration.ofSeconds(60); @Test public void builderHasDefaultConfiguration() throws Exception { - UpdateProcessorFactory f = Components.pollingDataSource(); - try (PollingProcessor pp = (PollingProcessor)f.createUpdateProcessor(SDK_KEY, LDConfig.DEFAULT, null)) { + DataSourceFactory f = Components.pollingDataSource(); + try (PollingProcessor pp = (PollingProcessor)f.createDataSource(clientContext(SDK_KEY, LDConfig.DEFAULT), null)) { assertThat(((DefaultFeatureRequestor)pp.requestor).baseUri, equalTo(LDConfig.DEFAULT_BASE_URI)); - assertThat(pp.pollIntervalMillis, equalTo(PollingDataSourceBuilder.DEFAULT_POLL_INTERVAL_MILLIS)); + assertThat(pp.pollInterval, equalTo(PollingDataSourceBuilder.DEFAULT_POLL_INTERVAL)); } } @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() + DataSourceFactory f = Components.pollingDataSource() .baseURI(uri) - .pollingIntervalMillis(LENGTHY_INTERVAL) - .stream(false) - .build(); - UpdateProcessorFactory f = Components.defaultUpdateProcessor(); - try (PollingProcessor pp = (PollingProcessor)f.createUpdateProcessor(SDK_KEY, config, null)) { + .pollInterval(LENGTHY_INTERVAL); + try (PollingProcessor pp = (PollingProcessor)f.createDataSource(clientContext(SDK_KEY, LDConfig.DEFAULT), 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)); - } + assertThat(pp.pollInterval, equalTo(LENGTHY_INTERVAL)); } } @Test public void testConnectionOk() throws Exception { MockFeatureRequestor requestor = new MockFeatureRequestor(); - requestor.allData = new FeatureRequestor.AllData(new HashMap(), new HashMap()); - FeatureStore store = new InMemoryFeatureStore(); + requestor.allData = new FeatureRequestor.AllData(new HashMap<>(), new HashMap<>()); + DataStore store = new InMemoryDataStore(); - try (PollingProcessor pollingProcessor = new PollingProcessor(requestor, store, LENGTHY_INTERVAL)) { + try (PollingProcessor pollingProcessor = new PollingProcessor(requestor, dataStoreUpdates(store), LENGTHY_INTERVAL)) { Future initFuture = pollingProcessor.start(); initFuture.get(1000, TimeUnit.MILLISECONDS); - assertTrue(pollingProcessor.initialized()); - assertTrue(store.initialized()); + assertTrue(pollingProcessor.isInitialized()); + assertTrue(store.isInitialized()); } } @@ -92,9 +74,9 @@ public void testConnectionOk() throws Exception { public void testConnectionProblem() throws Exception { 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(); + DataStore store = new InMemoryDataStore(); - try (PollingProcessor pollingProcessor = new PollingProcessor(requestor, store, LENGTHY_INTERVAL)) { + try (PollingProcessor pollingProcessor = new PollingProcessor(requestor, dataStoreUpdates(store), LENGTHY_INTERVAL)) { Future initFuture = pollingProcessor.start(); try { initFuture.get(200L, TimeUnit.MILLISECONDS); @@ -102,8 +84,8 @@ public void testConnectionProblem() throws Exception { } catch (TimeoutException ignored) { } assertFalse(initFuture.isDone()); - assertFalse(pollingProcessor.initialized()); - assertFalse(store.initialized()); + assertFalse(pollingProcessor.isInitialized()); + assertFalse(store.isInitialized()); } } @@ -140,7 +122,9 @@ 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(requestor, new InMemoryFeatureStore(), LENGTHY_INTERVAL)) { + DataStore store = new InMemoryDataStore(); + + try (PollingProcessor pollingProcessor = new PollingProcessor(requestor, dataStoreUpdates(store), LENGTHY_INTERVAL)) { long startTime = System.currentTimeMillis(); Future initFuture = pollingProcessor.start(); try { @@ -150,14 +134,16 @@ private void testUnrecoverableHttpError(int status) throws Exception { } assertTrue((System.currentTimeMillis() - startTime) < 9000); assertTrue(initFuture.isDone()); - assertFalse(pollingProcessor.initialized()); + assertFalse(pollingProcessor.isInitialized()); } } private void testRecoverableHttpError(int status) throws Exception { MockFeatureRequestor requestor = new MockFeatureRequestor(); requestor.httpException = new HttpErrorException(status); - try (PollingProcessor pollingProcessor = new PollingProcessor(requestor, new InMemoryFeatureStore(), LENGTHY_INTERVAL)) { + DataStore store = new InMemoryDataStore(); + + try (PollingProcessor pollingProcessor = new PollingProcessor(requestor, dataStoreUpdates(store), LENGTHY_INTERVAL)) { Future initFuture = pollingProcessor.start(); try { initFuture.get(200, TimeUnit.MILLISECONDS); @@ -165,7 +151,7 @@ private void testRecoverableHttpError(int status) throws Exception { } catch (TimeoutException ignored) { } assertFalse(initFuture.isDone()); - assertFalse(pollingProcessor.initialized()); + assertFalse(pollingProcessor.isInitialized()); } } @@ -176,11 +162,11 @@ private static class MockFeatureRequestor implements FeatureRequestor { public void close() throws IOException {} - public FeatureFlag getFlag(String featureKey) throws IOException, HttpErrorException { + public DataModel.FeatureFlag getFlag(String featureKey) throws IOException, HttpErrorException { return null; } - public Segment getSegment(String segmentKey) throws IOException, HttpErrorException { + public DataModel.Segment getSegment(String segmentKey) throws IOException, HttpErrorException { return null; } diff --git a/src/test/java/com/launchdarkly/client/SemanticVersionTest.java b/src/test/java/com/launchdarkly/sdk/server/SemanticVersionTest.java similarity index 98% rename from src/test/java/com/launchdarkly/client/SemanticVersionTest.java rename to src/test/java/com/launchdarkly/sdk/server/SemanticVersionTest.java index cfb08752e..41ceb972b 100644 --- a/src/test/java/com/launchdarkly/client/SemanticVersionTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/SemanticVersionTest.java @@ -1,12 +1,14 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; +import com.launchdarkly.sdk.server.SemanticVersion; + import org.junit.Test; +@SuppressWarnings("javadoc") public class SemanticVersionTest { - @Test public void canParseSimpleCompleteVersion() throws Exception { SemanticVersion sv = SemanticVersion.parse("2.3.4"); diff --git a/src/test/java/com/launchdarkly/client/SimpleLRUCacheTest.java b/src/test/java/com/launchdarkly/sdk/server/SimpleLRUCacheTest.java similarity index 92% rename from src/test/java/com/launchdarkly/client/SimpleLRUCacheTest.java rename to src/test/java/com/launchdarkly/sdk/server/SimpleLRUCacheTest.java index 996d51c29..69cf609e6 100644 --- a/src/test/java/com/launchdarkly/client/SimpleLRUCacheTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/SimpleLRUCacheTest.java @@ -1,10 +1,13 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.SimpleLRUCache; import org.junit.Test; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; +@SuppressWarnings("javadoc") public class SimpleLRUCacheTest { @Test public void getReturnsNullForNeverSeenValue() { diff --git a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java similarity index 63% rename from src/test/java/com/launchdarkly/client/StreamProcessorTest.java rename to src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java index 8858e6a3f..ce466454f 100644 --- a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java @@ -1,12 +1,16 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; -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; import com.launchdarkly.eventsource.MessageEvent; import com.launchdarkly.eventsource.UnsuccessfulResponseException; +import com.launchdarkly.sdk.server.TestComponents.MockEventSourceCreator; +import com.launchdarkly.sdk.server.integrations.StreamingDataSourceBuilder; +import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import org.easymock.EasyMockSupport; import org.junit.Before; @@ -14,8 +18,10 @@ import java.io.IOException; import java.net.URI; +import java.time.Duration; import java.util.Collections; import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.Future; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; @@ -23,11 +29,19 @@ 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.featureStoreThatThrowsException; -import static com.launchdarkly.client.VersionedDataKind.FEATURES; -import static com.launchdarkly.client.VersionedDataKind.SEGMENTS; +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; +import static com.launchdarkly.sdk.server.JsonHelpers.gsonInstance; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.segmentBuilder; +import static com.launchdarkly.sdk.server.TestComponents.clientContext; +import static com.launchdarkly.sdk.server.TestComponents.dataStoreThatThrowsException; +import static com.launchdarkly.sdk.server.TestComponents.dataStoreUpdates; +import static com.launchdarkly.sdk.server.TestHttpUtil.eventStreamResponse; +import static com.launchdarkly.sdk.server.TestHttpUtil.makeStartedServer; +import static com.launchdarkly.sdk.server.TestUtil.upsertFlag; +import static com.launchdarkly.sdk.server.TestUtil.upsertSegment; +import static com.launchdarkly.sdk.server.integrations.StreamingDataSourceBuilder.DEFAULT_INITIAL_RECONNECT_DELAY; import static org.easymock.EasyMock.expect; import static org.easymock.EasyMock.expectLastCall; import static org.hamcrest.MatcherAssert.assertThat; @@ -41,7 +55,6 @@ import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; -import okhttp3.Headers; import okhttp3.HttpUrl; import okhttp3.mockwebserver.MockWebServer; @@ -52,34 +65,33 @@ public class StreamProcessorTest extends EasyMockSupport { private static final URI STREAM_URI = URI.create("http://stream.test.com"); private static final String FEATURE1_KEY = "feature1"; private static final int FEATURE1_VERSION = 11; - private static final FeatureFlag FEATURE = new FeatureFlagBuilder(FEATURE1_KEY).version(FEATURE1_VERSION).build(); + private static final DataModel.FeatureFlag FEATURE = flagBuilder(FEATURE1_KEY).version(FEATURE1_VERSION).build(); 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 DataModel.Segment SEGMENT = segmentBuilder(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 InMemoryDataStore dataStore; private FeatureRequestor mockRequestor; private EventSource mockEventSource; - private EventHandler eventHandler; - private URI actualStreamUri; - private ConnectionErrorHandler errorHandler; - private Headers headers; + private MockEventSourceCreator mockEventSourceCreator; @Before public void setup() { - featureStore = new InMemoryFeatureStore(); + dataStore = new InMemoryDataStore(); mockRequestor = createStrictMock(FeatureRequestor.class); - mockEventSource = createStrictMock(EventSource.class); + mockEventSource = createMock(EventSource.class); + mockEventSourceCreator = new MockEventSourceCreator(mockEventSource); } @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)); + DataSourceFactory f = Components.streamingDataSource(); + try (StreamProcessor sp = (StreamProcessor)f.createDataSource(clientContext(SDK_KEY, LDConfig.DEFAULT), + dataStoreUpdates(dataStore))) { + assertThat(sp.initialReconnectDelay, equalTo(StreamingDataSourceBuilder.DEFAULT_INITIAL_RECONNECT_DELAY)); assertThat(sp.streamUri, equalTo(LDConfig.DEFAULT_STREAM_URI)); assertThat(((DefaultFeatureRequestor)sp.requestor).baseUri, equalTo(LDConfig.DEFAULT_BASE_URI)); } @@ -89,72 +101,44 @@ public void builderHasDefaultConfiguration() throws Exception { public void builderCanSpecifyConfiguration() throws Exception { URI streamUri = URI.create("http://fake"); URI pollUri = URI.create("http://also-fake"); - UpdateProcessorFactory f = Components.streamingDataSource() + DataSourceFactory f = Components.streamingDataSource() .baseURI(streamUri) - .initialReconnectDelayMillis(5555) + .initialReconnectDelay(Duration.ofMillis(5555)) .pollingBaseURI(pollUri); - try (StreamProcessor sp = (StreamProcessor)f.createUpdateProcessor(SDK_KEY, LDConfig.DEFAULT, null)) { - assertThat(sp.initialReconnectDelayMillis, equalTo(5555L)); + try (StreamProcessor sp = (StreamProcessor)f.createDataSource(clientContext(SDK_KEY, LDConfig.DEFAULT), + dataStoreUpdates(dataStore))) { + assertThat(sp.initialReconnectDelay, equalTo(Duration.ofMillis(5555))); 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() { createStreamProcessor(STREAM_URI).start(); - assertEquals(URI.create(STREAM_URI.toString() + "/all"), actualStreamUri); + assertEquals(URI.create(STREAM_URI.toString() + "/all"), + mockEventSourceCreator.getNextReceivedParams().streamUri); } @Test public void headersHaveAuthorization() { createStreamProcessor(STREAM_URI).start(); - assertEquals(SDK_KEY, headers.get("Authorization")); + assertEquals(SDK_KEY, + mockEventSourceCreator.getNextReceivedParams().headers.get("Authorization")); } @Test public void headersHaveUserAgent() { createStreamProcessor(STREAM_URI).start(); - assertEquals("JavaClient/" + LDClient.CLIENT_VERSION, headers.get("User-Agent")); + assertEquals("JavaClient/" + LDClient.CLIENT_VERSION, + mockEventSourceCreator.getNextReceivedParams().headers.get("User-Agent")); } @Test public void headersHaveAccept() { createStreamProcessor(STREAM_URI).start(); - assertEquals("text/event-stream", headers.get("Accept")); + assertEquals("text/event-stream", + mockEventSourceCreator.getNextReceivedParams().headers.get("Accept")); } @Test @@ -163,7 +147,8 @@ public void headersHaveWrapperWhenSet() { .http(Components.httpConfiguration().wrapper("Scala", "0.1.0")) .build(); createStreamProcessor(config, STREAM_URI).start(); - assertEquals("Scala/0.1.0", headers.get("X-LaunchDarkly-Wrapper")); + assertEquals("Scala/0.1.0", + mockEventSourceCreator.getNextReceivedParams().headers.get("X-LaunchDarkly-Wrapper")); } @Test @@ -171,10 +156,12 @@ public void putCausesFeatureToBeStored() throws Exception { expectNoStreamRestart(); createStreamProcessor(STREAM_URI).start(); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + MessageEvent event = new MessageEvent("{\"data\":{\"flags\":{\"" + FEATURE1_KEY + "\":" + featureJson(FEATURE1_KEY, FEATURE1_VERSION) + "}," + "\"segments\":{}}}"); - eventHandler.onMessage("put", event); + handler.onMessage("put", event); assertFeatureInStore(FEATURE); } @@ -184,9 +171,11 @@ public void putCausesSegmentToBeStored() throws Exception { expectNoStreamRestart(); createStreamProcessor(STREAM_URI).start(); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + MessageEvent event = new MessageEvent("{\"data\":{\"flags\":{},\"segments\":{\"" + SEGMENT1_KEY + "\":" + segmentJson(SEGMENT1_KEY, SEGMENT1_VERSION) + "}}}"); - eventHandler.onMessage("put", event); + handler.onMessage("put", event); assertSegmentInStore(SEGMENT); } @@ -194,29 +183,31 @@ public void putCausesSegmentToBeStored() throws Exception { @Test public void storeNotInitializedByDefault() throws Exception { createStreamProcessor(STREAM_URI).start(); - assertFalse(featureStore.initialized()); + assertFalse(dataStore.isInitialized()); } @Test public void putCausesStoreToBeInitialized() throws Exception { createStreamProcessor(STREAM_URI).start(); - eventHandler.onMessage("put", emptyPutEvent()); - assertTrue(featureStore.initialized()); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("put", emptyPutEvent()); + assertTrue(dataStore.isInitialized()); } @Test public void processorNotInitializedByDefault() throws Exception { StreamProcessor sp = createStreamProcessor(STREAM_URI); sp.start(); - assertFalse(sp.initialized()); + assertFalse(sp.isInitialized()); } @Test public void putCausesProcessorToBeInitialized() throws Exception { StreamProcessor sp = createStreamProcessor(STREAM_URI); sp.start(); - eventHandler.onMessage("put", emptyPutEvent()); - assertTrue(sp.initialized()); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("put", emptyPutEvent()); + assertTrue(sp.isInitialized()); } @Test @@ -230,7 +221,8 @@ public void futureIsNotSetByDefault() throws Exception { public void putCausesFutureToBeSet() throws Exception { StreamProcessor sp = createStreamProcessor(STREAM_URI); Future future = sp.start(); - eventHandler.onMessage("put", emptyPutEvent()); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("put", emptyPutEvent()); assertTrue(future.isDone()); } @@ -239,12 +231,13 @@ public void patchUpdatesFeature() throws Exception { expectNoStreamRestart(); createStreamProcessor(STREAM_URI).start(); - eventHandler.onMessage("put", emptyPutEvent()); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("put", emptyPutEvent()); String path = "/flags/" + FEATURE1_KEY; MessageEvent event = new MessageEvent("{\"path\":\"" + path + "\",\"data\":" + featureJson(FEATURE1_KEY, FEATURE1_VERSION) + "}"); - eventHandler.onMessage("patch", event); + handler.onMessage("patch", event); assertFeatureInStore(FEATURE); } @@ -254,12 +247,13 @@ public void patchUpdatesSegment() throws Exception { expectNoStreamRestart(); createStreamProcessor(STREAM_URI).start(); - eventHandler.onMessage("put", emptyPutEvent()); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("put", emptyPutEvent()); String path = "/segments/" + SEGMENT1_KEY; MessageEvent event = new MessageEvent("{\"path\":\"" + path + "\",\"data\":" + segmentJson(SEGMENT1_KEY, SEGMENT1_VERSION) + "}"); - eventHandler.onMessage("patch", event); + handler.onMessage("patch", event); assertSegmentInStore(SEGMENT); } @@ -269,15 +263,16 @@ public void deleteDeletesFeature() throws Exception { expectNoStreamRestart(); createStreamProcessor(STREAM_URI).start(); - eventHandler.onMessage("put", emptyPutEvent()); - featureStore.upsert(FEATURES, FEATURE); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("put", emptyPutEvent()); + upsertFlag(dataStore, FEATURE); String path = "/flags/" + FEATURE1_KEY; MessageEvent event = new MessageEvent("{\"path\":\"" + path + "\",\"version\":" + (FEATURE1_VERSION + 1) + "}"); - eventHandler.onMessage("delete", event); + handler.onMessage("delete", event); - assertNull(featureStore.get(FEATURES, FEATURE1_KEY)); + assertEquals(ItemDescriptor.deletedItem(FEATURE1_VERSION + 1), dataStore.get(FEATURES, FEATURE1_KEY)); } @Test @@ -285,15 +280,16 @@ public void deleteDeletesSegment() throws Exception { expectNoStreamRestart(); createStreamProcessor(STREAM_URI).start(); - eventHandler.onMessage("put", emptyPutEvent()); - featureStore.upsert(SEGMENTS, SEGMENT); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("put", emptyPutEvent()); + upsertSegment(dataStore, SEGMENT); String path = "/segments/" + SEGMENT1_KEY; MessageEvent event = new MessageEvent("{\"path\":\"" + path + "\",\"version\":" + (SEGMENT1_VERSION + 1) + "}"); - eventHandler.onMessage("delete", event); + handler.onMessage("delete", event); - assertNull(featureStore.get(SEGMENTS, SEGMENT1_KEY)); + assertEquals(ItemDescriptor.deletedItem(SEGMENT1_VERSION + 1), dataStore.get(SEGMENTS, SEGMENT1_KEY)); } @Test @@ -305,7 +301,8 @@ public void indirectPutRequestsAndStoresFeature() throws Exception { try (StreamProcessor sp = createStreamProcessor(STREAM_URI)) { sp.start(); - eventHandler.onMessage("indirect/put", new MessageEvent("")); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("indirect/put", new MessageEvent("")); assertFeatureInStore(FEATURE); } @@ -317,9 +314,10 @@ public void indirectPutInitializesStore() throws Exception { setupRequestorToReturnAllDataWithFlag(FEATURE); replayAll(); - eventHandler.onMessage("indirect/put", new MessageEvent("")); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("indirect/put", new MessageEvent("")); - assertTrue(featureStore.initialized()); + assertTrue(dataStore.isInitialized()); } @Test @@ -329,9 +327,10 @@ public void indirectPutInitializesProcessor() throws Exception { setupRequestorToReturnAllDataWithFlag(FEATURE); replayAll(); - eventHandler.onMessage("indirect/put", new MessageEvent("")); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("indirect/put", new MessageEvent("")); - assertTrue(featureStore.initialized()); + assertTrue(dataStore.isInitialized()); } @Test @@ -341,7 +340,8 @@ public void indirectPutSetsFuture() throws Exception { setupRequestorToReturnAllDataWithFlag(FEATURE); replayAll(); - eventHandler.onMessage("indirect/put", new MessageEvent("")); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("indirect/put", new MessageEvent("")); assertTrue(future.isDone()); } @@ -355,8 +355,9 @@ public void indirectPatchRequestsAndUpdatesFeature() throws Exception { try (StreamProcessor sp = createStreamProcessor(STREAM_URI)) { sp.start(); - eventHandler.onMessage("put", emptyPutEvent()); - eventHandler.onMessage("indirect/patch", new MessageEvent("/flags/" + FEATURE1_KEY)); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("put", emptyPutEvent()); + handler.onMessage("indirect/patch", new MessageEvent("/flags/" + FEATURE1_KEY)); assertFeatureInStore(FEATURE); } @@ -371,8 +372,9 @@ public void indirectPatchRequestsAndUpdatesSegment() throws Exception { try (StreamProcessor sp = createStreamProcessor(STREAM_URI)) { sp.start(); - eventHandler.onMessage("put", emptyPutEvent()); - eventHandler.onMessage("indirect/patch", new MessageEvent("/segments/" + SEGMENT1_KEY)); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("put", emptyPutEvent()); + handler.onMessage("indirect/patch", new MessageEvent("/segments/" + SEGMENT1_KEY)); assertSegmentInStore(SEGMENT); } @@ -381,12 +383,14 @@ public void indirectPatchRequestsAndUpdatesSegment() throws Exception { @Test public void unknownEventTypeDoesNotThrowException() throws Exception { createStreamProcessor(STREAM_URI).start(); - eventHandler.onMessage("what", new MessageEvent("")); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("what", new MessageEvent("")); } @Test public void streamWillReconnectAfterGeneralIOException() throws Exception { createStreamProcessor(STREAM_URI).start(); + ConnectionErrorHandler errorHandler = mockEventSourceCreator.getNextReceivedParams().errorHandler; ConnectionErrorHandler.Action action = errorHandler.onConnectionError(new IOException()); assertEquals(ConnectionErrorHandler.Action.PROCEED, action); } @@ -396,7 +400,8 @@ public void streamInitDiagnosticRecordedOnOpen() throws Exception { DiagnosticAccumulator acc = new DiagnosticAccumulator(new DiagnosticId(SDK_KEY)); long startTime = System.currentTimeMillis(); createStreamProcessor(LDConfig.DEFAULT, STREAM_URI, acc).start(); - eventHandler.onMessage("put", emptyPutEvent()); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("put", emptyPutEvent()); long timeAfterOpen = System.currentTimeMillis(); DiagnosticEvent.Statistics event = acc.createEventAndReset(0, 0); assertEquals(1, event.streamInits.size()); @@ -412,6 +417,7 @@ public void streamInitDiagnosticRecordedOnErrorDuringInit() throws Exception { DiagnosticAccumulator acc = new DiagnosticAccumulator(new DiagnosticId(SDK_KEY)); long startTime = System.currentTimeMillis(); createStreamProcessor(LDConfig.DEFAULT, STREAM_URI, acc).start(); + ConnectionErrorHandler errorHandler = mockEventSourceCreator.getNextReceivedParams().errorHandler; errorHandler.onConnectionError(new IOException()); long timeAfterOpen = System.currentTimeMillis(); DiagnosticEvent.Statistics event = acc.createEventAndReset(0, 0); @@ -427,10 +433,11 @@ public void streamInitDiagnosticRecordedOnErrorDuringInit() throws Exception { public void streamInitDiagnosticNotRecordedOnErrorAfterInit() throws Exception { DiagnosticAccumulator acc = new DiagnosticAccumulator(new DiagnosticId(SDK_KEY)); createStreamProcessor(LDConfig.DEFAULT, STREAM_URI, acc).start(); - eventHandler.onMessage("put", emptyPutEvent()); + StreamProcessor.EventSourceParams params = mockEventSourceCreator.getNextReceivedParams(); + params.handler.onMessage("put", emptyPutEvent()); // Drop first stream init from stream open acc.createEventAndReset(0, 0); - errorHandler.onConnectionError(new IOException()); + params.errorHandler.onConnectionError(new IOException()); DiagnosticEvent.Statistics event = acc.createEventAndReset(0, 0); assertEquals(0, event.streamInits.size()); } @@ -467,22 +474,22 @@ public void http500ErrorIsRecoverable() throws Exception { @Test public void putEventWithInvalidJsonCausesStreamRestart() throws Exception { - verifyEventCausesStreamRestart("put", "{sorry"); + verifyEventCausesStreamRestartWithInMemoryStore("put", "{sorry"); } @Test public void putEventWithWellFormedJsonButInvalidDataCausesStreamRestart() throws Exception { - verifyEventCausesStreamRestart("put", "{\"data\":{\"flags\":3}}"); + verifyEventCausesStreamRestartWithInMemoryStore("put", "{\"data\":{\"flags\":3}}"); } @Test public void patchEventWithInvalidJsonCausesStreamRestart() throws Exception { - verifyEventCausesStreamRestart("patch", "{sorry"); + verifyEventCausesStreamRestartWithInMemoryStore("patch", "{sorry"); } @Test public void patchEventWithWellFormedJsonButInvalidDataCausesStreamRestart() throws Exception { - verifyEventCausesStreamRestart("patch", "{\"path\":\"/flags/flagkey\", \"data\":{\"rules\":3}}"); + verifyEventCausesStreamRestartWithInMemoryStore("patch", "{\"path\":\"/flags/flagkey\", \"data\":{\"rules\":3}}"); } @Test @@ -492,7 +499,7 @@ public void patchEventWithInvalidPathCausesNoStreamRestart() throws Exception { @Test public void deleteEventWithInvalidJsonCausesStreamRestart() throws Exception { - verifyEventCausesStreamRestart("delete", "{sorry"); + verifyEventCausesStreamRestartWithInMemoryStore("delete", "{sorry"); } @Test @@ -508,37 +515,98 @@ public void indirectPatchEventWithInvalidPathDoesNotCauseStreamRestart() throws @Test public void indirectPutWithFailedPollCausesStreamRestart() throws Exception { expect(mockRequestor.getAllData()).andThrow(new IOException("sorry")); - verifyEventCausesStreamRestart("indirect/put", ""); + verifyEventCausesStreamRestartWithInMemoryStore("indirect/put", ""); } @Test public void indirectPatchWithFailedPollCausesStreamRestart() throws Exception { expect(mockRequestor.getFlag("flagkey")).andThrow(new IOException("sorry")); - verifyEventCausesStreamRestart("indirect/patch", "/flags/flagkey"); + verifyEventCausesStreamRestartWithInMemoryStore("indirect/patch", "/flags/flagkey"); } + @Test + public void restartsStreamIfStoreNeedsRefresh() throws Exception { + TestComponents.DataStoreWithStatusUpdates storeWithStatus = new TestComponents.DataStoreWithStatusUpdates(dataStore); + + CompletableFuture restarted = new CompletableFuture<>(); + mockEventSource.start(); + expectLastCall(); + mockEventSource.restart(); + expectLastCall().andAnswer(() -> { + restarted.complete(null); + return null; + }); + mockEventSource.close(); + expectLastCall(); + mockRequestor.close(); + expectLastCall(); + + replayAll(); + + try (StreamProcessor sp = createStreamProcessorWithStore(storeWithStatus)) { + sp.start(); + + storeWithStatus.broadcastStatusChange(new DataStoreStatusProvider.Status(false, false)); + storeWithStatus.broadcastStatusChange(new DataStoreStatusProvider.Status(true, true)); + + restarted.get(); + } + } + + @Test + public void doesNotRestartStreamIfStoreHadOutageButDoesNotNeedRefresh() throws Exception { + TestComponents.DataStoreWithStatusUpdates storeWithStatus = new TestComponents.DataStoreWithStatusUpdates(dataStore); + + CompletableFuture restarted = new CompletableFuture<>(); + mockEventSource.start(); + expectLastCall(); + mockEventSource.restart(); + expectLastCall().andAnswer(() -> { + restarted.complete(null); + return null; + }); + mockEventSource.close(); + expectLastCall(); + mockRequestor.close(); + expectLastCall(); + + replayAll(); + + try (StreamProcessor sp = createStreamProcessorWithStore(storeWithStatus)) { + sp.start(); + + storeWithStatus.broadcastStatusChange(new DataStoreStatusProvider.Status(false, false)); + storeWithStatus.broadcastStatusChange(new DataStoreStatusProvider.Status(true, false)); + + Thread.sleep(500); + assertFalse(restarted.isDone()); + } + } + @Test public void storeFailureOnPutCausesStreamRestart() throws Exception { - FeatureStore badStore = featureStoreThatThrowsException(new RuntimeException("sorry")); + DataStore badStore = dataStoreThatThrowsException(new RuntimeException("sorry")); expectStreamRestart(); replayAll(); try (StreamProcessor sp = createStreamProcessorWithStore(badStore)) { sp.start(); - eventHandler.onMessage("put", emptyPutEvent()); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("put", emptyPutEvent()); } verifyAll(); } @Test public void storeFailureOnPatchCausesStreamRestart() throws Exception { - FeatureStore badStore = featureStoreThatThrowsException(new RuntimeException("sorry")); + DataStore badStore = dataStoreThatThrowsException(new RuntimeException("sorry")); expectStreamRestart(); replayAll(); try (StreamProcessor sp = createStreamProcessorWithStore(badStore)) { sp.start(); - eventHandler.onMessage("patch", + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("patch", new MessageEvent("{\"path\":\"/flags/flagkey\",\"data\":{\"key\":\"flagkey\",\"version\":1}}")); } verifyAll(); @@ -546,13 +614,14 @@ public void storeFailureOnPatchCausesStreamRestart() throws Exception { @Test public void storeFailureOnDeleteCausesStreamRestart() throws Exception { - FeatureStore badStore = featureStoreThatThrowsException(new RuntimeException("sorry")); + DataStore badStore = dataStoreThatThrowsException(new RuntimeException("sorry")); expectStreamRestart(); replayAll(); try (StreamProcessor sp = createStreamProcessorWithStore(badStore)) { sp.start(); - eventHandler.onMessage("delete", + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("delete", new MessageEvent("{\"path\":\"/flags/flagkey\",\"version\":1}")); } verifyAll(); @@ -560,21 +629,22 @@ public void storeFailureOnDeleteCausesStreamRestart() throws Exception { @Test public void storeFailureOnIndirectPutCausesStreamRestart() throws Exception { - FeatureStore badStore = featureStoreThatThrowsException(new RuntimeException("sorry")); + DataStore badStore = dataStoreThatThrowsException(new RuntimeException("sorry")); setupRequestorToReturnAllDataWithFlag(FEATURE); expectStreamRestart(); replayAll(); try (StreamProcessor sp = createStreamProcessorWithStore(badStore)) { sp.start(); - eventHandler.onMessage("indirect/put", new MessageEvent("")); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("indirect/put", new MessageEvent("")); } verifyAll(); } @Test public void storeFailureOnIndirectPatchCausesStreamRestart() throws Exception { - FeatureStore badStore = featureStoreThatThrowsException(new RuntimeException("sorry")); + DataStore badStore = dataStoreThatThrowsException(new RuntimeException("sorry")); setupRequestorToReturnAllDataWithFlag(FEATURE); expectStreamRestart(); @@ -582,7 +652,8 @@ public void storeFailureOnIndirectPatchCausesStreamRestart() throws Exception { try (StreamProcessor sp = createStreamProcessorWithStore(badStore)) { sp.start(); - eventHandler.onMessage("indirect/put", new MessageEvent("")); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage("indirect/put", new MessageEvent("")); } verifyAll(); } @@ -592,7 +663,7 @@ private void verifyEventCausesNoStreamRestart(String eventName, String eventData verifyEventBehavior(eventName, eventData); } - private void verifyEventCausesStreamRestart(String eventName, String eventData) throws Exception { + private void verifyEventCausesStreamRestartWithInMemoryStore(String eventName, String eventData) throws Exception { expectStreamRestart(); verifyEventBehavior(eventName, eventData); } @@ -601,7 +672,8 @@ private void verifyEventBehavior(String eventName, String eventData) throws Exce replayAll(); try (StreamProcessor sp = createStreamProcessor(LDConfig.DEFAULT, STREAM_URI, null)) { sp.start(); - eventHandler.onMessage(eventName, new MessageEvent(eventData)); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onMessage(eventName, new MessageEvent(eventData)); } verifyAll(); } @@ -705,6 +777,7 @@ private void testUnrecoverableHttpError(int status) throws Exception { StreamProcessor sp = createStreamProcessor(STREAM_URI); Future initFuture = sp.start(); + ConnectionErrorHandler errorHandler = mockEventSourceCreator.getNextReceivedParams().errorHandler; ConnectionErrorHandler.Action action = errorHandler.onConnectionError(e); assertEquals(ConnectionErrorHandler.Action.SHUTDOWN, action); @@ -715,7 +788,7 @@ private void testUnrecoverableHttpError(int status) throws Exception { } assertTrue((System.currentTimeMillis() - startTime) < 9000); assertTrue(initFuture.isDone()); - assertFalse(sp.initialized()); + assertFalse(sp.isInitialized()); } private void testRecoverableHttpError(int status) throws Exception { @@ -724,6 +797,7 @@ private void testRecoverableHttpError(int status) throws Exception { StreamProcessor sp = createStreamProcessor(STREAM_URI); Future initFuture = sp.start(); + ConnectionErrorHandler errorHandler = mockEventSourceCreator.getNextReceivedParams().errorHandler; ConnectionErrorHandler.Action action = errorHandler.onConnectionError(e); assertEquals(ConnectionErrorHandler.Action.PROCEED, action); @@ -734,7 +808,7 @@ private void testRecoverableHttpError(int status) throws Exception { } assertTrue((System.currentTimeMillis() - startTime) >= 200); assertFalse(initFuture.isDone()); - assertFalse(sp.initialized()); + assertFalse(sp.isInitialized()); } private StreamProcessor createStreamProcessor(URI streamUri) { @@ -746,55 +820,44 @@ private StreamProcessor createStreamProcessor(LDConfig config, URI streamUri) { } private StreamProcessor createStreamProcessor(LDConfig config, URI streamUri, DiagnosticAccumulator diagnosticAccumulator) { - return new StreamProcessor(SDK_KEY, config.httpConfig, mockRequestor, featureStore, - new StubEventSourceCreator(), diagnosticAccumulator, - streamUri, config.deprecatedReconnectTimeMs); + return new StreamProcessor(SDK_KEY, config.httpConfig, mockRequestor, dataStoreUpdates(dataStore), + mockEventSourceCreator, diagnosticAccumulator, + streamUri, DEFAULT_INITIAL_RECONNECT_DELAY); } private StreamProcessor createStreamProcessorWithRealHttp(LDConfig config, URI streamUri) { - return new StreamProcessor(SDK_KEY, config.httpConfig, mockRequestor, featureStore, null, null, - streamUri, config.deprecatedReconnectTimeMs); + return new StreamProcessor(SDK_KEY, config.httpConfig, mockRequestor, dataStoreUpdates(dataStore), null, null, + streamUri, DEFAULT_INITIAL_RECONNECT_DELAY); } - private StreamProcessor createStreamProcessorWithStore(FeatureStore store) { - return new StreamProcessor(SDK_KEY, LDConfig.DEFAULT.httpConfig, mockRequestor, store, - new StubEventSourceCreator(), null, STREAM_URI, 0); + private StreamProcessor createStreamProcessorWithStore(DataStore store) { + return new StreamProcessor(SDK_KEY, LDConfig.DEFAULT.httpConfig, mockRequestor, dataStoreUpdates(store), + mockEventSourceCreator, null, STREAM_URI, DEFAULT_INITIAL_RECONNECT_DELAY); } private String featureJson(String key, int version) { - return "{\"key\":\"" + key + "\",\"version\":" + version + ",\"on\":true}"; + return gsonInstance().toJson(flagBuilder(key).version(version).build()); } private String segmentJson(String key, int version) { - return "{\"key\":\"" + key + "\",\"version\":" + version + ",\"includes\":[],\"excludes\":[],\"rules\":[]}"; + return gsonInstance().toJson(ModelBuilders.segmentBuilder(key).version(version).build()); } private MessageEvent emptyPutEvent() { return new MessageEvent("{\"data\":{\"flags\":{},\"segments\":{}}}"); } - private void setupRequestorToReturnAllDataWithFlag(FeatureFlag feature) throws Exception { + private void setupRequestorToReturnAllDataWithFlag(DataModel.FeatureFlag feature) throws Exception { FeatureRequestor.AllData data = new FeatureRequestor.AllData( - Collections.singletonMap(feature.getKey(), feature), Collections.emptyMap()); + Collections.singletonMap(feature.getKey(), feature), Collections.emptyMap()); expect(mockRequestor.getAllData()).andReturn(data); } - private void assertFeatureInStore(FeatureFlag feature) { - assertEquals(feature.getVersion(), featureStore.get(FEATURES, feature.getKey()).getVersion()); + private void assertFeatureInStore(DataModel.FeatureFlag feature) { + assertEquals(feature.getVersion(), dataStore.get(FEATURES, feature.getKey()).getVersion()); } - private void assertSegmentInStore(Segment segment) { - assertEquals(segment.getVersion(), featureStore.get(SEGMENTS, segment.getKey()).getVersion()); - } - - private class StubEventSourceCreator implements StreamProcessor.EventSourceCreator { - 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; - StreamProcessorTest.this.headers = headers; - return mockEventSource; - } + private void assertSegmentInStore(DataModel.Segment segment) { + assertEquals(segment.getVersion(), dataStore.get(SEGMENTS, segment.getKey()).getVersion()); } } diff --git a/src/test/java/com/launchdarkly/sdk/server/TestComponents.java b/src/test/java/com/launchdarkly/sdk/server/TestComponents.java new file mode 100644 index 000000000..cace1de30 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/TestComponents.java @@ -0,0 +1,281 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.eventsource.EventSource; +import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.server.DataModel.FeatureFlag; +import com.launchdarkly.sdk.server.integrations.EventProcessorBuilder; +import com.launchdarkly.sdk.server.interfaces.ClientContext; +import com.launchdarkly.sdk.server.interfaces.DataSource; +import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; +import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; +import com.launchdarkly.sdk.server.interfaces.Event; +import com.launchdarkly.sdk.server.interfaces.EventProcessor; +import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Future; +import java.util.concurrent.LinkedBlockingQueue; + +import static com.launchdarkly.sdk.server.DataModel.FEATURES; + +@SuppressWarnings("javadoc") +public class TestComponents { + public static ClientContext clientContext(final String sdkKey, final LDConfig config) { + return new ClientContextImpl(sdkKey, config, null); + } + + public static ClientContext clientContext(final String sdkKey, final LDConfig config, DiagnosticAccumulator diagnosticAccumulator) { + return new ClientContextImpl(sdkKey, config, diagnosticAccumulator); + } + + public static DataSourceFactory dataSourceWithData(FullDataSet data) { + return (context, dataStoreUpdates) -> new DataSourceWithData(data, dataStoreUpdates); + } + + public static DataStore dataStoreThatThrowsException(final RuntimeException e) { + return new DataStoreThatThrowsException(e); + } + + public static DataStoreUpdates dataStoreUpdates(final DataStore store) { + return new DataStoreUpdatesImpl(store, null); + } + + static EventsConfiguration defaultEventsConfig() { + return makeEventsConfig(false, false, null); + } + + public static DataSource failedDataSource() { + return new DataSourceThatNeverInitializes(); + } + + public static DataStore inMemoryDataStore() { + return new InMemoryDataStore(); // this is for tests in other packages which can't see this concrete class + } + + public static DataStore initedDataStore() { + DataStore store = new InMemoryDataStore(); + store.init(new FullDataSet(null)); + return store; + } + + static EventsConfiguration makeEventsConfig(boolean allAttributesPrivate, boolean inlineUsersInEvents, + Set privateAttributes) { + return new EventsConfiguration( + allAttributesPrivate, + 0, null, EventProcessorBuilder.DEFAULT_FLUSH_INTERVAL, + inlineUsersInEvents, + privateAttributes, + 0, 0, EventProcessorBuilder.DEFAULT_USER_KEYS_FLUSH_INTERVAL, + EventProcessorBuilder.DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL); + } + + public static DataSourceFactory specificDataSource(final DataSource up) { + return (context, dataStoreUpdates) -> up; + } + + public static DataStoreFactory specificDataStore(final DataStore store) { + return context -> store; + } + + public static EventProcessorFactory specificEventProcessor(final EventProcessor ep) { + return context -> ep; + } + + public static class TestEventProcessor implements EventProcessor { + List events = new ArrayList<>(); + + @Override + public void close() throws IOException {} + + @Override + public void sendEvent(Event e) { + events.add(e); + } + + @Override + public void flush() {} + } + + public static class DataSourceFactoryThatExposesUpdater implements DataSourceFactory { + private final FullDataSet initialData; + private DataStoreUpdates dataStoreUpdates; + + public DataSourceFactoryThatExposesUpdater(FullDataSet initialData) { + this.initialData = initialData; + } + + @Override + public DataSource createDataSource(ClientContext context, DataStoreUpdates dataStoreUpdates) { + this.dataStoreUpdates = dataStoreUpdates; + return dataSourceWithData(initialData).createDataSource(context, dataStoreUpdates); + } + + public void updateFlag(FeatureFlag flag) { + dataStoreUpdates.upsert(FEATURES, flag.getKey(), new ItemDescriptor(flag.getVersion(), flag)); + } + } + + private static class DataSourceThatNeverInitializes implements DataSource { + public Future start() { + return new CompletableFuture<>(); + } + + public boolean isInitialized() { + return false; + } + + public void close() throws IOException { + } + }; + + private static class DataSourceWithData implements DataSource { + private final FullDataSet data; + private final DataStoreUpdates dataStoreUpdates; + + DataSourceWithData(FullDataSet data, DataStoreUpdates dataStoreUpdates) { + this.data = data; + this.dataStoreUpdates = dataStoreUpdates; + } + + public Future start() { + dataStoreUpdates.init(data); + return CompletableFuture.completedFuture(null); + } + + public boolean isInitialized() { + return true; + } + + public void close() throws IOException { + } + } + + private static class DataStoreThatThrowsException implements DataStore { + private final RuntimeException e; + + DataStoreThatThrowsException(RuntimeException e) { + this.e = e; + } + + public void close() throws IOException { } + + public ItemDescriptor get(DataKind kind, String key) { + throw e; + } + + public KeyedItems getAll(DataKind kind) { + throw e; + } + + public void init(FullDataSet allData) { + throw e; + } + + public boolean upsert(DataKind kind, String key, ItemDescriptor item) { + throw e; + } + + public boolean isInitialized() { + return true; + } + } + + public static class DataStoreWithStatusUpdates implements DataStore, DataStoreStatusProvider { + private final DataStore wrappedStore; + private final List listeners = new ArrayList<>(); + volatile Status currentStatus = new Status(true, false); + + DataStoreWithStatusUpdates(DataStore wrappedStore) { + this.wrappedStore = wrappedStore; + } + + public void broadcastStatusChange(final Status newStatus) { + currentStatus = newStatus; + final StatusListener[] ls; + synchronized (this) { + ls = listeners.toArray(new StatusListener[listeners.size()]); + } + Thread t = new Thread(() -> { + for (StatusListener l: ls) { + l.dataStoreStatusChanged(newStatus); + } + }); + t.start(); + } + + public void close() throws IOException { + wrappedStore.close(); + } + + public ItemDescriptor get(DataKind kind, String key) { + return wrappedStore.get(kind, key); + } + + public KeyedItems getAll(DataKind kind) { + return wrappedStore.getAll(kind); + } + + public void init(FullDataSet allData) { + wrappedStore.init(allData); + } + + public boolean upsert(DataKind kind, String key, ItemDescriptor item) { + return wrappedStore.upsert(kind, key, item); + } + + public boolean isInitialized() { + return wrappedStore.isInitialized(); + } + + public Status getStoreStatus() { + return currentStatus; + } + + public boolean addStatusListener(StatusListener listener) { + synchronized (this) { + listeners.add(listener); + } + return true; + } + + public void removeStatusListener(StatusListener listener) { + synchronized (this) { + listeners.remove(listener); + } + } + + public CacheStats getCacheStats() { + return null; + } + } + + public static class MockEventSourceCreator implements StreamProcessor.EventSourceCreator { + private final EventSource eventSource; + private final BlockingQueue receivedParams = new LinkedBlockingQueue<>(); + + MockEventSourceCreator(EventSource eventSource) { + this.eventSource = eventSource; + } + + public EventSource createEventSource(StreamProcessor.EventSourceParams params) { + receivedParams.add(params); + return eventSource; + } + + public StreamProcessor.EventSourceParams getNextReceivedParams() { + return receivedParams.poll(); + } + } +} diff --git a/src/test/java/com/launchdarkly/client/TestHttpUtil.java b/src/test/java/com/launchdarkly/sdk/server/TestHttpUtil.java similarity index 91% rename from src/test/java/com/launchdarkly/client/TestHttpUtil.java rename to src/test/java/com/launchdarkly/sdk/server/TestHttpUtil.java index 79fd8f30a..fa45ea59d 100644 --- a/src/test/java/com/launchdarkly/client/TestHttpUtil.java +++ b/src/test/java/com/launchdarkly/sdk/server/TestHttpUtil.java @@ -1,7 +1,8 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; -import com.launchdarkly.client.integrations.PollingDataSourceBuilder; -import com.launchdarkly.client.integrations.StreamingDataSourceBuilder; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.integrations.PollingDataSourceBuilder; +import com.launchdarkly.sdk.server.integrations.StreamingDataSourceBuilder; import java.io.Closeable; import java.io.IOException; diff --git a/src/test/java/com/launchdarkly/sdk/server/TestUtil.java b/src/test/java/com/launchdarkly/sdk/server/TestUtil.java new file mode 100644 index 000000000..c81630b5c --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/TestUtil.java @@ -0,0 +1,176 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.collect.ImmutableSet; +import com.google.gson.Gson; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.LDValueType; +import com.launchdarkly.sdk.server.DataModel.FeatureFlag; +import com.launchdarkly.sdk.server.DataModel.Segment; +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; +import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; +import com.launchdarkly.sdk.server.interfaces.FlagValueChangeEvent; +import com.launchdarkly.sdk.server.interfaces.FlagValueChangeListener; + +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.TypeSafeDiagnosingMatcher; + +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.TimeUnit; + +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.fail; + +@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 void upsertFlag(DataStore store, FeatureFlag flag) { + store.upsert(FEATURES, flag.getKey(), new ItemDescriptor(flag.getVersion(), flag)); + } + + public static void upsertSegment(DataStore store, Segment segment) { + store.upsert(SEGMENTS, segment.getKey(), new ItemDescriptor(segment.getVersion(), segment)); + } + + public static class FlagChangeEventSink extends FlagChangeEventSinkBase implements FlagChangeListener { + @Override + public void onFlagChange(FlagChangeEvent event) { + events.add(event); + } + } + + public static class FlagValueChangeEventSink extends FlagChangeEventSinkBase implements FlagValueChangeListener { + @Override + public void onFlagValueChange(FlagValueChangeEvent event) { + events.add(event); + } + } + + private static class FlagChangeEventSinkBase { + protected final BlockingQueue events = new ArrayBlockingQueue<>(100); + + public T awaitEvent() { + try { + T event = events.poll(1, TimeUnit.SECONDS); + assertNotNull("expected flag change event", event); + return event; + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + + public void expectEvents(String... flagKeys) { + Set expectedChangedFlagKeys = ImmutableSet.copyOf(flagKeys); + Set actualChangedFlagKeys = new HashSet<>(); + for (int i = 0; i < expectedChangedFlagKeys.size(); i++) { + try { + T e = events.poll(1, TimeUnit.SECONDS); + if (e == null) { + fail("expected change events for " + expectedChangedFlagKeys + " but got " + actualChangedFlagKeys); + } + actualChangedFlagKeys.add(e.getKey()); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + assertThat(actualChangedFlagKeys, equalTo(expectedChangedFlagKeys)); + expectNoEvents(); + } + + public void expectNoEvents() { + try { + T event = events.poll(100, TimeUnit.MILLISECONDS); + assertNull("expected no more flag change events", event); + } catch (InterruptedException e) {} + } + } + + public static Evaluator.EvalResult simpleEvaluation(int variation, LDValue value) { + return new Evaluator.EvalResult(value, variation, EvaluationReason.fallthrough()); + } + + public static Matcher hasJsonProperty(final String name, LDValue value) { + return hasJsonProperty(name, equalTo(value)); + } + + public static Matcher hasJsonProperty(final String name, String value) { + return hasJsonProperty(name, LDValue.of(value)); + } + + public static Matcher hasJsonProperty(final String name, int value) { + return hasJsonProperty(name, LDValue.of(value)); + } + + public static Matcher hasJsonProperty(final String name, double value) { + return hasJsonProperty(name, LDValue.of(value)); + } + + public static Matcher hasJsonProperty(final String name, boolean value) { + return hasJsonProperty(name, LDValue.of(value)); + } + + public static Matcher hasJsonProperty(final String name, final Matcher matcher) { + return new TypeSafeDiagnosingMatcher() { + @Override + public void describeTo(Description description) { + description.appendText(name + ": "); + matcher.describeTo(description); + } + + @Override + protected boolean matchesSafely(LDValue item, Description mismatchDescription) { + LDValue value = item.get(name); + if (!matcher.matches(value)) { + matcher.describeMismatch(value, mismatchDescription); + return false; + } + return true; + } + }; + } + + public static Matcher isJsonArray(final Matcher> matcher) { + return new TypeSafeDiagnosingMatcher() { + @Override + public void describeTo(Description description) { + description.appendText("array: "); + matcher.describeTo(description); + } + + @Override + protected boolean matchesSafely(LDValue item, Description mismatchDescription) { + if (item.getType() != LDValueType.ARRAY) { + matcher.describeMismatch(item, mismatchDescription); + return false; + } else { + Iterable values = item.values(); + if (!matcher.matches(values)) { + matcher.describeMismatch(values, mismatchDescription); + return false; + } + } + return true; + } + }; + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/UtilTest.java b/src/test/java/com/launchdarkly/sdk/server/UtilTest.java new file mode 100644 index 000000000..ef7ffed7f --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/UtilTest.java @@ -0,0 +1,40 @@ +package com.launchdarkly.sdk.server; + +import org.junit.Test; + +import java.time.Duration; + +import static com.launchdarkly.sdk.server.Util.configureHttpClientBuilder; +import static com.launchdarkly.sdk.server.Util.shutdownHttpClient; +import static org.junit.Assert.assertEquals; + +import okhttp3.OkHttpClient; + +@SuppressWarnings("javadoc") +public class UtilTest { + @Test + public void testConnectTimeout() { + LDConfig config = new LDConfig.Builder().http(Components.httpConfiguration().connectTimeout(Duration.ofSeconds(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 testSocketTimeout() { + LDConfig config = new LDConfig.Builder().http(Components.httpConfiguration().socketTimeout(Duration.ofSeconds(3))).build(); + OkHttpClient.Builder httpBuilder = new OkHttpClient.Builder(); + configureHttpClientBuilder(config.httpConfig, httpBuilder); + OkHttpClient httpClient = httpBuilder.build(); + try { + assertEquals(3000, httpClient.readTimeoutMillis()); + } finally { + shutdownHttpClient(httpClient); + } + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/ClientWithFileDataSourceTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/ClientWithFileDataSourceTest.java new file mode 100644 index 000000000..9f0b8430b --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/ClientWithFileDataSourceTest.java @@ -0,0 +1,50 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.LDClient; +import com.launchdarkly.sdk.server.LDConfig; +import com.launchdarkly.sdk.server.integrations.FileData; +import com.launchdarkly.sdk.server.integrations.FileDataSourceBuilder; + +import org.junit.Test; + +import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.FLAG_VALUE_1; +import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.FLAG_VALUE_1_KEY; +import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.FULL_FLAG_1_KEY; +import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.FULL_FLAG_1_VALUE; +import static com.launchdarkly.sdk.server.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 { + FileDataSourceBuilder fdsb = FileData.dataSource() + .filePaths(resourceFilePath("all-properties.json")); + LDConfig config = new LDConfig.Builder() + .dataSource(fdsb) + .events(Components.noEvents()) + .build(); + return new LDClient("sdkKey", config); + } + + @Test + public void fullFlagDefinitionEvaluatesAsExpected() throws Exception { + try (LDClient client = makeClient()) { + assertThat(client.jsonValueVariation(FULL_FLAG_1_KEY, user, LDValue.of("default")), + equalTo(FULL_FLAG_1_VALUE)); + } + } + + @Test + public void simplifiedFlagEvaluatesAsExpected() throws Exception { + try (LDClient client = makeClient()) { + assertThat(client.jsonValueVariation(FLAG_VALUE_1_KEY, user, LDValue.of("default")), + equalTo(FLAG_VALUE_1)); + } + } +} diff --git a/src/test/java/com/launchdarkly/client/integrations/DataLoaderTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/DataLoaderTest.java similarity index 74% rename from src/test/java/com/launchdarkly/client/integrations/DataLoaderTest.java rename to src/test/java/com/launchdarkly/sdk/server/integrations/DataLoaderTest.java index b78585783..d9f2969db 100644 --- a/src/test/java/com/launchdarkly/client/integrations/DataLoaderTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/DataLoaderTest.java @@ -1,24 +1,25 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; 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 com.launchdarkly.client.integrations.FileDataSourceImpl.DataBuilder; -import com.launchdarkly.client.integrations.FileDataSourceImpl.DataLoader; -import com.launchdarkly.client.integrations.FileDataSourceParsing.FileDataException; +import com.launchdarkly.sdk.server.integrations.FileDataSourceImpl.DataBuilder; +import com.launchdarkly.sdk.server.integrations.FileDataSourceImpl.DataLoader; +import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FileDataException; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; 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.integrations.FileDataSourceTestData.FLAG_VALUE_1_KEY; -import static com.launchdarkly.client.integrations.FileDataSourceTestData.resourceFilePath; +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; +import static com.launchdarkly.sdk.server.DataStoreTestTypes.toDataMap; +import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.FLAG_VALUE_1_KEY; +import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.resourceFilePath; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; @@ -59,8 +60,8 @@ public void flagValueIsConvertedToFlag() throws Exception { "\"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(); + ItemDescriptor flag = toDataMap(builder.build()).get(FEATURES).get(FLAG_VALUE_1_KEY); + JsonObject actual = gson.toJsonTree(flag.getItem()).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()) { @@ -101,10 +102,10 @@ public void duplicateSegmentKeyThrowsException() throws Exception { } } - private void assertDataHasItemsOfKind(VersionedDataKind kind) { - Map items = builder.build().get(kind); + private void assertDataHasItemsOfKind(DataKind kind) { + Map items = toDataMap(builder.build()).get(kind); if (items == null || items.size() == 0) { - Assert.fail("expected at least one item in \"" + kind.getNamespace() + "\", received: " + builder.build()); + Assert.fail("expected at least one item in \"" + kind.getName() + "\", received: " + builder.build()); } } } diff --git a/src/test/java/com/launchdarkly/client/integrations/EventProcessorBuilderTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/EventProcessorBuilderTest.java similarity index 50% rename from src/test/java/com/launchdarkly/client/integrations/EventProcessorBuilderTest.java rename to src/test/java/com/launchdarkly/sdk/server/integrations/EventProcessorBuilderTest.java index 689c210fa..7a9142d80 100644 --- a/src/test/java/com/launchdarkly/client/integrations/EventProcessorBuilderTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/EventProcessorBuilderTest.java @@ -1,9 +1,12 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; -import com.launchdarkly.client.Components; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.integrations.EventProcessorBuilder; import org.junit.Test; +import java.time.Duration; + import static org.junit.Assert.assertEquals; @SuppressWarnings("javadoc") @@ -11,19 +14,18 @@ public class EventProcessorBuilderTest { @Test public void testDefaultDiagnosticRecordingInterval() { EventProcessorBuilder builder = Components.sendEvents(); - assertEquals(900, builder.diagnosticRecordingIntervalSeconds); + assertEquals(Duration.ofSeconds(900), builder.diagnosticRecordingInterval); } @Test public void testDiagnosticRecordingInterval() { - EventProcessorBuilder builder = Components.sendEvents().diagnosticRecordingIntervalSeconds(120); - assertEquals(120, builder.diagnosticRecordingIntervalSeconds); + EventProcessorBuilder builder = Components.sendEvents().diagnosticRecordingInterval(Duration.ofSeconds(120)); + assertEquals(Duration.ofSeconds(120), builder.diagnosticRecordingInterval); } @Test public void testMinimumDiagnosticRecordingIntervalEnforced() { - EventProcessorBuilder builder = Components.sendEvents().diagnosticRecordingIntervalSeconds(10); - assertEquals(60, builder.diagnosticRecordingIntervalSeconds); + EventProcessorBuilder builder = Components.sendEvents().diagnosticRecordingInterval(Duration.ofSeconds(10)); + assertEquals(Duration.ofSeconds(60), builder.diagnosticRecordingInterval); } - } diff --git a/src/test/java/com/launchdarkly/client/integrations/FileDataSourceTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java similarity index 62% rename from src/test/java/com/launchdarkly/client/integrations/FileDataSourceTest.java rename to src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java index 0d933e967..0861c178c 100644 --- a/src/test/java/com/launchdarkly/client/integrations/FileDataSourceTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java @@ -1,10 +1,8 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; -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 com.launchdarkly.sdk.server.LDConfig; +import com.launchdarkly.sdk.server.interfaces.DataSource; +import com.launchdarkly.sdk.server.interfaces.DataStore; import org.junit.Test; @@ -14,10 +12,17 @@ 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.google.common.collect.Iterables.size; +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; +import static com.launchdarkly.sdk.server.DataStoreTestTypes.toItemsMap; +import static com.launchdarkly.sdk.server.TestComponents.clientContext; +import static com.launchdarkly.sdk.server.TestComponents.dataStoreUpdates; +import static com.launchdarkly.sdk.server.TestComponents.inMemoryDataStore; +import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.ALL_FLAG_KEYS; +import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.ALL_SEGMENT_KEYS; +import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.getResourceContents; +import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.resourceFilePath; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.junit.Assert.fail; @@ -26,7 +31,7 @@ public class FileDataSourceTest { private static final Path badFilePath = Paths.get("no-such-file.json"); - private final FeatureStore store = new InMemoryFeatureStore(); + private final DataStore store = inMemoryDataStore(); private final LDConfig config = new LDConfig.Builder().build(); private final FileDataSourceBuilder factory; @@ -37,29 +42,33 @@ public FileDataSourceTest() throws Exception { private static FileDataSourceBuilder makeFactoryWithFile(Path path) { return FileData.dataSource().filePaths(path); } + + private DataSource makeDataSource(FileDataSourceBuilder builder) { + return builder.createDataSource(clientContext("", config), dataStoreUpdates(store)); + } @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)); + try (DataSource fp = makeDataSource(factory)) { + assertThat(store.isInitialized(), equalTo(false)); + assertThat(size(store.getAll(FEATURES).getItems()), equalTo(0)); + assertThat(size(store.getAll(SEGMENTS).getItems()), equalTo(0)); } } @Test public void flagsAreLoadedOnStart() throws Exception { - try (UpdateProcessor fp = factory.createUpdateProcessor("", config, store)) { + try (DataSource fp = makeDataSource(factory)) { 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)); + assertThat(store.isInitialized(), equalTo(true)); + assertThat(toItemsMap(store.getAll(FEATURES)).keySet(), equalTo(ALL_FLAG_KEYS)); + assertThat(toItemsMap(store.getAll(SEGMENTS)).keySet(), equalTo(ALL_SEGMENT_KEYS)); } } @Test public void startFutureIsCompletedAfterSuccessfulLoad() throws Exception { - try (UpdateProcessor fp = factory.createUpdateProcessor("", config, store)) { + try (DataSource fp = makeDataSource(factory)) { Future future = fp.start(); assertThat(future.isDone(), equalTo(true)); } @@ -67,16 +76,16 @@ public void startFutureIsCompletedAfterSuccessfulLoad() throws Exception { @Test public void initializedIsTrueAfterSuccessfulLoad() throws Exception { - try (UpdateProcessor fp = factory.createUpdateProcessor("", config, store)) { + try (DataSource fp = makeDataSource(factory)) { fp.start(); - assertThat(fp.initialized(), equalTo(true)); + assertThat(fp.isInitialized(), equalTo(true)); } } @Test public void startFutureIsCompletedAfterUnsuccessfulLoad() throws Exception { factory.filePaths(badFilePath); - try (UpdateProcessor fp = factory.createUpdateProcessor("", config, store)) { + try (DataSource fp = makeDataSource(factory)) { Future future = fp.start(); assertThat(future.isDone(), equalTo(true)); } @@ -85,9 +94,9 @@ public void startFutureIsCompletedAfterUnsuccessfulLoad() throws Exception { @Test public void initializedIsFalseAfterUnsuccessfulLoad() throws Exception { factory.filePaths(badFilePath); - try (UpdateProcessor fp = factory.createUpdateProcessor("", config, store)) { + try (DataSource fp = makeDataSource(factory)) { fp.start(); - assertThat(fp.initialized(), equalTo(false)); + assertThat(fp.isInitialized(), equalTo(false)); } } @@ -97,12 +106,12 @@ public void modifiedFileIsNotReloadedIfAutoUpdateIsOff() throws Exception { FileDataSourceBuilder factory1 = makeFactoryWithFile(file.toPath()); try { setFileContents(file, getResourceContents("flag-only.json")); - try (UpdateProcessor fp = factory1.createUpdateProcessor("", config, store)) { + try (DataSource fp = makeDataSource(factory1)) { 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)); + assertThat(toItemsMap(store.getAll(FEATURES)).size(), equalTo(1)); + assertThat(toItemsMap(store.getAll(SEGMENTS)).size(), equalTo(0)); } } finally { file.delete(); @@ -119,13 +128,13 @@ public void modifiedFileIsReloadedIfAutoUpdateIsOn() throws Exception { long maxMsToWait = 10000; try { setFileContents(file, getResourceContents("flag-only.json")); // this file has 1 flag - try (UpdateProcessor fp = factory1.createUpdateProcessor("", config, store)) { + try (DataSource fp = makeDataSource(factory1)) { 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()) { + if (toItemsMap(store.getAll(FEATURES)).size() == ALL_FLAG_KEYS.size()) { // success return; } @@ -145,13 +154,13 @@ public void ifFilesAreBadAtStartTimeAutoUpdateCanStillLoadGoodDataLater() throws FileDataSourceBuilder factory1 = makeFactoryWithFile(file.toPath()).autoUpdate(true); long maxMsToWait = 10000; try { - try (UpdateProcessor fp = factory1.createUpdateProcessor("", config, store)) { + try (DataSource fp = makeDataSource(factory1)) { 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) { + if (toItemsMap(store.getAll(FEATURES)).size() > 0) { // success return; } diff --git a/src/test/java/com/launchdarkly/client/integrations/FileDataSourceTestData.java b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTestData.java similarity index 54% rename from src/test/java/com/launchdarkly/client/integrations/FileDataSourceTestData.java rename to src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTestData.java index d222f4c77..de1cb8ae5 100644 --- a/src/test/java/com/launchdarkly/client/integrations/FileDataSourceTestData.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTestData.java @@ -1,10 +1,8 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; 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 com.launchdarkly.sdk.LDValue; import java.net.URISyntaxException; import java.net.URL; @@ -16,26 +14,23 @@ @SuppressWarnings("javadoc") public class FileDataSourceTestData { - 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,\"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); + public static final LDValue FULL_FLAG_1 = + LDValue.parse("{\"key\":\"flag1\",\"on\":true,\"fallthrough\":{\"variation\":2},\"variations\":[\"fall\",\"off\",\"on\"]}"); + public static final LDValue FULL_FLAG_1_VALUE = LDValue.of("on"); + 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 LDValue FLAG_VALUE_1 = LDValue.of("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 LDValue FULL_SEGMENT_1 = LDValue.parse("{\"key\":\"seg1\",\"include\":[\"user1\"]}"); + 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); diff --git a/src/test/java/com/launchdarkly/client/integrations/FlagFileParserJsonTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/FlagFileParserJsonTest.java similarity index 57% rename from src/test/java/com/launchdarkly/client/integrations/FlagFileParserJsonTest.java rename to src/test/java/com/launchdarkly/sdk/server/integrations/FlagFileParserJsonTest.java index c23a66772..45fe4cdfb 100644 --- a/src/test/java/com/launchdarkly/client/integrations/FlagFileParserJsonTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/FlagFileParserJsonTest.java @@ -1,6 +1,6 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; -import com.launchdarkly.client.integrations.FileDataSourceParsing.JsonFlagFileParser; +import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.JsonFlagFileParser; @SuppressWarnings("javadoc") public class FlagFileParserJsonTest extends FlagFileParserTestBase { diff --git a/src/test/java/com/launchdarkly/client/integrations/FlagFileParserTestBase.java b/src/test/java/com/launchdarkly/sdk/server/integrations/FlagFileParserTestBase.java similarity index 77% rename from src/test/java/com/launchdarkly/client/integrations/FlagFileParserTestBase.java rename to src/test/java/com/launchdarkly/sdk/server/integrations/FlagFileParserTestBase.java index fd2be268f..4aedd7359 100644 --- a/src/test/java/com/launchdarkly/client/integrations/FlagFileParserTestBase.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/FlagFileParserTestBase.java @@ -1,8 +1,8 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; -import com.launchdarkly.client.integrations.FileDataSourceParsing.FileDataException; -import com.launchdarkly.client.integrations.FileDataSourceParsing.FlagFileParser; -import com.launchdarkly.client.integrations.FileDataSourceParsing.FlagFileRep; +import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FileDataException; +import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FlagFileParser; +import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FlagFileRep; import org.junit.Test; @@ -10,10 +10,10 @@ 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.sdk.server.integrations.FileDataSourceTestData.FLAG_VALUES; +import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.FULL_FLAGS; +import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.FULL_SEGMENTS; +import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.resourceFilePath; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.nullValue; diff --git a/src/test/java/com/launchdarkly/client/integrations/FlagFileParserYamlTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/FlagFileParserYamlTest.java similarity index 57% rename from src/test/java/com/launchdarkly/client/integrations/FlagFileParserYamlTest.java rename to src/test/java/com/launchdarkly/sdk/server/integrations/FlagFileParserYamlTest.java index 3ad640e92..ce9100c4a 100644 --- a/src/test/java/com/launchdarkly/client/integrations/FlagFileParserYamlTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/FlagFileParserYamlTest.java @@ -1,6 +1,6 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; -import com.launchdarkly.client.integrations.FileDataSourceParsing.YamlFlagFileParser; +import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.YamlFlagFileParser; @SuppressWarnings("javadoc") public class FlagFileParserYamlTest extends FlagFileParserTestBase { diff --git a/src/test/java/com/launchdarkly/client/integrations/HttpConfigurationBuilderTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/HttpConfigurationBuilderTest.java similarity index 90% rename from src/test/java/com/launchdarkly/client/integrations/HttpConfigurationBuilderTest.java rename to src/test/java/com/launchdarkly/sdk/server/integrations/HttpConfigurationBuilderTest.java index b9a254866..1bef491f7 100644 --- a/src/test/java/com/launchdarkly/client/integrations/HttpConfigurationBuilderTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/HttpConfigurationBuilderTest.java @@ -1,7 +1,7 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.sdk.server.integrations; -import com.launchdarkly.client.Components; -import com.launchdarkly.client.interfaces.HttpConfiguration; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; import org.junit.Test; @@ -13,6 +13,7 @@ import java.net.UnknownHostException; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; +import java.time.Duration; import javax.net.ssl.SSLSocketFactory; import javax.net.ssl.X509TrustManager; @@ -27,10 +28,10 @@ public class HttpConfigurationBuilderTest { @Test public void testDefaults() { HttpConfiguration hc = Components.httpConfiguration().createHttpConfiguration(); - assertEquals(HttpConfigurationBuilder.DEFAULT_CONNECT_TIMEOUT_MILLIS, hc.getConnectTimeoutMillis()); + assertEquals(HttpConfigurationBuilder.DEFAULT_CONNECT_TIMEOUT, hc.getConnectTimeout()); assertNull(hc.getProxy()); assertNull(hc.getProxyAuthentication()); - assertEquals(HttpConfigurationBuilder.DEFAULT_SOCKET_TIMEOUT_MILLIS, hc.getSocketTimeoutMillis()); + assertEquals(HttpConfigurationBuilder.DEFAULT_SOCKET_TIMEOUT, hc.getSocketTimeout()); assertNull(hc.getSslSocketFactory()); assertNull(hc.getTrustManager()); assertNull(hc.getWrapperIdentifier()); @@ -39,9 +40,9 @@ public void testDefaults() { @Test public void testConnectTimeout() { HttpConfiguration hc = Components.httpConfiguration() - .connectTimeoutMillis(999) + .connectTimeout(Duration.ofMillis(999)) .createHttpConfiguration(); - assertEquals(999, hc.getConnectTimeoutMillis()); + assertEquals(999, hc.getConnectTimeout().toMillis()); } @Test @@ -67,9 +68,9 @@ public void testProxyBasicAuth() { @Test public void testSocketTimeout() { HttpConfiguration hc = Components.httpConfiguration() - .socketTimeoutMillis(999) + .socketTimeout(Duration.ofMillis(999)) .createHttpConfiguration(); - assertEquals(999, hc.getSocketTimeoutMillis()); + assertEquals(999, hc.getSocketTimeout().toMillis()); } @Test diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/MockPersistentDataStore.java b/src/test/java/com/launchdarkly/sdk/server/integrations/MockPersistentDataStore.java new file mode 100644 index 000000000..f8697dbb7 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/MockPersistentDataStore.java @@ -0,0 +1,165 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.google.common.collect.ImmutableList; +import com.launchdarkly.sdk.server.DataStoreTestTypes.TestItem; +import com.launchdarkly.sdk.server.interfaces.PersistentDataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.SerializedItemDescriptor; + +import java.io.IOException; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +@SuppressWarnings("javadoc") +public final class MockPersistentDataStore implements PersistentDataStore { + public static final class MockDatabaseInstance { + Map>> dataByPrefix = new HashMap<>(); + Map initedByPrefix = new HashMap<>(); + } + + final Map> data; + final AtomicBoolean inited; + final AtomicInteger initedCount = new AtomicInteger(0); + volatile int initedQueryCount; + volatile boolean persistOnlyAsString; + volatile boolean unavailable; + volatile RuntimeException fakeError; + volatile Runnable updateHook; + + public MockPersistentDataStore() { + this.data = new HashMap<>(); + this.inited = new AtomicBoolean(); + } + + public MockPersistentDataStore(MockDatabaseInstance sharedData, String prefix) { + synchronized (sharedData) { + if (sharedData.dataByPrefix.containsKey(prefix)) { + this.data = sharedData.dataByPrefix.get(prefix); + this.inited = sharedData.initedByPrefix.get(prefix); + } else { + this.data = new HashMap<>(); + this.inited = new AtomicBoolean(); + sharedData.dataByPrefix.put(prefix, this.data); + sharedData.initedByPrefix.put(prefix, this.inited); + } + } + } + + @Override + public void close() throws IOException { + } + + @Override + public SerializedItemDescriptor get(DataKind kind, String key) { + maybeThrow(); + if (data.containsKey(kind)) { + SerializedItemDescriptor item = data.get(kind).get(key); + if (item != null) { + if (persistOnlyAsString) { + // This simulates the kind of store implementation that can't track metadata separately + return new SerializedItemDescriptor(0, false, item.getSerializedItem()); + } else { + return item; + } + } + } + return null; + } + + @Override + public KeyedItems getAll(DataKind kind) { + maybeThrow(); + return data.containsKey(kind) ? new KeyedItems<>(ImmutableList.copyOf(data.get(kind).entrySet())) : new KeyedItems<>(null); + } + + @Override + public void init(FullDataSet allData) { + initedCount.incrementAndGet(); + maybeThrow(); + data.clear(); + for (Map.Entry> entry: allData.getData()) { + DataKind kind = entry.getKey(); + HashMap items = new LinkedHashMap<>(); + for (Map.Entry e: entry.getValue().getItems()) { + items.put(e.getKey(), storableItem(kind, e.getValue())); + } + data.put(kind, items); + } + inited.set(true); + } + + @Override + public boolean upsert(DataKind kind, String key, SerializedItemDescriptor item) { + maybeThrow(); + if (updateHook != null) { + updateHook.run(); + } + if (!data.containsKey(kind)) { + data.put(kind, new HashMap<>()); + } + Map items = data.get(kind); + SerializedItemDescriptor oldItem = items.get(key); + if (oldItem != null) { + // If persistOnlyAsString is true, simulate the kind of implementation where we can't see the + // version as a separate attribute in the database and must deserialize the item to get it. + int oldVersion = persistOnlyAsString ? + kind.deserialize(oldItem.getSerializedItem()).getVersion() : + oldItem.getVersion(); + if (oldVersion >= item.getVersion()) { + return false; + } + } + items.put(key, storableItem(kind, item)); + return true; + } + + @Override + public boolean isInitialized() { + maybeThrow(); + initedQueryCount++; + return inited.get(); + } + + @Override + public boolean isStoreAvailable() { + return !unavailable; + } + + public void forceSet(DataKind kind, TestItem item) { + forceSet(kind, item.key, item.toSerializedItemDescriptor()); + } + + public void forceSet(DataKind kind, String key, SerializedItemDescriptor item) { + if (!data.containsKey(kind)) { + data.put(kind, new HashMap<>()); + } + Map items = data.get(kind); + items.put(key, storableItem(kind, item)); + } + + public void forceRemove(DataKind kind, String key) { + if (data.containsKey(kind)) { + data.get(kind).remove(key); + } + } + + private SerializedItemDescriptor storableItem(DataKind kind, SerializedItemDescriptor item) { + if (item.isDeleted() && !persistOnlyAsString) { + // This simulates the kind of store implementation that *can* track metadata separately, so we don't + // have to persist the placeholder string for deleted items + return new SerializedItemDescriptor(item.getVersion(), true, null); + } + return item; + } + + private void maybeThrow() { + if (fakeError != null) { + throw fakeError; + } + } +} \ No newline at end of file diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreGenericTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreGenericTest.java new file mode 100644 index 000000000..af4040c3a --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreGenericTest.java @@ -0,0 +1,76 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.google.common.collect.ImmutableList; + +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; + +/** + * This verifies that PersistentDataStoreTestBase behaves as expected as long as the PersistentDataStore + * implementation behaves as expected. Since there aren't any actual database integrations built into the + * SDK project, and PersistentDataStoreTestBase will be used by external projects like java-server-sdk-redis, + * we want to make sure the test logic is correct regardless of database implementation details. + * + * PersistentDataStore implementations may be able to persist the version and deleted state as metadata + * separate from the serialized item string; or they may not, in which case a little extra parsing is + * necessary. MockPersistentDataStore is able to simulate both of these scenarios, and we test both here. + */ +@SuppressWarnings("javadoc") +@RunWith(Parameterized.class) +public class PersistentDataStoreGenericTest extends PersistentDataStoreTestBase { + private final MockPersistentDataStore.MockDatabaseInstance sharedData = new MockPersistentDataStore.MockDatabaseInstance(); + private final TestMode testMode; + + static class TestMode { + final boolean persistOnlyAsString; + + TestMode(boolean persistOnlyAsString) { + this.persistOnlyAsString = persistOnlyAsString; + } + + @Override + public String toString() { + return "TestMode(" + (persistOnlyAsString ? "persistOnlyAsString" : "persistWithMetadata") + ")"; + } + } + + @Parameters(name="{0}") + public static Iterable data() { + return ImmutableList.of( + new TestMode(false), + new TestMode(true) + ); + } + + public PersistentDataStoreGenericTest(TestMode testMode) { + this.testMode = testMode; + } + + @Override + protected MockPersistentDataStore makeStore() { + return makeStoreWithPrefix(""); + } + + @Override + protected MockPersistentDataStore makeStoreWithPrefix(String prefix) { + MockPersistentDataStore store = new MockPersistentDataStore(sharedData, prefix); + store.persistOnlyAsString = testMode.persistOnlyAsString; + return store; + } + + @Override + protected void clearAllData() { + synchronized (sharedData) { + for (String prefix: sharedData.dataByPrefix.keySet()) { + sharedData.dataByPrefix.get(prefix).clear(); + } + } + } + + @Override + protected boolean setUpdateHook(MockPersistentDataStore storeUnderTest, Runnable hook) { + storeUnderTest.updateHook = hook; + return true; + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreTestBase.java b/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreTestBase.java new file mode 100644 index 000000000..df805b330 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreTestBase.java @@ -0,0 +1,353 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.launchdarkly.sdk.server.DataStoreTestTypes.DataBuilder; +import com.launchdarkly.sdk.server.DataStoreTestTypes.TestItem; +import com.launchdarkly.sdk.server.interfaces.PersistentDataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.SerializedItemDescriptor; + +import org.junit.After; +import org.junit.Assume; +import org.junit.Before; +import org.junit.Test; + +import java.util.Map; + +import static com.launchdarkly.sdk.server.DataStoreTestTypes.OTHER_TEST_ITEMS; +import static com.launchdarkly.sdk.server.DataStoreTestTypes.TEST_ITEMS; +import static com.launchdarkly.sdk.server.DataStoreTestTypes.toItemsMap; +import static com.launchdarkly.sdk.server.DataStoreTestTypes.toSerialized; +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("key1", "first", 10); + + protected TestItem item2 = new TestItem("key2", "second", 10); + + protected TestItem otherItem1 = new TestItem("key1", "other-first", 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; + } + + private void assertEqualsSerializedItem(TestItem item, SerializedItemDescriptor serializedItemDesc) { + // This allows for the fact that a PersistentDataStore may not be able to get the item version without + // deserializing it, so we allow the version to be zero. + assertEquals(item.toSerializedItemDescriptor().getSerializedItem(), serializedItemDesc.getSerializedItem()); + if (serializedItemDesc.getVersion() != 0) { + assertEquals(item.version, serializedItemDesc.getVersion()); + } + } + + private void assertEqualsDeletedItem(SerializedItemDescriptor expected, SerializedItemDescriptor serializedItemDesc) { + // As above, the PersistentDataStore may not have separate access to the version and deleted state; + // PersistentDataStoreWrapper compensates for this when it deserializes the item. + if (serializedItemDesc.getSerializedItem() == null) { + assertTrue(serializedItemDesc.isDeleted()); + assertEquals(expected.getVersion(), serializedItemDesc.getVersion()); + } else { + ItemDescriptor itemDesc = TEST_ITEMS.deserialize(serializedItemDesc.getSerializedItem()); + assertEquals(ItemDescriptor.deletedItem(expected.getVersion()), itemDesc); + } + } + + @Before + public void setup() { + store = makeStore(); + } + + @After + public void teardown() throws Exception { + store.close(); + } + + @Test + public void storeNotInitializedBeforeInit() { + clearAllData(); + assertFalse(store.isInitialized()); + } + + @Test + public void storeInitializedAfterInit() { + store.init(new DataBuilder().buildSerialized()); + assertTrue(store.isInitialized()); + } + + @Test + public void initCompletelyReplacesPreviousData() { + clearAllData(); + + FullDataSet allData = + new DataBuilder().add(TEST_ITEMS, item1, item2).add(OTHER_TEST_ITEMS, otherItem1).buildSerialized(); + store.init(allData); + + TestItem item2v2 = item2.withVersion(item2.version + 1); + allData = new DataBuilder().add(TEST_ITEMS, item2v2).add(OTHER_TEST_ITEMS).buildSerialized(); + store.init(allData); + + assertNull(store.get(TEST_ITEMS, item1.key)); + assertEqualsSerializedItem(item2v2, store.get(TEST_ITEMS, item2.key)); + assertNull(store.get(OTHER_TEST_ITEMS, otherItem1.key)); + } + + @Test + public void oneInstanceCanDetectIfAnotherInstanceHasInitializedTheStore() { + clearAllData(); + T store2 = makeStore(); + + assertFalse(store.isInitialized()); + + store2.init(new DataBuilder().add(TEST_ITEMS, item1).buildSerialized()); + + assertTrue(store.isInitialized()); + } + + @Test + public void oneInstanceCanDetectIfAnotherInstanceHasInitializedTheStoreEvenIfEmpty() { + clearAllData(); + T store2 = makeStore(); + + assertFalse(store.isInitialized()); + + store2.init(new DataBuilder().buildSerialized()); + + assertTrue(store.isInitialized()); + } + + @Test + public void getExistingItem() { + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).buildSerialized()); + assertEqualsSerializedItem(item1, store.get(TEST_ITEMS, item1.key)); + } + + @Test + public void getNonexistingItem() { + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).buildSerialized()); + assertNull(store.get(TEST_ITEMS, "biz")); + } + + @Test + public void getAll() { + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).add(OTHER_TEST_ITEMS, otherItem1).buildSerialized()); + Map items = toItemsMap(store.getAll(TEST_ITEMS)); + assertEquals(2, items.size()); + assertEqualsSerializedItem(item1, items.get(item1.key)); + assertEqualsSerializedItem(item2, items.get(item2.key)); + } + + @Test + public void getAllWithDeletedItem() { + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).buildSerialized()); + SerializedItemDescriptor deletedItem = toSerialized(TEST_ITEMS, ItemDescriptor.deletedItem(item1.version + 1)); + store.upsert(TEST_ITEMS, item1.key, deletedItem); + Map items = toItemsMap(store.getAll(TEST_ITEMS)); + assertEquals(2, items.size()); + assertEqualsSerializedItem(item2, items.get(item2.key)); + assertEqualsDeletedItem(deletedItem, items.get(item1.key)); + } + + @Test + public void upsertWithNewerVersion() { + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).buildSerialized()); + TestItem newVer = item1.withVersion(item1.version + 1).withName("modified"); + store.upsert(TEST_ITEMS, item1.key, newVer.toSerializedItemDescriptor()); + assertEqualsSerializedItem(newVer, store.get(TEST_ITEMS, item1.key)); + } + + @Test + public void upsertWithOlderVersion() { + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).buildSerialized()); + TestItem oldVer = item1.withVersion(item1.version - 1).withName("modified"); + store.upsert(TEST_ITEMS, item1.key, oldVer.toSerializedItemDescriptor()); + assertEqualsSerializedItem(item1, store.get(TEST_ITEMS, oldVer.key)); + } + + @Test + public void upsertNewItem() { + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).buildSerialized()); + TestItem newItem = new TestItem("new-name", "new-key", 99); + store.upsert(TEST_ITEMS, newItem.key, newItem.toSerializedItemDescriptor()); + assertEqualsSerializedItem(newItem, store.get(TEST_ITEMS, newItem.key)); + } + + @Test + public void deleteWithNewerVersion() { + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).buildSerialized()); + SerializedItemDescriptor deletedItem = toSerialized(TEST_ITEMS, ItemDescriptor.deletedItem(item1.version + 1)); + store.upsert(TEST_ITEMS, item1.key, deletedItem); + assertEqualsDeletedItem(deletedItem, store.get(TEST_ITEMS, item1.key)); + } + + @Test + public void deleteWithOlderVersion() { + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).buildSerialized()); + SerializedItemDescriptor deletedItem = toSerialized(TEST_ITEMS, ItemDescriptor.deletedItem(item1.version - 1)); + store.upsert(TEST_ITEMS, item1.key, deletedItem); + assertEqualsSerializedItem(item1, store.get(TEST_ITEMS, item1.key)); + } + + @Test + public void deleteUnknownItem() { + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).buildSerialized()); + SerializedItemDescriptor deletedItem = toSerialized(TEST_ITEMS, ItemDescriptor.deletedItem(11)); + store.upsert(TEST_ITEMS, "deleted-key", deletedItem); + assertEqualsDeletedItem(deletedItem, store.get(TEST_ITEMS, "deleted-key")); + } + + @Test + public void upsertOlderVersionAfterDelete() { + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).buildSerialized()); + SerializedItemDescriptor deletedItem = toSerialized(TEST_ITEMS, ItemDescriptor.deletedItem(item1.version + 1)); + store.upsert(TEST_ITEMS, item1.key, deletedItem); + store.upsert(TEST_ITEMS, item1.key, item1.toSerializedItemDescriptor()); + assertEqualsDeletedItem(deletedItem, store.get(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.upsert(TEST_ITEMS, startItem.key, startItem.withVersion(versionCounter).toSerializedItemDescriptor()); + versionCounter++; + } + } + }; + + try { + assumeTrue(setUpdateHook(store, concurrentModifier)); + + store.init(new DataBuilder().add(TEST_ITEMS, startItem).buildSerialized()); + + TestItem store1End = startItem.withVersion(store1VersionEnd); + store.upsert(TEST_ITEMS, startItem.key, store1End.toSerializedItemDescriptor()); + + SerializedItemDescriptor result = store.get(TEST_ITEMS, startItem.key); + assertEqualsSerializedItem(startItem.withVersion(store1VersionEnd), result); + } 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.upsert(TEST_ITEMS, startItem.key, startItem.withVersion(store2Version).toSerializedItemDescriptor()); + } + }; + + try { + assumeTrue(setUpdateHook(store, concurrentModifier)); + + store.init(new DataBuilder().add(TEST_ITEMS, startItem).buildSerialized()); + + TestItem store1End = startItem.withVersion(store1VersionEnd); + store.upsert(TEST_ITEMS, startItem.key, store1End.toSerializedItemDescriptor()); + + SerializedItemDescriptor result = store.get(TEST_ITEMS, startItem.key); + assertEqualsSerializedItem(startItem.withVersion(store2Version), result); + } finally { + store2.close(); + } + } + + @Test + public void storesWithDifferentPrefixAreIndependent() throws Exception { + T store1 = makeStoreWithPrefix("aaa"); + Assume.assumeNotNull(store1); + T store2 = makeStoreWithPrefix("bbb"); + clearAllData(); + + try { + assertFalse(store1.isInitialized()); + assertFalse(store2.isInitialized()); + + 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(TEST_ITEMS, item1a, item1b).buildSerialized()); + assertTrue(store1.isInitialized()); + assertFalse(store2.isInitialized()); + + store2.init(new DataBuilder().add(TEST_ITEMS, item2a, item2c).buildSerialized()); + assertTrue(store1.isInitialized()); + assertTrue(store2.isInitialized()); + + Map items1 = toItemsMap(store1.getAll(TEST_ITEMS)); + Map items2 = toItemsMap(store2.getAll(TEST_ITEMS)); + assertEquals(2, items1.size()); + assertEquals(2, items2.size()); + assertEqualsSerializedItem(item1a, items1.get(item1a.key)); + assertEqualsSerializedItem(item1b, items1.get(item1b.key)); + assertEqualsSerializedItem(item2a, items2.get(item2a.key)); + assertEqualsSerializedItem(item2c, items2.get(item2c.key)); + + assertEqualsSerializedItem(item1a, store1.get(TEST_ITEMS, item1a.key)); + assertEqualsSerializedItem(item1b, store1.get(TEST_ITEMS, item1b.key)); + assertEqualsSerializedItem(item2a, store2.get(TEST_ITEMS, item2a.key)); + assertEqualsSerializedItem(item2c, store2.get(TEST_ITEMS, item2c.key)); + } finally { + store1.close(); + store2.close(); + } + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapperTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapperTest.java new file mode 100644 index 000000000..1db918cbd --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapperTest.java @@ -0,0 +1,685 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.launchdarkly.sdk.server.DataStoreTestTypes.DataBuilder; +import com.launchdarkly.sdk.server.DataStoreTestTypes.TestItem; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.SerializedItemDescriptor; + +import org.junit.After; +import org.junit.Assert; +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.time.Duration; +import java.util.Map; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; + +import static com.launchdarkly.sdk.server.DataStoreTestTypes.TEST_ITEMS; +import static com.launchdarkly.sdk.server.DataStoreTestTypes.toDataMap; +import static com.launchdarkly.sdk.server.DataStoreTestTypes.toItemsMap; +import static com.launchdarkly.sdk.server.DataStoreTestTypes.toSerialized; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThan; +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") +@RunWith(Parameterized.class) +public class PersistentDataStoreWrapperTest { + private static final RuntimeException FAKE_ERROR = new RuntimeException("fake error"); + + private final TestMode testMode; + private final MockPersistentDataStore core; + private final PersistentDataStoreWrapper wrapper; + + static class TestMode { + final boolean cached; + final boolean cachedIndefinitely; + final boolean persistOnlyAsString; + + TestMode(boolean cached, boolean cachedIndefinitely, boolean persistOnlyAsString) { + this.cached = cached; + this.cachedIndefinitely = cachedIndefinitely; + this.persistOnlyAsString = persistOnlyAsString; + } + + boolean isCached() { + return cached; + } + + boolean isCachedWithFiniteTtl() { + return cached && !cachedIndefinitely; + } + + boolean isCachedIndefinitely() { + return cached && cachedIndefinitely; + } + + Duration getCacheTtl() { + return cached ? (cachedIndefinitely ? Duration.ofMillis(-1) : Duration.ofSeconds(30)) : Duration.ZERO; + } + + @Override + public String toString() { + return "TestMode(" + + (cached ? (cachedIndefinitely ? "CachedIndefinitely" : "Cached") : "Uncached") + + (persistOnlyAsString ? ",persistOnlyAsString" : "") + ")"; + } + } + + @Parameters(name="cached={0}") + public static Iterable data() { + return ImmutableList.of( + new TestMode(true, false, false), + new TestMode(true, false, true), + new TestMode(true, true, false), + new TestMode(true, true, true), + new TestMode(false, false, false), + new TestMode(false, false, true) + ); + } + + public PersistentDataStoreWrapperTest(TestMode testMode) { + this.testMode = testMode; + this.core = new MockPersistentDataStore(); + this.core.persistOnlyAsString = testMode.persistOnlyAsString; + this.wrapper = new PersistentDataStoreWrapper(core, testMode.getCacheTtl(), + PersistentDataStoreBuilder.StaleValuesPolicy.EVICT, false); + } + + @After + public void tearDown() throws IOException { + this.wrapper.close(); + } + + @Test + public void get() { + TestItem itemv1 = new TestItem("key", 1); + TestItem itemv2 = itemv1.withVersion(2); + + core.forceSet(TEST_ITEMS, itemv1); + assertThat(wrapper.get(TEST_ITEMS, itemv1.key), equalTo(itemv1.toItemDescriptor())); + + core.forceSet(TEST_ITEMS, itemv2); + + // if cached, we will not see the new underlying value yet + ItemDescriptor result = wrapper.get(TEST_ITEMS, itemv1.key); + ItemDescriptor expected = (testMode.isCached() ? itemv1 : itemv2).toItemDescriptor(); + assertThat(result, equalTo(expected)); + } + + @Test + public void getDeletedItem() { + String key = "key"; + + core.forceSet(TEST_ITEMS, key, toSerialized(TEST_ITEMS, ItemDescriptor.deletedItem(1))); + assertThat(wrapper.get(TEST_ITEMS, key), equalTo(ItemDescriptor.deletedItem(1))); + + TestItem itemv2 = new TestItem(key, 2); + core.forceSet(TEST_ITEMS, itemv2); + + // if cached, we will not see the new underlying value yet + ItemDescriptor result = wrapper.get(TEST_ITEMS, key); + ItemDescriptor expected = testMode.isCached() ? ItemDescriptor.deletedItem(1) : itemv2.toItemDescriptor(); + assertThat(result, equalTo(expected)); + } + + @Test + public void getMissingItem() { + String key = "key"; + + assertThat(wrapper.get(TEST_ITEMS, key), nullValue()); + + TestItem item = new TestItem(key, 1); + core.forceSet(TEST_ITEMS, item); + + // if cached, the cache can retain a null result + ItemDescriptor result = wrapper.get(TEST_ITEMS, item.key); + assertThat(result, testMode.isCached() ? nullValue(ItemDescriptor.class) : equalTo(item.toItemDescriptor())); + } + + @Test + public void cachedGetUsesValuesFromInit() { + assumeThat(testMode.isCached(), is(true)); + + TestItem item1 = new TestItem("key1", 1); + TestItem item2 = new TestItem("key2", 1); + wrapper.init(new DataBuilder().add(TEST_ITEMS, item1, item2).build()); + + core.forceRemove(TEST_ITEMS, item1.key); + + assertThat(wrapper.get(TEST_ITEMS, item1.key), equalTo(item1.toItemDescriptor())); + } + + @Test + public void getAll() { + TestItem item1 = new TestItem("key1", 1); + TestItem item2 = new TestItem("key2", 1); + + core.forceSet(TEST_ITEMS, item1); + core.forceSet(TEST_ITEMS, item2); + Map items = toItemsMap(wrapper.getAll(TEST_ITEMS)); + Map expected = ImmutableMap.of( + item1.key, item1.toItemDescriptor(), item2.key, item2.toItemDescriptor()); + assertThat(items, equalTo(expected)); + + core.forceRemove(TEST_ITEMS, item2.key); + items = toItemsMap(wrapper.getAll(TEST_ITEMS)); + if (testMode.isCached()) { + assertThat(items, equalTo(expected)); + } else { + Map expected1 = ImmutableMap.of(item1.key, item1.toItemDescriptor()); + assertThat(items, equalTo(expected1)); + } + } + + @Test + public void getAllDoesNotRemoveDeletedItems() { + String key1 = "key1", key2 = "key2"; + TestItem item1 = new TestItem(key1, 1); + + core.forceSet(TEST_ITEMS, item1); + core.forceSet(TEST_ITEMS, key2, toSerialized(TEST_ITEMS, ItemDescriptor.deletedItem(1))); + Map items = toItemsMap(wrapper.getAll(TEST_ITEMS)); + Map expected = ImmutableMap.of( + key1, item1.toItemDescriptor(), key2, ItemDescriptor.deletedItem(1)); + assertThat(items, equalTo(expected)); + } + + @Test + public void cachedAllUsesValuesFromInit() { + assumeThat(testMode.isCached(), is(true)); + + TestItem item1 = new TestItem("key1", 1); + TestItem item2 = new TestItem("key2", 1); + FullDataSet allData = new DataBuilder().add(TEST_ITEMS, item1, item2).build(); + wrapper.init(allData); + + core.forceRemove(TEST_ITEMS, item2.key); + + Map items = toItemsMap(wrapper.getAll(TEST_ITEMS)); + Map expected = toDataMap(allData).get(TEST_ITEMS); + assertThat(items, equalTo(expected)); + } + + @Test + public void cachedStoreWithFiniteTtlDoesNotUpdateCacheIfCoreInitFails() { + assumeThat(testMode.isCachedWithFiniteTtl(), is(true)); + + TestItem item = new TestItem("key", 1); + + core.fakeError = FAKE_ERROR; + try { + wrapper.init(new DataBuilder().add(TEST_ITEMS, item).build()); + fail("expected exception"); + } catch(RuntimeException e) { + assertThat(e, is(FAKE_ERROR)); + } + + core.fakeError = null; + assertThat(toItemsMap(wrapper.getAll(TEST_ITEMS)).size(), equalTo(0)); + } + + @Test + public void cachedStoreWithInfiniteTtlUpdatesCacheEvenIfCoreInitFails() { + assumeThat(testMode.isCachedIndefinitely(), is(true)); + + TestItem item = new TestItem("key", 1); + + core.fakeError = FAKE_ERROR; + try { + wrapper.init(new DataBuilder().add(TEST_ITEMS, item).build()); + fail("expected exception"); + } catch(RuntimeException e) { + assertThat(e, is(FAKE_ERROR)); + } + + core.fakeError = null; + Map expected = ImmutableMap.of(item.key, item.toItemDescriptor()); + assertThat(toItemsMap(wrapper.getAll(TEST_ITEMS)), equalTo(expected)); + } + + @Test + public void upsertSuccessful() { + TestItem itemv1 = new TestItem("key", 1); + TestItem itemv2 = itemv1.withVersion(2); + + wrapper.upsert(TEST_ITEMS, itemv1.key, itemv1.toItemDescriptor()); + assertThat(core.data.get(TEST_ITEMS).get(itemv1.key), equalTo(itemv1.toSerializedItemDescriptor())); + + wrapper.upsert(TEST_ITEMS, itemv1.key, itemv2.toItemDescriptor()); + assertThat(core.data.get(TEST_ITEMS).get(itemv1.key), equalTo(itemv2.toSerializedItemDescriptor())); + + // 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 (testMode.isCached()) { + TestItem itemv3 = itemv1.withVersion(3); + core.forceSet(TEST_ITEMS, itemv3); + } + + assertThat(wrapper.get(TEST_ITEMS, itemv1.key), equalTo(itemv2.toItemDescriptor())); + } + + @Test + public void cachedUpsertUnsuccessful() { + assumeThat(testMode.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 + // 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. + TestItem itemv1 = new TestItem("key", 1); + TestItem itemv2 = itemv1.withVersion(2); + + wrapper.upsert(TEST_ITEMS, itemv1.key, itemv2.toItemDescriptor()); + assertThat(core.data.get(TEST_ITEMS).get(itemv2.key), equalTo(itemv2.toSerializedItemDescriptor())); + + boolean success = wrapper.upsert(TEST_ITEMS, itemv1.key, itemv1.toItemDescriptor()); + assertThat(success, is(false)); + assertThat(core.data.get(TEST_ITEMS).get(itemv1.key), equalTo(itemv2.toSerializedItemDescriptor())); // value in store remains the same + + TestItem itemv3 = itemv1.withVersion(3); + core.forceSet(TEST_ITEMS, itemv3); // bypasses cache so we can verify that itemv2 is in the cache + + assertThat(wrapper.get(TEST_ITEMS, itemv1.key), equalTo(itemv2.toItemDescriptor())); + } + + @Test + public void cachedStoreWithFiniteTtlDoesNotUpdateCacheIfCoreUpdateFails() { + assumeThat(testMode.isCachedWithFiniteTtl(), is(true)); + + TestItem itemv1 = new TestItem("key", 1); + TestItem itemv2 = itemv1.withVersion(2); + + wrapper.init(new DataBuilder().add(TEST_ITEMS, itemv1).build()); + + core.fakeError = FAKE_ERROR; + try { + wrapper.upsert(TEST_ITEMS, itemv1.key, itemv2.toItemDescriptor()); + fail("expected exception"); + } catch(RuntimeException e) { + assertThat(e, is(FAKE_ERROR)); + } + core.fakeError = null; + + // cache still has old item, same as underlying store + assertThat(wrapper.get(TEST_ITEMS, itemv1.key), equalTo(itemv1.toItemDescriptor())); + } + + @Test + public void cachedStoreWithInfiniteTtlUpdatesCacheEvenIfCoreUpdateFails() { + assumeThat(testMode.isCachedIndefinitely(), is(true)); + + TestItem itemv1 = new TestItem("key", 1); + TestItem itemv2 = itemv1.withVersion(2); + + wrapper.init(new DataBuilder().add(TEST_ITEMS, itemv1).build()); + + core.fakeError = FAKE_ERROR; + try { + wrapper.upsert(TEST_ITEMS, itemv1.key, itemv2.toItemDescriptor()); + Assert.fail("expected exception"); + } catch(RuntimeException e) { + assertThat(e, is(FAKE_ERROR)); + } + core.fakeError = null; + + // underlying store has old item but cache has new item + assertThat(wrapper.get(TEST_ITEMS, itemv1.key), equalTo(itemv2.toItemDescriptor())); + } + + @Test + public void cachedStoreWithFiniteTtlRemovesCachedAllDataIfOneItemIsUpdated() { + assumeThat(testMode.isCachedWithFiniteTtl(), is(true)); + + TestItem item1v1 = new TestItem("key1", 1); + TestItem item1v2 = item1v1.withVersion(2); + TestItem item2v1 = new TestItem("key2", 1); + TestItem item2v2 = item2v1.withVersion(2); + + wrapper.init(new DataBuilder().add(TEST_ITEMS, item1v1, item2v1).build()); + wrapper.getAll(TEST_ITEMS); // now the All data is cached + + // do an upsert for item1 - this should drop the previous all() data from the cache + wrapper.upsert(TEST_ITEMS, item1v1.key, item1v2.toItemDescriptor()); + + // modify item2 directly in the underlying data + core.forceSet(TEST_ITEMS, item2v2); + + // now, all() should reread the underlying data so we see both changes + Map expected = ImmutableMap.of( + item1v1.key, item1v2.toItemDescriptor(), item2v1.key, item2v2.toItemDescriptor()); + assertThat(toItemsMap(wrapper.getAll(TEST_ITEMS)), equalTo(expected)); + } + + @Test + public void cachedStoreWithInfiniteTtlUpdatesCachedAllDataIfOneItemIsUpdated() { + assumeThat(testMode.isCachedIndefinitely(), is(true)); + + TestItem item1v1 = new TestItem("key1", 1); + TestItem item1v2 = item1v1.withVersion(2); + TestItem item2v1 = new TestItem("key2", 1); + TestItem item2v2 = item2v1.withVersion(2); + + wrapper.init(new DataBuilder().add(TEST_ITEMS, item1v1, item2v1).build()); + wrapper.getAll(TEST_ITEMS); // now the All data is cached + + // do an upsert for item1 - this should update the underlying data *and* the cached all() data + wrapper.upsert(TEST_ITEMS, item1v1.key, item1v2.toItemDescriptor()); + + // modify item2 directly in the underlying data + core.forceSet(TEST_ITEMS, item2v2); + + // now, all() should *not* reread the underlying data - we should only see the change to item1 + Map expected = ImmutableMap.of( + item1v1.key, item1v2.toItemDescriptor(), item2v1.key, item2v1.toItemDescriptor()); + assertThat(toItemsMap(wrapper.getAll(TEST_ITEMS)), equalTo(expected)); + } + + @Test + public void delete() { + TestItem itemv1 = new TestItem("key", 1); + + core.forceSet(TEST_ITEMS, itemv1); + assertThat(wrapper.get(TEST_ITEMS, itemv1.key), equalTo(itemv1.toItemDescriptor())); + + ItemDescriptor deletedItem = ItemDescriptor.deletedItem(2); + wrapper.upsert(TEST_ITEMS, itemv1.key, deletedItem); + + // some stores will persist a special placeholder string, others will store the metadata separately + SerializedItemDescriptor serializedDeletedItem = testMode.persistOnlyAsString ? + toSerialized(TEST_ITEMS, ItemDescriptor.deletedItem(deletedItem.getVersion())) : + new SerializedItemDescriptor(deletedItem.getVersion(), true, null); + assertThat(core.data.get(TEST_ITEMS).get(itemv1.key), equalTo(serializedDeletedItem)); + + // make a change that bypasses the cache + TestItem itemv3 = itemv1.withVersion(3); + core.forceSet(TEST_ITEMS, itemv3); + + ItemDescriptor result = wrapper.get(TEST_ITEMS, itemv1.key); + assertThat(result, equalTo(testMode.isCached() ? deletedItem : itemv3.toItemDescriptor())); + } + + @Test + public void initializedCallsInternalMethodOnlyIfNotAlreadyInited() { + assumeThat(testMode.isCached(), is(false)); + + assertThat(wrapper.isInitialized(), is(false)); + assertThat(core.initedQueryCount, equalTo(1)); + + core.inited.set(true); + assertThat(wrapper.isInitialized(), is(true)); + assertThat(core.initedQueryCount, equalTo(2)); + + core.inited.set(false); + assertThat(wrapper.isInitialized(), is(true)); + assertThat(core.initedQueryCount, equalTo(2)); + } + + @Test + public void initializedDoesNotCallInternalMethodAfterInitHasBeenCalled() { + assumeThat(testMode.isCached(), is(false)); + + assertThat(wrapper.isInitialized(), is(false)); + assertThat(core.initedQueryCount, equalTo(1)); + + wrapper.init(new DataBuilder().build()); + + assertThat(wrapper.isInitialized(), is(true)); + assertThat(core.initedQueryCount, equalTo(1)); + } + + @Test + public void initializedCanCacheFalseResult() throws Exception { + assumeThat(testMode.isCached(), is(true)); + + // We need to create a different object for this test so we can set a short cache TTL + try (PersistentDataStoreWrapper wrapper1 = new PersistentDataStoreWrapper(core, + Duration.ofMillis(500), PersistentDataStoreBuilder.StaleValuesPolicy.EVICT, false)) { + assertThat(wrapper1.isInitialized(), is(false)); + assertThat(core.initedQueryCount, equalTo(1)); + + core.inited.set(true); + assertThat(core.initedQueryCount, equalTo(1)); + + Thread.sleep(600); + + assertThat(wrapper1.isInitialized(), is(true)); + assertThat(core.initedQueryCount, equalTo(2)); + + // From this point on it should remain true and the method should not be called + assertThat(wrapper1.isInitialized(), is(true)); + assertThat(core.initedQueryCount, equalTo(2)); + } + } + + @Test + public void canGetCacheStats() throws Exception { + assumeThat(testMode.isCachedWithFiniteTtl(), is(true)); + + try (PersistentDataStoreWrapper w = new PersistentDataStoreWrapper(core, + Duration.ofSeconds(30), PersistentDataStoreBuilder.StaleValuesPolicy.EVICT, true)) { + DataStoreStatusProvider.CacheStats stats = w.getCacheStats(); + + assertThat(stats, equalTo(new DataStoreStatusProvider.CacheStats(0, 0, 0, 0, 0, 0))); + + // Cause a cache miss + w.get(TEST_ITEMS, "key1"); + stats = w.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(TEST_ITEMS, new TestItem("key2", 1)); + w.get(TEST_ITEMS, "key2"); // this one is a cache miss, but causes the item to be loaded and cached + w.get(TEST_ITEMS, "key2"); // now it's a cache hit + stats = w.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(TEST_ITEMS, "key3"); // cache miss -> tries to load the item -> gets an exception + fail("expected exception"); + } catch (RuntimeException e) { + assertThat(e, is((Throwable)core.fakeError)); + } + stats = w.getCacheStats(); + assertThat(stats.getHitCount(), equalTo(1L)); + assertThat(stats.getMissCount(), equalTo(3L)); + assertThat(stats.getLoadSuccessCount(), equalTo(2L)); + assertThat(stats.getLoadExceptionCount(), equalTo(1L)); + } + } + + @Test + public void statusIsOkInitially() throws Exception { + DataStoreStatusProvider.Status status = wrapper.getStoreStatus(); + assertThat(status.isAvailable(), is(true)); + assertThat(status.isRefreshNeeded(), is(false)); + } + + @Test + public void statusIsUnavailableAfterError() throws Exception { + causeStoreError(core, wrapper); + + DataStoreStatusProvider.Status status = wrapper.getStoreStatus(); + assertThat(status.isAvailable(), is(false)); + assertThat(status.isRefreshNeeded(), is(false)); + } + + @Test + public void statusListenerIsNotifiedOnFailureAndRecovery() throws Exception { + final BlockingQueue statuses = new LinkedBlockingQueue<>(); + wrapper.addStatusListener(statuses::add); + + causeStoreError(core, wrapper); + + DataStoreStatusProvider.Status status1 = statuses.take(); + assertThat(status1.isAvailable(), is(false)); + assertThat(status1.isRefreshNeeded(), is(false)); + + // Trigger another error, just to show that it will *not* publish a redundant status update since it + // is already in a failed state + causeStoreError(core, wrapper); + + // Now simulate the data store becoming OK again; the poller detects this and publishes a new status + makeStoreAvailable(core); + DataStoreStatusProvider.Status status2 = statuses.take(); + assertThat(status2.isAvailable(), is(true)); + assertThat(status2.isRefreshNeeded(), is(!testMode.isCachedIndefinitely())); + } + + @Test + public void cacheIsWrittenToStoreAfterRecoveryIfTtlIsInfinite() throws Exception { + assumeThat(testMode.isCachedIndefinitely(), is(true)); + + final BlockingQueue statuses = new LinkedBlockingQueue<>(); + wrapper.addStatusListener(statuses::add); + + TestItem item1v1 = new TestItem("key1", 1); + TestItem item1v2 = item1v1.withVersion(2); + TestItem item2 = new TestItem("key2", 1); + + wrapper.init(new DataBuilder().add(TEST_ITEMS, item1v1).build()); + + // In infinite cache mode, we do *not* expect exceptions thrown by the store to be propagated; it will + // swallow the error, but also go into polling/recovery mode. Note that even though the store rejects + // this update, it'll still be cached. + causeStoreError(core, wrapper); + try { + wrapper.upsert(TEST_ITEMS, item1v1.key, item1v2.toItemDescriptor()); + fail("expected exception"); + } catch (RuntimeException e) { + assertThat(e.getMessage(), equalTo(FAKE_ERROR.getMessage())); + } + assertThat(wrapper.get(TEST_ITEMS, item1v1.key), equalTo(item1v2.toItemDescriptor())); + + DataStoreStatusProvider.Status status1 = statuses.take(); + assertThat(status1.isAvailable(), is(false)); + assertThat(status1.isRefreshNeeded(), is(false)); + + // While the store is still down, try to update it again - the update goes into the cache + try { + wrapper.upsert(TEST_ITEMS, item2.key, item2.toItemDescriptor()); + fail("expected exception"); + } catch (RuntimeException e) { + assertThat(e.getMessage(), equalTo(FAKE_ERROR.getMessage())); + } + assertThat(wrapper.get(TEST_ITEMS, item2.key), equalTo(item2.toItemDescriptor())); + + // Verify that this update did not go into the underlying data yet + assertThat(core.data.get(TEST_ITEMS).get(item2.key), nullValue()); + + // Now simulate the store coming back up + makeStoreAvailable(core); + + // Wait for the poller to notice this and publish a new status + DataStoreStatusProvider.Status status2 = statuses.take(); + assertThat(status2.isAvailable(), is(true)); + assertThat(status2.isRefreshNeeded(), is(false)); + + // Once that has happened, the cache should have been written to the store + assertThat(core.data.get(TEST_ITEMS).get(item1v1.key), equalTo(item1v2.toSerializedItemDescriptor())); + assertThat(core.data.get(TEST_ITEMS).get(item2.key), equalTo(item2.toSerializedItemDescriptor())); + } + + @Test + public void statusRemainsUnavailableIfStoreSaysItIsAvailableButInitFails() throws Exception { + assumeThat(testMode.isCachedIndefinitely(), is(true)); + + // Most of this test is identical to cacheIsWrittenToStoreAfterRecoveryIfTtlIsInfinite() except as noted below. + + final BlockingQueue statuses = new LinkedBlockingQueue<>(); + wrapper.addStatusListener(statuses::add); + + TestItem item1v1 = new TestItem("key1", 1); + TestItem item1v2 = item1v1.withVersion(2); + TestItem item2 = new TestItem("key2", 1); + + wrapper.init(new DataBuilder().add(TEST_ITEMS, item1v1).build()); + + causeStoreError(core, wrapper); + try { + wrapper.upsert(TEST_ITEMS, item1v1.key, item1v2.toItemDescriptor()); + fail("expected exception"); + } catch (RuntimeException e) { + assertThat(e.getMessage(), equalTo(FAKE_ERROR.getMessage())); + } + assertThat(wrapper.get(TEST_ITEMS, item1v1.key), equalTo(item1v2.toItemDescriptor())); + + DataStoreStatusProvider.Status status1 = statuses.take(); + assertThat(status1.isAvailable(), is(false)); + assertThat(status1.isRefreshNeeded(), is(false)); + + // While the store is still down, try to update it again - the update goes into the cache + try { + wrapper.upsert(TEST_ITEMS, item2.key, item2.toItemDescriptor()); + fail("expected exception"); + } catch (RuntimeException e) { + assertThat(e.getMessage(), equalTo(FAKE_ERROR.getMessage())); + } + assertThat(wrapper.get(TEST_ITEMS, item2.key), equalTo(item2.toItemDescriptor())); + + // Verify that this update did not go into the underlying data yet + assertThat(core.data.get(TEST_ITEMS).get(item2.key), nullValue()); + + // Here's what is unique to this test: we are telling the store to report its status as "available", + // but *not* clearing the fake exception, so when the poller tries to write the cached data with + // init() it should fail. + core.unavailable = false; + + // We can't prove that an unwanted status transition will never happen, but we can verify that it + // does not happen within two status poll intervals. + Thread.sleep(PersistentDataStoreStatusManager.POLL_INTERVAL_MS * 2); + + assertThat(statuses.isEmpty(), is(true)); + int initedCount = core.initedCount.get(); + assertThat(initedCount, greaterThan(1)); // that is, it *tried* to do at least one init + + // Now simulate the store coming back up and actually working + core.fakeError = null; + + // Wait for the poller to notice this and publish a new status + DataStoreStatusProvider.Status status2 = statuses.take(); + assertThat(status2.isAvailable(), is(true)); + assertThat(status2.isRefreshNeeded(), is(false)); + + // Once that has happened, the cache should have been written to the store + assertThat(core.data.get(TEST_ITEMS).get(item1v1.key), equalTo(item1v2.toSerializedItemDescriptor())); + assertThat(core.data.get(TEST_ITEMS).get(item2.key), equalTo(item2.toSerializedItemDescriptor())); + assertThat(core.initedCount.get(), greaterThan(initedCount)); + } + + private void causeStoreError(MockPersistentDataStore core, PersistentDataStoreWrapper w) { + core.unavailable = true; + core.fakeError = new RuntimeException(FAKE_ERROR.getMessage()); + try { + wrapper.upsert(TEST_ITEMS, "irrelevant-key", ItemDescriptor.deletedItem(1)); + fail("expected exception"); + } catch (RuntimeException e) { + assertThat(e.getMessage(), equalTo(FAKE_ERROR.getMessage())); + } + } + + private void makeStoreAvailable(MockPersistentDataStore core) { + core.fakeError = null; + core.unavailable = false; + } +} From c08db3062a6308ba1c1fb23176335af77ad8cd18 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 5 May 2020 11:52:53 -0700 Subject: [PATCH 404/673] use shared single-thread executor for most intermittent tasks --- .../sdk/server/ClientContextImpl.java | 81 +++++-- .../launchdarkly/sdk/server/Components.java | 30 ++- .../sdk/server/DataStoreUpdatesImpl.java | 13 +- .../sdk/server/DefaultEventProcessor.java | 66 ++++-- .../sdk/server/EventBroadcasterImpl.java | 80 +++++++ .../sdk/server/FlagChangeEventPublisher.java | 58 ----- .../com/launchdarkly/sdk/server/LDClient.java | 44 +++- .../PersistentDataStoreStatusManager.java | 65 ++--- .../PersistentDataStoreWrapper.java | 35 ++- .../sdk/server/PollingProcessor.java | 21 +- .../PersistentDataStoreBuilder.java | 20 +- .../sdk/server/DataStoreUpdatesImplTest.java | 224 +++++++++--------- .../launchdarkly/sdk/server/LDClientTest.java | 8 +- .../PersistentDataStoreWrapperTest.java | 32 ++- .../sdk/server/PollingProcessorTest.java | 21 +- .../sdk/server/TestComponents.java | 8 +- .../integrations/MockPersistentDataStore.java | 16 +- 17 files changed, 451 insertions(+), 371 deletions(-) create mode 100644 src/main/java/com/launchdarkly/sdk/server/EventBroadcasterImpl.java delete mode 100644 src/main/java/com/launchdarkly/sdk/server/FlagChangeEventPublisher.java rename src/main/java/com/launchdarkly/sdk/server/{integrations => }/PersistentDataStoreStatusManager.java (59%) rename src/main/java/com/launchdarkly/sdk/server/{integrations => }/PersistentDataStoreWrapper.java (94%) rename src/test/java/com/launchdarkly/sdk/server/{integrations => }/PersistentDataStoreWrapperTest.java (97%) diff --git a/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java b/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java index eae023c78..4f45d2da0 100644 --- a/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java @@ -3,17 +3,56 @@ import com.launchdarkly.sdk.server.interfaces.ClientContext; import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; + +/** + * This is the package-private implementation of {@link ClientContext} that contains additional non-public + * SDK objects that may be used by our internal components. + *

    + * All component factories, whether they are built-in ones or custom ones from the application, receive a + * {@link ClientContext} and can access its public properties. But only our built-in ones can see the + * package-private properties, which they can do by calling {@code ClientContextImpl.get(ClientContext)} + * to make sure that what they have is really a {@code ClientContextImpl} (as opposed to some other + * implementation of {@link ClientContext}, which might have been created for instance in application + * test code). + */ final class ClientContextImpl implements ClientContext { + private static volatile ScheduledExecutorService fallbackSharedExecutor = null; + private final String sdkKey; private final HttpConfiguration httpConfiguration; private final boolean offline; - private final DiagnosticAccumulator diagnosticAccumulator; - private final DiagnosticEvent.Init diagnosticInitEvent; - - ClientContextImpl(String sdkKey, LDConfig configuration, DiagnosticAccumulator diagnosticAccumulator) { + final ScheduledExecutorService sharedExecutor; + final DiagnosticAccumulator diagnosticAccumulator; + final DiagnosticEvent.Init diagnosticInitEvent; + + private ClientContextImpl( + String sdkKey, + HttpConfiguration httpConfiguration, + boolean offline, + ScheduledExecutorService sharedExecutor, + DiagnosticAccumulator diagnosticAccumulator, + DiagnosticEvent.Init diagnosticInitEvent + ) { + this.sdkKey = sdkKey; + this.httpConfiguration = httpConfiguration; + this.offline = offline; + this.sharedExecutor = sharedExecutor; + this.diagnosticAccumulator = diagnosticAccumulator; + this.diagnosticInitEvent = diagnosticInitEvent; + } + + ClientContextImpl( + String sdkKey, + LDConfig configuration, + ScheduledExecutorService sharedExecutor, + DiagnosticAccumulator diagnosticAccumulator + ) { this.sdkKey = sdkKey; this.httpConfiguration = configuration.httpConfig; this.offline = configuration.offline; + this.sharedExecutor = sharedExecutor; if (!configuration.diagnosticOptOut && diagnosticAccumulator != null) { this.diagnosticAccumulator = diagnosticAccumulator; this.diagnosticInitEvent = new DiagnosticEvent.Init(diagnosticAccumulator.dataSinceDate, diagnosticAccumulator.diagnosticId, configuration); @@ -38,27 +77,23 @@ public HttpConfiguration getHttpConfiguration() { return httpConfiguration; } - // Note that the following two properties are package-private - they are only used by SDK internal components, - // not any custom components implemented by an application. - DiagnosticAccumulator getDiagnosticAccumulator() { - return diagnosticAccumulator; - } - - DiagnosticEvent.Init getDiagnosticInitEvent() { - return diagnosticInitEvent; - } - - static DiagnosticAccumulator getDiagnosticAccumulator(ClientContext context) { + /** + * This mechanism is a convenience for internal components to access the package-private fields of the + * context if it is a ClientContextImpl, and to receive null values for those fields if it is not. + * The latter case should only happen in application test code where the application developer has no + * way to create our package-private ClientContextImpl. In that case, we also generate a temporary + * sharedExecutor so components can work correctly in tests. + */ + static ClientContextImpl get(ClientContext context) { if (context instanceof ClientContextImpl) { - return ((ClientContextImpl)context).getDiagnosticAccumulator(); + return (ClientContextImpl)context; } - return null; - } - - static DiagnosticEvent.Init getDiagnosticInitEvent(ClientContext context) { - if (context instanceof ClientContextImpl) { - return ((ClientContextImpl)context).getDiagnosticInitEvent(); + synchronized (ClientContextImpl.class) { + if (fallbackSharedExecutor == null) { + fallbackSharedExecutor = Executors.newSingleThreadScheduledExecutor(); + } } - return null; + return new ClientContextImpl(context.getSdkKey(), context.getHttpConfiguration(), context.isOffline(), + fallbackSharedExecutor, null, null); } } diff --git a/src/main/java/com/launchdarkly/sdk/server/Components.java b/src/main/java/com/launchdarkly/sdk/server/Components.java index 6ffdd37c7..f5949939c 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Components.java +++ b/src/main/java/com/launchdarkly/sdk/server/Components.java @@ -22,6 +22,7 @@ import com.launchdarkly.sdk.server.interfaces.FlagValueChangeListener; import com.launchdarkly.sdk.server.interfaces.HttpAuthentication; import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; +import com.launchdarkly.sdk.server.interfaces.PersistentDataStore; import com.launchdarkly.sdk.server.interfaces.PersistentDataStoreFactory; import java.io.IOException; @@ -405,7 +406,7 @@ public DataSource createDataSource(ClientContext context, DataStoreUpdates dataS requestor, dataStoreUpdates, null, - ClientContextImpl.getDiagnosticAccumulator(context), + ClientContextImpl.get(context).diagnosticAccumulator, streamUri, initialReconnectDelay ); @@ -447,7 +448,12 @@ public DataSource createDataSource(ClientContext context, DataStoreUpdates dataS baseURI == null ? LDConfig.DEFAULT_BASE_URI : baseURI, true ); - return new PollingProcessor(requestor, dataStoreUpdates, pollInterval); + return new PollingProcessor( + requestor, + dataStoreUpdates, + ClientContextImpl.get(context).sharedExecutor, + pollInterval + ); } @Override @@ -488,8 +494,9 @@ public EventProcessor createEventProcessor(ClientContext context) { diagnosticRecordingInterval ), context.getHttpConfiguration(), - ClientContextImpl.getDiagnosticAccumulator(context), - ClientContextImpl.getDiagnosticInitEvent(context) + ClientContextImpl.get(context).sharedExecutor, + ClientContextImpl.get(context).diagnosticAccumulator, + ClientContextImpl.get(context).diagnosticInitEvent ); } @@ -551,5 +558,20 @@ public LDValue describeConfiguration(LDConfig config) { } return LDValue.of("custom"); } + + /** + * Called by the SDK to create the data store instance. + */ + @Override + public DataStore createDataStore(ClientContext context) { + PersistentDataStore core = persistentDataStoreFactory.createPersistentDataStore(context); + return new PersistentDataStoreWrapper( + core, + cacheTime, + staleValuesPolicy, + recordCacheStats, + ClientContextImpl.get(context).sharedExecutor + ); + } } } diff --git a/src/main/java/com/launchdarkly/sdk/server/DataStoreUpdatesImpl.java b/src/main/java/com/launchdarkly/sdk/server/DataStoreUpdatesImpl.java index 7edc3ef14..190b61462 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DataStoreUpdatesImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/DataStoreUpdatesImpl.java @@ -11,6 +11,7 @@ import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; +import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; import java.util.HashMap; import java.util.HashSet; @@ -31,13 +32,13 @@ */ final class DataStoreUpdatesImpl implements DataStoreUpdates { private final DataStore store; - private final FlagChangeEventPublisher flagChangeEventPublisher; + private final EventBroadcasterImpl flagChangeEventNotifier; private final DataModelDependencies.DependencyTracker dependencyTracker = new DataModelDependencies.DependencyTracker(); private final DataStoreStatusProvider dataStoreStatusProvider; - DataStoreUpdatesImpl(DataStore store, FlagChangeEventPublisher flagChangeEventPublisher) { + DataStoreUpdatesImpl(DataStore store, EventBroadcasterImpl flagChangeEventNotifier) { this.store = store; - this.flagChangeEventPublisher = flagChangeEventPublisher; + this.flagChangeEventNotifier = flagChangeEventNotifier; this.dataStoreStatusProvider = new DataStoreStatusProviderImpl(store); } @@ -87,16 +88,16 @@ public DataStoreStatusProvider getStatusProvider() { } private boolean hasFlagChangeEventListeners() { - return flagChangeEventPublisher != null && flagChangeEventPublisher.hasListeners(); + return flagChangeEventNotifier != null && flagChangeEventNotifier.hasListeners(); } private void sendChangeEvents(Iterable affectedItems) { - if (flagChangeEventPublisher == null) { + if (flagChangeEventNotifier == null) { return; } for (KindAndKey item: affectedItems) { if (item.kind == FEATURES) { - flagChangeEventPublisher.publishEvent(new FlagChangeEvent(item.key)); + flagChangeEventNotifier.broadcast(new FlagChangeEvent(item.key)); } } } diff --git a/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java index a814f81ea..7ab2e9ae4 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java @@ -22,7 +22,6 @@ import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.Semaphore; import java.util.concurrent.ThreadFactory; @@ -57,18 +56,28 @@ final class DefaultEventProcessor implements EventProcessor { private final AtomicBoolean closed = new AtomicBoolean(false); private volatile boolean inputCapacityExceeded = false; - DefaultEventProcessor(String sdkKey, EventsConfiguration eventsConfig, HttpConfiguration httpConfig, - DiagnosticAccumulator diagnosticAccumulator, DiagnosticEvent.Init diagnosticInitEvent) { + DefaultEventProcessor( + String sdkKey, + EventsConfiguration eventsConfig, + HttpConfiguration httpConfig, + ScheduledExecutorService sharedExecutor, + DiagnosticAccumulator diagnosticAccumulator, + DiagnosticEvent.Init diagnosticInitEvent + ) { inbox = new ArrayBlockingQueue<>(eventsConfig.capacity); - ThreadFactory threadFactory = new ThreadFactoryBuilder() - .setDaemon(true) - .setNameFormat("LaunchDarkly-EventProcessor-%d") - .setPriority(Thread.MIN_PRIORITY) - .build(); - scheduler = Executors.newSingleThreadScheduledExecutor(threadFactory); - - dispatcher = new EventDispatcher(sdkKey, eventsConfig, httpConfig, inbox, threadFactory, closed, diagnosticAccumulator, diagnosticInitEvent); + scheduler = sharedExecutor; + + dispatcher = new EventDispatcher( + sdkKey, + eventsConfig, + httpConfig, + sharedExecutor, + inbox, + closed, + diagnosticAccumulator, + diagnosticInitEvent + ); Runnable flusher = () -> { postMessageAsync(MessageType.FLUSH, null); @@ -106,7 +115,6 @@ public void flush() { @Override public void close() throws IOException { if (closed.compareAndSet(false, true)) { - scheduler.shutdown(); postMessageAsync(MessageType.FLUSH, null); postMessageAndWait(MessageType.SHUTDOWN, null); } @@ -211,21 +219,32 @@ static final class EventDispatcher { private final AtomicLong lastKnownPastTime = new AtomicLong(0); private final AtomicBoolean disabled = new AtomicBoolean(false); @VisibleForTesting final DiagnosticAccumulator diagnosticAccumulator; - private final ExecutorService diagnosticExecutor; + private final ExecutorService sharedExecutor; private final SendDiagnosticTaskFactory sendDiagnosticTaskFactory; private long deduplicatedUsers = 0; - private EventDispatcher(String sdkKey, EventsConfiguration eventsConfig, HttpConfiguration httpConfig, - final BlockingQueue inbox, - ThreadFactory threadFactory, - final AtomicBoolean closed, - DiagnosticAccumulator diagnosticAccumulator, - DiagnosticEvent.Init diagnosticInitEvent) { + private EventDispatcher( + String sdkKey, + EventsConfiguration eventsConfig, + HttpConfiguration httpConfig, + ExecutorService sharedExecutor, + final BlockingQueue inbox, + final AtomicBoolean closed, + DiagnosticAccumulator diagnosticAccumulator, + DiagnosticEvent.Init diagnosticInitEvent + ) { this.eventsConfig = eventsConfig; + this.sharedExecutor = sharedExecutor; this.diagnosticAccumulator = diagnosticAccumulator; this.busyFlushWorkersCount = new AtomicInteger(0); + ThreadFactory threadFactory = new ThreadFactoryBuilder() + .setDaemon(true) + .setNameFormat("LaunchDarkly-event-delivery-%d") + .setPriority(Thread.MIN_PRIORITY) + .build(); + OkHttpClient.Builder httpBuilder = new OkHttpClient.Builder(); configureHttpClientBuilder(httpConfig, httpBuilder); httpClient = httpBuilder.build(); @@ -274,10 +293,8 @@ public void uncaughtException(Thread t, Throwable e) { if (diagnosticAccumulator != null) { // Set up diagnostics this.sendDiagnosticTaskFactory = new SendDiagnosticTaskFactory(sdkKey, eventsConfig, httpClient, httpConfig); - diagnosticExecutor = Executors.newSingleThreadExecutor(threadFactory); - diagnosticExecutor.submit(sendDiagnosticTaskFactory.createSendDiagnosticTask(diagnosticInitEvent)); + sharedExecutor.submit(sendDiagnosticTaskFactory.createSendDiagnosticTask(diagnosticInitEvent)); } else { - diagnosticExecutor = null; sendDiagnosticTaskFactory = null; } } @@ -333,7 +350,7 @@ private void sendAndResetDiagnostics(EventBuffer outbox) { // 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)); + sharedExecutor.submit(sendDiagnosticTaskFactory.createSendDiagnosticTask(diagnosticEvent)); } private void doShutdown() { @@ -342,9 +359,6 @@ private void doShutdown() { for (SendEventsTask task: flushWorkers) { task.stop(); } - if (diagnosticExecutor != null) { - diagnosticExecutor.shutdown(); - } shutdownHttpClient(httpClient); } diff --git a/src/main/java/com/launchdarkly/sdk/server/EventBroadcasterImpl.java b/src/main/java/com/launchdarkly/sdk/server/EventBroadcasterImpl.java new file mode 100644 index 000000000..543755da5 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/EventBroadcasterImpl.java @@ -0,0 +1,80 @@ +package com.launchdarkly.sdk.server; + +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ExecutorService; +import java.util.function.BiConsumer; + +/** + * A generic mechanism for registering event listeners and broadcasting events to them. The SDK maintains an + * instance of this for each available type of listener (flag change, data store status, etc.). They are all + * intended to share a single executor service; notifications are submitted individually to this service for + * each listener. + * + * @param the listener interface class + * @param the event class + */ +final class EventBroadcasterImpl { + private final CopyOnWriteArrayList listeners = new CopyOnWriteArrayList<>(); + private final BiConsumer broadcastAction; + private final ExecutorService executor; + + /** + * Creates an instance. + * + * @param broadcastAction a lambda that calls the appropriate listener method for an event + * @param executor the executor to use for running notification tasks on a worker thread; if this + * is null (which should only be the case in test code) then broadcasting an event will be a no-op + */ + EventBroadcasterImpl(BiConsumer broadcastAction, ExecutorService executor) { + this.broadcastAction = broadcastAction; + this.executor = executor; + } + + /** + * Registers a listener for this type of event. This method is thread-safe. + * + * @param listener the listener to register + */ + void register(ListenerT listener) { + listeners.add(listener); + } + + /** + * Unregisters a listener. This method is thread-safe. + * + * @param listener the listener to unregister + */ + void unregister(ListenerT listener) { + listeners.remove(listener); + } + + /** + * Returns true if any listeners are currently registered. This method is thread-safe. + * + * @return true if there are listeners + */ + boolean hasListeners() { + return !listeners.isEmpty(); + } + + /** + * Broadcasts an event to all available listeners. + * + * @param event the event to broadcast + */ + void broadcast(EventT event) { + if (executor == null) { + return; + } + for (ListenerT l: listeners) { + executor.execute(() -> { + try { + broadcastAction.accept(l, event); + } catch (Exception e) { + LDClient.logger.warn("Unexpected error from listener ({0}): {1}", l.getClass(), e.toString()); + LDClient.logger.debug(e.toString(), e); + } + }); + } + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/FlagChangeEventPublisher.java b/src/main/java/com/launchdarkly/sdk/server/FlagChangeEventPublisher.java deleted file mode 100644 index 217174b32..000000000 --- a/src/main/java/com/launchdarkly/sdk/server/FlagChangeEventPublisher.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.launchdarkly.sdk.server; - -import com.google.common.util.concurrent.ThreadFactoryBuilder; -import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; -import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; - -import java.io.Closeable; -import java.io.IOException; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.ThreadFactory; - -final class FlagChangeEventPublisher implements Closeable { - private final CopyOnWriteArrayList listeners = new CopyOnWriteArrayList<>(); - private volatile ExecutorService executor = null; - - public void register(FlagChangeListener listener) { - listeners.add(listener); - synchronized (this) { - if (executor == null) { - executor = createExecutorService(); - } - } - } - - public void unregister(FlagChangeListener listener) { - listeners.remove(listener); - } - - public boolean hasListeners() { - return !listeners.isEmpty(); - } - - public void publishEvent(FlagChangeEvent event) { - for (FlagChangeListener l: listeners) { - executor.execute(() -> { - l.onFlagChange(event); - }); - } - } - - @Override - public void close() throws IOException { - if (executor != null) { - executor.shutdown(); - } - } - - private ExecutorService createExecutorService() { - ThreadFactory threadFactory = new ThreadFactoryBuilder() - .setDaemon(true) - .setNameFormat("LaunchDarkly-FlagChangeEventPublisher-%d") - .setPriority(Thread.MIN_PRIORITY) - .build(); - return Executors.newCachedThreadPool(threadFactory); - } -} diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClient.java b/src/main/java/com/launchdarkly/sdk/server/LDClient.java index dc1b53b18..6812daa63 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClient.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClient.java @@ -1,5 +1,6 @@ package com.launchdarkly.sdk.server; +import com.google.common.util.concurrent.ThreadFactoryBuilder; import com.launchdarkly.sdk.EvaluationDetail; import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDUser; @@ -16,6 +17,7 @@ import com.launchdarkly.sdk.server.interfaces.Event; import com.launchdarkly.sdk.server.interfaces.EventProcessor; import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; +import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; import org.apache.commons.codec.binary.Hex; @@ -28,7 +30,10 @@ import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.util.Map; +import java.util.concurrent.Executors; import java.util.concurrent.Future; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadFactory; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.jar.Attributes; @@ -54,13 +59,14 @@ public final class LDClient implements LDClientInterface { static final String CLIENT_VERSION = getClientVersion(); private final String sdkKey; + private final boolean offline; private final Evaluator evaluator; - private final FlagChangeEventPublisher flagChangeEventPublisher; final EventProcessor eventProcessor; final DataSource dataSource; final DataStore dataStore; private final DataStoreStatusProvider dataStoreStatusProvider; - private final boolean offline; + private final EventBroadcasterImpl flagChangeEventNotifier; + private final ScheduledExecutorService sharedExecutor; /** * Creates a new client instance that connects to LaunchDarkly with the default configuration. In most @@ -94,6 +100,8 @@ public LDClient(String sdkKey, LDConfig config) { this.sdkKey = checkNotNull(sdkKey, "sdkKey must not be null"); this.offline = config.offline; + this.sharedExecutor = createSharedExecutor(); + final EventProcessorFactory epFactory = config.eventProcessorFactory == null ? Components.sendEvents() : config.eventProcessorFactory; @@ -108,8 +116,12 @@ public LDClient(String sdkKey, LDConfig config) { // Do not create diagnostic accumulator if config has specified is opted out, or if we're not using the // standard event processor final boolean useDiagnostics = !config.diagnosticOptOut && epFactory instanceof EventProcessorBuilder; - final ClientContextImpl context = new ClientContextImpl(sdkKey, config, - useDiagnostics ? new DiagnosticAccumulator(new DiagnosticId(sdkKey)) : null); + final ClientContextImpl context = new ClientContextImpl( + sdkKey, + config, + sharedExecutor, + useDiagnostics ? new DiagnosticAccumulator(new DiagnosticId(sdkKey)) : null + ); this.eventProcessor = epFactory.createEventProcessor(context); @@ -128,11 +140,11 @@ public DataModel.Segment getSegment(String key) { } }); - this.flagChangeEventPublisher = new FlagChangeEventPublisher(); + this.flagChangeEventNotifier = new EventBroadcasterImpl<>(FlagChangeListener::onFlagChange, sharedExecutor); DataSourceFactory dataSourceFactory = config.dataSourceFactory == null ? Components.streamingDataSource() : config.dataSourceFactory; - DataStoreUpdates dataStoreUpdates = new DataStoreUpdatesImpl(dataStore, flagChangeEventPublisher); + DataStoreUpdates dataStoreUpdates = new DataStoreUpdatesImpl(dataStore, flagChangeEventNotifier); this.dataSource = dataSourceFactory.createDataSource(context, dataStoreUpdates); Future startFuture = dataSource.start(); @@ -397,12 +409,12 @@ private Evaluator.EvalResult evaluateInternal(String featureKey, LDUser user, LD @Override public void registerFlagChangeListener(FlagChangeListener listener) { - flagChangeEventPublisher.register(listener); + flagChangeEventNotifier.register(listener); } @Override public void unregisterFlagChangeListener(FlagChangeListener listener) { - flagChangeEventPublisher.unregister(listener); + flagChangeEventNotifier.unregister(listener); } @Override @@ -416,7 +428,7 @@ public void close() throws IOException { this.dataStore.close(); this.eventProcessor.close(); this.dataSource.close(); - this.flagChangeEventPublisher.close(); + this.sharedExecutor.shutdownNow(); } @Override @@ -454,6 +466,20 @@ public String version() { return CLIENT_VERSION; } + // This executor is used for a variety of SDK tasks such as flag change events, checking the data store + // status after an outage, and the poll task in polling mode. These are all tasks that we do not expect + // to be executing frequently so that it is acceptable to use a single thread to execute them one at a + // time rather than a thread pool, thus reducing the number of threads spawned by the SDK. This also + // has the benefit of producing predictable delivery order for event listener notifications. + private ScheduledExecutorService createSharedExecutor() { + ThreadFactory threadFactory = new ThreadFactoryBuilder() + .setDaemon(true) + .setNameFormat("LaunchDarkly-tasks-%d") + .setPriority(Thread.MIN_PRIORITY) + .build(); + return Executors.newSingleThreadScheduledExecutor(threadFactory); + } + private static String getClientVersion() { Class clazz = LDConfig.class; String className = clazz.getSimpleName() + ".class"; diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreStatusManager.java b/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreStatusManager.java similarity index 59% rename from src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreStatusManager.java rename to src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreStatusManager.java index a59528420..1ad99f34b 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreStatusManager.java +++ b/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreStatusManager.java @@ -1,6 +1,5 @@ -package com.launchdarkly.sdk.server.integrations; +package com.launchdarkly.sdk.server; -import com.google.common.util.concurrent.ThreadFactoryBuilder; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider.Status; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider.StatusListener; @@ -8,13 +7,9 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.ArrayList; -import java.util.List; import java.util.concurrent.Callable; -import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.ThreadFactory; import java.util.concurrent.TimeUnit; /** @@ -27,42 +22,43 @@ final class PersistentDataStoreStatusManager { private static final Logger logger = LoggerFactory.getLogger(PersistentDataStoreStatusManager.class); static final int POLL_INTERVAL_MS = 500; // visible for testing - private final List listeners = new ArrayList<>(); private final ScheduledExecutorService scheduler; + private final EventBroadcasterImpl statusBroadcaster; private final Callable statusPollFn; private final boolean refreshOnRecovery; private volatile boolean lastAvailable; private volatile ScheduledFuture pollerFuture; - PersistentDataStoreStatusManager(boolean refreshOnRecovery, boolean availableNow, Callable statusPollFn) { + PersistentDataStoreStatusManager( + boolean refreshOnRecovery, + boolean availableNow, + Callable statusPollFn, + ScheduledExecutorService sharedExecutor + ) { this.refreshOnRecovery = refreshOnRecovery; this.lastAvailable = availableNow; this.statusPollFn = statusPollFn; - - ThreadFactory threadFactory = new ThreadFactoryBuilder() - .setNameFormat("LaunchDarkly-DataStoreStatusManager-%d") - .build(); - scheduler = Executors.newSingleThreadScheduledExecutor(threadFactory); - // Using newSingleThreadScheduledExecutor avoids ambiguity about execution order if we might have - // have a StatusNotificationTask happening soon after another one. + this.scheduler = sharedExecutor; + this.statusBroadcaster = new EventBroadcasterImpl<>( + DataStoreStatusProvider.StatusListener::dataStoreStatusChanged, + sharedExecutor + ); } - synchronized void addStatusListener(StatusListener listener) { - listeners.add(listener); + void addStatusListener(StatusListener listener) { + statusBroadcaster.register(listener); } synchronized void removeStatusListener(StatusListener listener) { - listeners.remove(listener); + statusBroadcaster.unregister(listener); } void updateAvailability(boolean available) { - StatusListener[] copyOfListeners = null; synchronized (this) { if (lastAvailable == available) { return; } lastAvailable = available; - copyOfListeners = listeners.toArray(new StatusListener[listeners.size()]); } Status status = new Status(available, available && refreshOnRecovery); @@ -71,10 +67,7 @@ void updateAvailability(boolean available) { logger.warn("Persistent store is available again"); } - // Notify all the subscribers (on a worker thread, so we can't be blocked by a slow listener). - if (copyOfListeners.length > 0) { - scheduler.schedule(new StatusNotificationTask(status, copyOfListeners), 0, TimeUnit.MILLISECONDS); - } + statusBroadcaster.broadcast(status); // If the store has just become unavailable, start a poller to detect when it comes back. If it has // become available, stop any polling we are currently doing. @@ -111,28 +104,4 @@ public void run() { synchronized boolean isAvailable() { return lastAvailable; } - - void close() { - scheduler.shutdown(); - } - - private static final class StatusNotificationTask implements Runnable { - private final Status status; - private final StatusListener[] listeners; - - StatusNotificationTask(Status status, StatusListener[] listeners) { - this.status = status; - this.listeners = listeners; - } - - public void run() { - for (StatusListener listener: listeners) { - try { - listener.dataStoreStatusChanged(status); - } catch (Exception e) { - logger.error("Unexpected error from StatusListener: {0}", e); - } - } - } - } } diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapper.java b/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapper.java similarity index 94% rename from src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapper.java rename to src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapper.java index f5868ee3a..4ee659b44 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapper.java +++ b/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapper.java @@ -1,4 +1,4 @@ -package com.launchdarkly.sdk.server.integrations; +package com.launchdarkly.sdk.server; import com.google.common.base.Optional; import com.google.common.cache.CacheBuilder; @@ -7,8 +7,8 @@ import com.google.common.collect.ImmutableList; import com.google.common.util.concurrent.ListeningExecutorService; import com.google.common.util.concurrent.MoreExecutors; -import com.google.common.util.concurrent.ThreadFactoryBuilder; import com.google.common.util.concurrent.UncheckedExecutionException; +import com.launchdarkly.sdk.server.integrations.PersistentDataStoreBuilder; import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; @@ -28,9 +28,7 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.atomic.AtomicBoolean; import static com.google.common.collect.Iterables.concat; @@ -47,7 +45,6 @@ */ final class PersistentDataStoreWrapper implements DataStore, DataStoreStatusProvider { private static final Logger logger = LoggerFactory.getLogger(PersistentDataStoreWrapper.class); - private static final String CACHE_REFRESH_THREAD_POOL_NAME_FORMAT = "CachingStoreWrapper-refresher-pool-%d"; private final PersistentDataStore core; private final LoadingCache> itemCache; @@ -57,13 +54,14 @@ final class PersistentDataStoreWrapper implements DataStore, DataStoreStatusProv private final boolean cacheIndefinitely; private final Set cachedDataKinds = new HashSet<>(); // this map is used in pollForAvailability() private final AtomicBoolean inited = new AtomicBoolean(false); - private final ListeningExecutorService executorService; + private final ListeningExecutorService cacheExecutor; PersistentDataStoreWrapper( final PersistentDataStore core, Duration cacheTtl, PersistentDataStoreBuilder.StaleValuesPolicy staleValuesPolicy, - boolean recordCacheStats + boolean recordCacheStats, + ScheduledExecutorService sharedExecutor ) { this.core = core; @@ -71,7 +69,7 @@ final class PersistentDataStoreWrapper implements DataStore, DataStoreStatusProv itemCache = null; allCache = null; initCache = null; - executorService = null; + cacheExecutor = null; cacheIndefinitely = false; } else { cacheIndefinitely = cacheTtl.isNegative(); @@ -95,22 +93,25 @@ public Boolean load(String key) throws Exception { }; if (staleValuesPolicy == PersistentDataStoreBuilder.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); + cacheExecutor = MoreExecutors.listeningDecorator(sharedExecutor); // 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); + itemLoader = CacheLoader.asyncReloading(itemLoader, cacheExecutor); } else { - executorService = null; + cacheExecutor = null; } itemCache = newCacheBuilder(cacheTtl, staleValuesPolicy, recordCacheStats).build(itemLoader); allCache = newCacheBuilder(cacheTtl, staleValuesPolicy, recordCacheStats).build(allLoader); initCache = newCacheBuilder(cacheTtl, staleValuesPolicy, recordCacheStats).build(initLoader); } - statusManager = new PersistentDataStoreStatusManager(!cacheIndefinitely, true, this::pollAvailabilityAfterOutage); + statusManager = new PersistentDataStoreStatusManager( + !cacheIndefinitely, + true, + this::pollAvailabilityAfterOutage, + sharedExecutor + ); } private static CacheBuilder newCacheBuilder( @@ -139,10 +140,6 @@ private static CacheBuilder newCacheBuilder( @Override public void close() throws IOException { - if (executorService != null) { - executorService.shutdownNow(); - } - statusManager.close(); core.close(); } diff --git a/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java b/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java index 6bb689245..3ebb084b7 100644 --- a/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java @@ -1,7 +1,6 @@ package com.launchdarkly.sdk.server; import com.google.common.annotations.VisibleForTesting; -import com.google.common.util.concurrent.ThreadFactoryBuilder; import com.launchdarkly.sdk.server.interfaces.DataSource; import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; import com.launchdarkly.sdk.server.interfaces.SerializationException; @@ -12,10 +11,8 @@ import java.io.IOException; import java.time.Duration; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ThreadFactory; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -27,13 +24,19 @@ final class PollingProcessor implements DataSource { @VisibleForTesting final FeatureRequestor requestor; private final DataStoreUpdates dataStoreUpdates; + private final ScheduledExecutorService scheduler; @VisibleForTesting final Duration pollInterval; private AtomicBoolean initialized = new AtomicBoolean(false); - private ScheduledExecutorService scheduler = null; - PollingProcessor(FeatureRequestor requestor, DataStoreUpdates dataStoreUpdates, Duration pollInterval) { + PollingProcessor( + FeatureRequestor requestor, + DataStoreUpdates dataStoreUpdates, + ScheduledExecutorService sharedExecutor, + Duration pollInterval + ) { this.requestor = requestor; // note that HTTP configuration is applied to the requestor when it is created this.dataStoreUpdates = dataStoreUpdates; + this.scheduler = sharedExecutor; this.pollInterval = pollInterval; } @@ -45,9 +48,6 @@ public boolean isInitialized() { @Override public void close() throws IOException { logger.info("Closing LaunchDarkly PollingProcessor"); - if (scheduler != null) { - scheduler.shutdown(); - } requestor.close(); } @@ -56,10 +56,6 @@ public Future start() { logger.info("Starting LaunchDarkly polling client with interval: " + pollInterval.toMillis() + " milliseconds"); final CompletableFuture initFuture = new CompletableFuture<>(); - ThreadFactory threadFactory = new ThreadFactoryBuilder() - .setNameFormat("LaunchDarkly-PollingProcessor-%d") - .build(); - scheduler = Executors.newScheduledThreadPool(1, threadFactory); scheduler.scheduleAtFixedRate(() -> { try { @@ -72,7 +68,6 @@ public Future start() { } catch (HttpErrorException e) { logger.error(httpErrorMessage(e.getStatus(), "polling request", "will retry")); if (!isHttpErrorRecoverable(e.getStatus())) { - scheduler.shutdown(); initFuture.complete(null); // if client is initializing, make it stop waiting; has no effect if already inited } } catch (IOException e) { diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreBuilder.java b/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreBuilder.java index d80e825e6..c33235afe 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreBuilder.java @@ -1,11 +1,8 @@ package com.launchdarkly.sdk.server.integrations; import com.launchdarkly.sdk.server.Components; -import com.launchdarkly.sdk.server.interfaces.ClientContext; -import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; -import com.launchdarkly.sdk.server.interfaces.PersistentDataStore; import com.launchdarkly.sdk.server.interfaces.PersistentDataStoreFactory; import java.time.Duration; @@ -45,10 +42,10 @@ public abstract class PersistentDataStoreBuilder implements DataStoreFactory { */ public static final Duration DEFAULT_CACHE_TTL = Duration.ofSeconds(15); - protected final PersistentDataStoreFactory persistentDataStoreFactory; // see Components for why this is not private - private Duration cacheTime = DEFAULT_CACHE_TTL; - private StaleValuesPolicy staleValuesPolicy = StaleValuesPolicy.EVICT; - private boolean recordCacheStats = false; + protected final PersistentDataStoreFactory persistentDataStoreFactory; // see Components for why these are not private + protected Duration cacheTime = DEFAULT_CACHE_TTL; + protected StaleValuesPolicy staleValuesPolicy = StaleValuesPolicy.EVICT; + protected boolean recordCacheStats = false; /** * Possible values for {@link #staleValuesPolicy(StaleValuesPolicy)}. @@ -196,13 +193,4 @@ public PersistentDataStoreBuilder recordCacheStats(boolean recordCacheStats) { this.recordCacheStats = recordCacheStats; return this; } - - /** - * Called by the SDK to create the data store instance. - */ - @Override - public DataStore createDataStore(ClientContext context) { - PersistentDataStore core = persistentDataStoreFactory.createPersistentDataStore(context); - return new PersistentDataStoreWrapper(core, cacheTime, staleValuesPolicy, recordCacheStats); - } } diff --git a/src/test/java/com/launchdarkly/sdk/server/DataStoreUpdatesImplTest.java b/src/test/java/com/launchdarkly/sdk/server/DataStoreUpdatesImplTest.java index 4f041624c..d98b083a5 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataStoreUpdatesImplTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataStoreUpdatesImplTest.java @@ -11,6 +11,8 @@ import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; +import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; import org.easymock.Capture; import org.easymock.EasyMock; @@ -19,6 +21,8 @@ import java.util.List; import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import static com.google.common.collect.Iterables.transform; import static com.launchdarkly.sdk.server.DataModel.FEATURES; @@ -38,6 +42,14 @@ public class DataStoreUpdatesImplTest extends EasyMockSupport { // Note that these tests must use the actual data model types for flags and segments, rather than the // TestItem type from DataStoreTestTypes, because the dependency behavior is based on the real data model. + private ExecutorService executorService = Executors.newSingleThreadExecutor(); + private EventBroadcasterImpl flagChangeBroadcaster = + new EventBroadcasterImpl<>(FlagChangeListener::onFlagChange, executorService); + + public void tearDown() { + executorService.shutdown(); + } + @Test public void doesNotTryToSendEventsIfThereIsNoEventPublisher() { DataStore store = inMemoryDataStore(); @@ -55,22 +67,20 @@ public void sendsEventsOnInitForNewlyAddedFlags() throws Exception { .addAny(SEGMENTS, segmentBuilder("segment1").version(1).build()); - try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { - DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, flagChangeBroadcaster); - storeUpdates.init(builder.build()); + storeUpdates.init(builder.build()); - FlagChangeEventSink eventSink = new FlagChangeEventSink(); - publisher.register(eventSink); - - builder.addAny(FEATURES, flagBuilder("flag2").version(1).build()) - .addAny(SEGMENTS, segmentBuilder("segment2").version(1).build()); - // the new segment triggers no events since nothing is using it - - storeUpdates.init(builder.build()); - - eventSink.expectEvents("flag2"); - } + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + flagChangeBroadcaster.register(eventSink); + + builder.addAny(FEATURES, flagBuilder("flag2").version(1).build()) + .addAny(SEGMENTS, segmentBuilder("segment2").version(1).build()); + // the new segment triggers no events since nothing is using it + + storeUpdates.init(builder.build()); + + eventSink.expectEvents("flag2"); } @Test @@ -82,18 +92,16 @@ public void sendsEventOnUpdateForNewlyAddedFlag() throws Exception { .addAny(SEGMENTS, segmentBuilder("segment1").version(1).build()); - try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { - DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, flagChangeBroadcaster); - storeUpdates.init(builder.build()); - - FlagChangeEventSink eventSink = new FlagChangeEventSink(); - publisher.register(eventSink); - - storeUpdates.upsert(FEATURES, "flag2", new ItemDescriptor(1, flagBuilder("flag2").version(1).build())); - - eventSink.expectEvents("flag2"); - } + storeUpdates.init(builder.build()); + + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + flagChangeBroadcaster.register(eventSink); + + storeUpdates.upsert(FEATURES, "flag2", new ItemDescriptor(1, flagBuilder("flag2").version(1).build())); + + eventSink.expectEvents("flag2"); } @Test @@ -107,20 +115,18 @@ public void sendsEventsOnInitForUpdatedFlags() throws Exception { segmentBuilder("segment1").version(1).build(), segmentBuilder("segment2").version(1).build()); - try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { - DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, flagChangeBroadcaster); - storeUpdates.init(builder.build()); + storeUpdates.init(builder.build()); - FlagChangeEventSink eventSink = new FlagChangeEventSink(); - publisher.register(eventSink); - - builder.addAny(FEATURES, flagBuilder("flag2").version(2).build()) // modified flag - .addAny(SEGMENTS, segmentBuilder("segment2").version(2).build()); // modified segment, but it's irrelevant - storeUpdates.init(builder.build()); - - eventSink.expectEvents("flag2"); - } + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + flagChangeBroadcaster.register(eventSink); + + builder.addAny(FEATURES, flagBuilder("flag2").version(2).build()) // modified flag + .addAny(SEGMENTS, segmentBuilder("segment2").version(2).build()); // modified segment, but it's irrelevant + storeUpdates.init(builder.build()); + + eventSink.expectEvents("flag2"); } @Test @@ -133,18 +139,16 @@ public void sendsEventOnUpdateForUpdatedFlag() throws Exception { .addAny(SEGMENTS, segmentBuilder("segment1").version(1).build()); - try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { - DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, flagChangeBroadcaster); - storeUpdates.init(builder.build()); + storeUpdates.init(builder.build()); - FlagChangeEventSink eventSink = new FlagChangeEventSink(); - publisher.register(eventSink); - - storeUpdates.upsert(FEATURES, "flag2", new ItemDescriptor(2, flagBuilder("flag2").version(2).build())); + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + flagChangeBroadcaster.register(eventSink); - eventSink.expectEvents("flag2"); - } + storeUpdates.upsert(FEATURES, "flag2", new ItemDescriptor(2, flagBuilder("flag2").version(2).build())); + + eventSink.expectEvents("flag2"); } @Test @@ -157,22 +161,20 @@ public void sendsEventsOnInitForDeletedFlags() throws Exception { .addAny(SEGMENTS, segmentBuilder("segment1").version(1).build()); - try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { - DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, flagChangeBroadcaster); - storeUpdates.init(builder.build()); + storeUpdates.init(builder.build()); - FlagChangeEventSink eventSink = new FlagChangeEventSink(); - publisher.register(eventSink); - - builder.remove(FEATURES, "flag2"); - builder.remove(SEGMENTS, "segment1"); // deleted segment isn't being used so it's irrelevant - // note that the full data set for init() will never include deleted item placeholders - - storeUpdates.init(builder.build()); - - eventSink.expectEvents("flag2"); - } + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + flagChangeBroadcaster.register(eventSink); + + builder.remove(FEATURES, "flag2"); + builder.remove(SEGMENTS, "segment1"); // deleted segment isn't being used so it's irrelevant + // note that the full data set for init() will never include deleted item placeholders + + storeUpdates.init(builder.build()); + + eventSink.expectEvents("flag2"); } @Test @@ -185,18 +187,16 @@ public void sendsEventOnUpdateForDeletedFlag() throws Exception { .addAny(SEGMENTS, segmentBuilder("segment1").version(1).build()); - try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { - DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, flagChangeBroadcaster); - storeUpdates.init(builder.build()); - - FlagChangeEventSink eventSink = new FlagChangeEventSink(); - publisher.register(eventSink); - - storeUpdates.upsert(FEATURES, "flag2", ItemDescriptor.deletedItem(2)); - - eventSink.expectEvents("flag2"); - } + storeUpdates.init(builder.build()); + + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + flagChangeBroadcaster.register(eventSink); + + storeUpdates.upsert(FEATURES, "flag2", ItemDescriptor.deletedItem(2)); + + eventSink.expectEvents("flag2"); } @Test @@ -211,19 +211,17 @@ public void sendsEventsOnInitForFlagsWhosePrerequisitesChanged() throws Exceptio flagBuilder("flag5").version(1).prerequisites(prerequisite("flag4", 0)).build(), flagBuilder("flag6").version(1).build()); - try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { - DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); - - storeUpdates.init(builder.build()); - - FlagChangeEventSink eventSink = new FlagChangeEventSink(); - publisher.register(eventSink); + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, flagChangeBroadcaster); - builder.addAny(FEATURES, flagBuilder("flag1").version(2).build()); - storeUpdates.init(builder.build()); + storeUpdates.init(builder.build()); - eventSink.expectEvents("flag1", "flag2", "flag4", "flag5"); - } + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + flagChangeBroadcaster.register(eventSink); + + builder.addAny(FEATURES, flagBuilder("flag1").version(2).build()); + storeUpdates.init(builder.build()); + + eventSink.expectEvents("flag1", "flag2", "flag4", "flag5"); } @Test @@ -238,18 +236,16 @@ public void sendsEventsOnUpdateForFlagsWhosePrerequisitesChanged() throws Except flagBuilder("flag5").version(1).prerequisites(prerequisite("flag4", 0)).build(), flagBuilder("flag6").version(1).build()); - try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { - DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, flagChangeBroadcaster); - storeUpdates.init(builder.build()); - - FlagChangeEventSink eventSink = new FlagChangeEventSink(); - publisher.register(eventSink); - - storeUpdates.upsert(FEATURES, "flag1", new ItemDescriptor(2, flagBuilder("flag1").version(2).build())); - - eventSink.expectEvents("flag1", "flag2", "flag4", "flag5"); - } + storeUpdates.init(builder.build()); + + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + flagChangeBroadcaster.register(eventSink); + + storeUpdates.upsert(FEATURES, "flag1", new ItemDescriptor(2, flagBuilder("flag1").version(2).build())); + + eventSink.expectEvents("flag1", "flag2", "flag4", "flag5"); } @Test @@ -269,18 +265,16 @@ public void sendsEventsOnInitForFlagsWhoseSegmentsChanged() throws Exception { segmentBuilder("segment1").version(1).build(), segmentBuilder("segment2").version(1).build()); - try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { - DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, flagChangeBroadcaster); - storeUpdates.init(builder.build()); - - FlagChangeEventSink eventSink = new FlagChangeEventSink(); - publisher.register(eventSink); - - storeUpdates.upsert(SEGMENTS, "segment1", new ItemDescriptor(2, segmentBuilder("segment1").version(2).build())); - - eventSink.expectEvents("flag2", "flag4"); - } + storeUpdates.init(builder.build()); + + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + flagChangeBroadcaster.register(eventSink); + + storeUpdates.upsert(SEGMENTS, "segment1", new ItemDescriptor(2, segmentBuilder("segment1").version(2).build())); + + eventSink.expectEvents("flag2", "flag4"); } @Test @@ -300,19 +294,17 @@ public void sendsEventsOnUpdateForFlagsWhoseSegmentsChanged() throws Exception { segmentBuilder("segment1").version(1).build(), segmentBuilder("segment2").version(1).build()); - try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { - DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, flagChangeBroadcaster); - storeUpdates.init(builder.build()); - - FlagChangeEventSink eventSink = new FlagChangeEventSink(); - publisher.register(eventSink); - - builder.addAny(SEGMENTS, segmentBuilder("segment1").version(2).build()); - storeUpdates.init(builder.build()); - - eventSink.expectEvents("flag2", "flag4"); - } + storeUpdates.init(builder.build()); + + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + flagChangeBroadcaster.register(eventSink); + + builder.addAny(SEGMENTS, segmentBuilder("segment1").version(2).build()); + storeUpdates.init(builder.build()); + + eventSink.expectEvents("flag2", "flag4"); } @Test diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java index d8440d7a2..72f326944 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java @@ -177,7 +177,7 @@ public void sameDiagnosticAccumulatorPassedToFactoriesWhenSupported() throws IOE verifyAll(); DiagnosticAccumulator acc = ((DefaultEventProcessor)client.eventProcessor).dispatcher.diagnosticAccumulator; assertNotNull(acc); - assertSame(acc, ClientContextImpl.getDiagnosticAccumulator(capturedDataSourceContext.getValue())); + assertSame(acc, ClientContextImpl.get(capturedDataSourceContext.getValue()).diagnosticAccumulator); } } @@ -200,7 +200,7 @@ public void nullDiagnosticAccumulatorPassedToFactoriesWhenOptedOut() throws IOEx try (LDClient client = new LDClient(SDK_KEY, config)) { verifyAll(); assertNull(((DefaultEventProcessor)client.eventProcessor).dispatcher.diagnosticAccumulator); - assertNull(ClientContextImpl.getDiagnosticAccumulator(capturedDataSourceContext.getValue())); + assertNull(ClientContextImpl.get(capturedDataSourceContext.getValue()).diagnosticAccumulator); } } @@ -228,8 +228,8 @@ public void nullDiagnosticAccumulatorPassedToUpdateFactoryWhenEventProcessorDoes try (LDClient client = new LDClient(SDK_KEY, config)) { verifyAll(); - assertNull(ClientContextImpl.getDiagnosticAccumulator(capturedEventContext.getValue())); - assertNull(ClientContextImpl.getDiagnosticAccumulator(capturedDataSourceContext.getValue())); + assertNull(ClientContextImpl.get(capturedEventContext.getValue()).diagnosticAccumulator); + assertNull(ClientContextImpl.get(capturedDataSourceContext.getValue()).diagnosticAccumulator); } } diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapperTest.java b/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperTest.java similarity index 97% rename from src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapperTest.java rename to src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperTest.java index 1db918cbd..306a6babc 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapperTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperTest.java @@ -1,9 +1,11 @@ -package com.launchdarkly.sdk.server.integrations; +package com.launchdarkly.sdk.server; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.launchdarkly.sdk.server.DataStoreTestTypes.DataBuilder; import com.launchdarkly.sdk.server.DataStoreTestTypes.TestItem; +import com.launchdarkly.sdk.server.integrations.MockPersistentDataStore; +import com.launchdarkly.sdk.server.integrations.PersistentDataStoreBuilder; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; @@ -26,6 +28,7 @@ import static com.launchdarkly.sdk.server.DataStoreTestTypes.toDataMap; import static com.launchdarkly.sdk.server.DataStoreTestTypes.toItemsMap; import static com.launchdarkly.sdk.server.DataStoreTestTypes.toSerialized; +import static com.launchdarkly.sdk.server.TestComponents.sharedExecutor; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.greaterThan; @@ -94,8 +97,13 @@ public PersistentDataStoreWrapperTest(TestMode testMode) { this.testMode = testMode; this.core = new MockPersistentDataStore(); this.core.persistOnlyAsString = testMode.persistOnlyAsString; - this.wrapper = new PersistentDataStoreWrapper(core, testMode.getCacheTtl(), - PersistentDataStoreBuilder.StaleValuesPolicy.EVICT, false); + this.wrapper = new PersistentDataStoreWrapper( + core, + testMode.getCacheTtl(), + PersistentDataStoreBuilder.StaleValuesPolicy.EVICT, + false, + sharedExecutor + ); } @After @@ -445,8 +453,13 @@ public void initializedCanCacheFalseResult() throws Exception { assumeThat(testMode.isCached(), is(true)); // We need to create a different object for this test so we can set a short cache TTL - try (PersistentDataStoreWrapper wrapper1 = new PersistentDataStoreWrapper(core, - Duration.ofMillis(500), PersistentDataStoreBuilder.StaleValuesPolicy.EVICT, false)) { + try (PersistentDataStoreWrapper wrapper1 = new PersistentDataStoreWrapper( + core, + Duration.ofMillis(500), + PersistentDataStoreBuilder.StaleValuesPolicy.EVICT, + false, + sharedExecutor + )) { assertThat(wrapper1.isInitialized(), is(false)); assertThat(core.initedQueryCount, equalTo(1)); @@ -468,8 +481,13 @@ public void initializedCanCacheFalseResult() throws Exception { public void canGetCacheStats() throws Exception { assumeThat(testMode.isCachedWithFiniteTtl(), is(true)); - try (PersistentDataStoreWrapper w = new PersistentDataStoreWrapper(core, - Duration.ofSeconds(30), PersistentDataStoreBuilder.StaleValuesPolicy.EVICT, true)) { + try (PersistentDataStoreWrapper w = new PersistentDataStoreWrapper( + core, + Duration.ofSeconds(30), + PersistentDataStoreBuilder.StaleValuesPolicy.EVICT, + true, + sharedExecutor + )) { DataStoreStatusProvider.CacheStats stats = w.getCacheStats(); assertThat(stats, equalTo(new DataStoreStatusProvider.CacheStats(0, 0, 0, 0, 0, 0))); diff --git a/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java index b43a2e2c5..9918b5318 100644 --- a/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java @@ -1,13 +1,5 @@ package com.launchdarkly.sdk.server; -import com.launchdarkly.sdk.server.Components; -import com.launchdarkly.sdk.server.DataModel; -import com.launchdarkly.sdk.server.DefaultFeatureRequestor; -import com.launchdarkly.sdk.server.FeatureRequestor; -import com.launchdarkly.sdk.server.HttpErrorException; -import com.launchdarkly.sdk.server.InMemoryDataStore; -import com.launchdarkly.sdk.server.LDConfig; -import com.launchdarkly.sdk.server.PollingProcessor; import com.launchdarkly.sdk.server.integrations.PollingDataSourceBuilder; import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; import com.launchdarkly.sdk.server.interfaces.DataStore; @@ -24,6 +16,7 @@ import static com.launchdarkly.sdk.server.TestComponents.clientContext; import static com.launchdarkly.sdk.server.TestComponents.dataStoreUpdates; +import static com.launchdarkly.sdk.server.TestComponents.sharedExecutor; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.junit.Assert.assertFalse; @@ -35,6 +28,10 @@ public class PollingProcessorTest { private static final String SDK_KEY = "sdk-key"; private static final Duration LENGTHY_INTERVAL = Duration.ofSeconds(60); + private PollingProcessor makeProcessor(FeatureRequestor requestor, DataStore store) { + return new PollingProcessor(requestor, dataStoreUpdates(store), sharedExecutor, LENGTHY_INTERVAL); + } + @Test public void builderHasDefaultConfiguration() throws Exception { DataSourceFactory f = Components.pollingDataSource(); @@ -62,7 +59,7 @@ public void testConnectionOk() throws Exception { requestor.allData = new FeatureRequestor.AllData(new HashMap<>(), new HashMap<>()); DataStore store = new InMemoryDataStore(); - try (PollingProcessor pollingProcessor = new PollingProcessor(requestor, dataStoreUpdates(store), LENGTHY_INTERVAL)) { + try (PollingProcessor pollingProcessor = makeProcessor(requestor, store)) { Future initFuture = pollingProcessor.start(); initFuture.get(1000, TimeUnit.MILLISECONDS); assertTrue(pollingProcessor.isInitialized()); @@ -76,7 +73,7 @@ public void testConnectionProblem() throws Exception { requestor.ioException = new IOException("This exception is part of a test and yes you should be seeing it."); DataStore store = new InMemoryDataStore(); - try (PollingProcessor pollingProcessor = new PollingProcessor(requestor, dataStoreUpdates(store), LENGTHY_INTERVAL)) { + try (PollingProcessor pollingProcessor = makeProcessor(requestor, store)) { Future initFuture = pollingProcessor.start(); try { initFuture.get(200L, TimeUnit.MILLISECONDS); @@ -124,7 +121,7 @@ private void testUnrecoverableHttpError(int status) throws Exception { requestor.httpException = new HttpErrorException(status); DataStore store = new InMemoryDataStore(); - try (PollingProcessor pollingProcessor = new PollingProcessor(requestor, dataStoreUpdates(store), LENGTHY_INTERVAL)) { + try (PollingProcessor pollingProcessor = makeProcessor(requestor, store)) { long startTime = System.currentTimeMillis(); Future initFuture = pollingProcessor.start(); try { @@ -143,7 +140,7 @@ private void testRecoverableHttpError(int status) throws Exception { requestor.httpException = new HttpErrorException(status); DataStore store = new InMemoryDataStore(); - try (PollingProcessor pollingProcessor = new PollingProcessor(requestor, dataStoreUpdates(store), LENGTHY_INTERVAL)) { + try (PollingProcessor pollingProcessor = makeProcessor(requestor, store)) { Future initFuture = pollingProcessor.start(); try { initFuture.get(200, TimeUnit.MILLISECONDS); diff --git a/src/test/java/com/launchdarkly/sdk/server/TestComponents.java b/src/test/java/com/launchdarkly/sdk/server/TestComponents.java index cace1de30..e097d5668 100644 --- a/src/test/java/com/launchdarkly/sdk/server/TestComponents.java +++ b/src/test/java/com/launchdarkly/sdk/server/TestComponents.java @@ -25,19 +25,23 @@ import java.util.Set; import java.util.concurrent.BlockingQueue; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ScheduledExecutorService; import static com.launchdarkly.sdk.server.DataModel.FEATURES; @SuppressWarnings("javadoc") public class TestComponents { + static ScheduledExecutorService sharedExecutor = Executors.newSingleThreadScheduledExecutor(); + public static ClientContext clientContext(final String sdkKey, final LDConfig config) { - return new ClientContextImpl(sdkKey, config, null); + return new ClientContextImpl(sdkKey, config, sharedExecutor, null); } public static ClientContext clientContext(final String sdkKey, final LDConfig config, DiagnosticAccumulator diagnosticAccumulator) { - return new ClientContextImpl(sdkKey, config, diagnosticAccumulator); + return new ClientContextImpl(sdkKey, config, sharedExecutor, diagnosticAccumulator); } public static DataSourceFactory dataSourceWithData(FullDataSet data) { diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/MockPersistentDataStore.java b/src/test/java/com/launchdarkly/sdk/server/integrations/MockPersistentDataStore.java index f8697dbb7..3f7fca20a 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/MockPersistentDataStore.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/MockPersistentDataStore.java @@ -22,14 +22,14 @@ public static final class MockDatabaseInstance { Map initedByPrefix = new HashMap<>(); } - final Map> data; - final AtomicBoolean inited; - final AtomicInteger initedCount = new AtomicInteger(0); - volatile int initedQueryCount; - volatile boolean persistOnlyAsString; - volatile boolean unavailable; - volatile RuntimeException fakeError; - volatile Runnable updateHook; + public final Map> data; + public final AtomicBoolean inited; + public final AtomicInteger initedCount = new AtomicInteger(0); + public volatile int initedQueryCount; + public volatile boolean persistOnlyAsString; + public volatile boolean unavailable; + public volatile RuntimeException fakeError; + public volatile Runnable updateHook; public MockPersistentDataStore() { this.data = new HashMap<>(); From a46ea5876b5c8bebe8c69a658e2bf36218fe4056 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 5 May 2020 14:29:14 -0700 Subject: [PATCH 405/673] 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 da5cc46f1c5052629bb6101a371f440200b138cf Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 5 May 2020 15:11:47 -0700 Subject: [PATCH 406/673] simplify store status implementation to not use optional interface --- .../launchdarkly/sdk/server/Components.java | 7 +- .../server/DataStoreStatusProviderImpl.java | 43 +++- .../sdk/server/DataStoreUpdatesImpl.java | 8 +- .../sdk/server/InMemoryDataStore.java | 13 +- .../com/launchdarkly/sdk/server/LDClient.java | 27 ++- .../PersistentDataStoreStatusManager.java | 20 +- .../server/PersistentDataStoreWrapper.java | 21 +- .../sdk/server/StreamProcessor.java | 6 +- .../sdk/server/interfaces/ClientContext.java | 8 +- .../sdk/server/interfaces/DataStore.java | 25 +++ .../server/interfaces/DataStoreFactory.java | 6 +- .../interfaces/DataStoreStatusProvider.java | 153 ++++---------- .../sdk/server/interfaces/DataStoreTypes.java | 111 ++++++++++ .../sdk/server/DataStoreUpdatesImplTest.java | 24 +-- .../sdk/server/DiagnosticEventTest.java | 4 +- .../sdk/server/LDClientListenersTest.java | 190 ++++++++++++++++++ .../launchdarkly/sdk/server/LDClientTest.java | 95 --------- .../PersistentDataStoreWrapperTest.java | 27 ++- .../sdk/server/StreamProcessorTest.java | 26 ++- .../sdk/server/TestComponents.java | 114 ++++++----- 20 files changed, 580 insertions(+), 348 deletions(-) create mode 100644 src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java diff --git a/src/main/java/com/launchdarkly/sdk/server/Components.java b/src/main/java/com/launchdarkly/sdk/server/Components.java index f5949939c..39f57f697 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Components.java +++ b/src/main/java/com/launchdarkly/sdk/server/Components.java @@ -13,6 +13,7 @@ import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; import com.launchdarkly.sdk.server.interfaces.DiagnosticDescription; import com.launchdarkly.sdk.server.interfaces.Event; @@ -31,6 +32,7 @@ import java.net.URI; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Future; +import java.util.function.Consumer; import okhttp3.Credentials; @@ -292,7 +294,7 @@ public static FlagChangeListener flagValueMonitoringListener(LDClientInterface c private static final class InMemoryDataStoreFactory implements DataStoreFactory, DiagnosticDescription { static final DataStoreFactory INSTANCE = new InMemoryDataStoreFactory(); @Override - public DataStore createDataStore(ClientContext context) { + public DataStore createDataStore(ClientContext context, Consumer statusUpdater) { return new InMemoryDataStore(); } @@ -563,13 +565,14 @@ public LDValue describeConfiguration(LDConfig config) { * Called by the SDK to create the data store instance. */ @Override - public DataStore createDataStore(ClientContext context) { + public DataStore createDataStore(ClientContext context, Consumer statusUpdater) { PersistentDataStore core = persistentDataStoreFactory.createPersistentDataStore(context); return new PersistentDataStoreWrapper( core, cacheTime, staleValuesPolicy, recordCacheStats, + statusUpdater, ClientContextImpl.get(context).sharedExecutor ); } diff --git a/src/main/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImpl.java b/src/main/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImpl.java index db63225ee..ba31795b3 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImpl.java @@ -2,35 +2,58 @@ import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.CacheStats; + +import java.util.concurrent.atomic.AtomicReference; // Simple delegator to ensure that LDClient.getDataStoreStatusProvider() never returns null and that // the application isn't given direct access to the store. final class DataStoreStatusProviderImpl implements DataStoreStatusProvider { - private final DataStoreStatusProvider delegateTo; + private final DataStore store; + private final EventBroadcasterImpl statusBroadcaster; + private final AtomicReference lastStatus; - DataStoreStatusProviderImpl(DataStore store) { - delegateTo = store instanceof DataStoreStatusProvider ? (DataStoreStatusProvider)store : null; + DataStoreStatusProviderImpl( + DataStore store, + EventBroadcasterImpl statusBroadcaster + ) { + this.store = store; + this.statusBroadcaster = statusBroadcaster; + this.lastStatus = new AtomicReference<>(new DataStoreStatusProvider.Status(true, false)); // initially "available" + } + + // package-private + void updateStatus(DataStoreStatusProvider.Status newStatus) { + if (newStatus != null) { + DataStoreStatusProvider.Status oldStatus = lastStatus.getAndSet(newStatus); + if (!newStatus.equals(oldStatus)) { + statusBroadcaster.broadcast(newStatus); + } + } } @Override public Status getStoreStatus() { - return delegateTo == null ? null : delegateTo.getStoreStatus(); + return lastStatus.get(); } @Override - public boolean addStatusListener(StatusListener listener) { - return delegateTo != null && delegateTo.addStatusListener(listener); + public void addStatusListener(StatusListener listener) { + statusBroadcaster.register(listener); } @Override public void removeStatusListener(StatusListener listener) { - if (delegateTo != null) { - delegateTo.removeStatusListener(listener); - } + statusBroadcaster.unregister(listener); + } + + @Override + public boolean isStatusMonitoringEnabled() { + return store.isStatusMonitoringEnabled(); } @Override public CacheStats getCacheStats() { - return delegateTo == null ? null : delegateTo.getCacheStats(); + return store.getCacheStats(); } } diff --git a/src/main/java/com/launchdarkly/sdk/server/DataStoreUpdatesImpl.java b/src/main/java/com/launchdarkly/sdk/server/DataStoreUpdatesImpl.java index 190b61462..b815654ed 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DataStoreUpdatesImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/DataStoreUpdatesImpl.java @@ -36,10 +36,14 @@ final class DataStoreUpdatesImpl implements DataStoreUpdates { private final DataModelDependencies.DependencyTracker dependencyTracker = new DataModelDependencies.DependencyTracker(); private final DataStoreStatusProvider dataStoreStatusProvider; - DataStoreUpdatesImpl(DataStore store, EventBroadcasterImpl flagChangeEventNotifier) { + DataStoreUpdatesImpl( + DataStore store, + EventBroadcasterImpl flagChangeEventNotifier, + DataStoreStatusProvider dataStoreStatusProvider + ) { this.store = store; this.flagChangeEventNotifier = flagChangeEventNotifier; - this.dataStoreStatusProvider = new DataStoreStatusProviderImpl(store); + this.dataStoreStatusProvider = dataStoreStatusProvider; } @Override diff --git a/src/main/java/com/launchdarkly/sdk/server/InMemoryDataStore.java b/src/main/java/com/launchdarkly/sdk/server/InMemoryDataStore.java index aea263cbc..60f089b14 100644 --- a/src/main/java/com/launchdarkly/sdk/server/InMemoryDataStore.java +++ b/src/main/java/com/launchdarkly/sdk/server/InMemoryDataStore.java @@ -13,6 +13,7 @@ import java.io.IOException; import java.util.HashMap; import java.util.Map; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.CacheStats; /** * A thread-safe, versioned store for feature flags and related data based on a @@ -99,7 +100,17 @@ public boolean upsert(DataKind kind, String key, ItemDescriptor item) { public boolean isInitialized() { return initialized; } - + + @Override + public boolean isStatusMonitoringEnabled() { + return false; + } + + @Override + public CacheStats getCacheStats() { + return null; + } + /** * Does nothing; this class does not have any resources to release * diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClient.java b/src/main/java/com/launchdarkly/sdk/server/LDClient.java index 6812daa63..6151ad5bf 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClient.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClient.java @@ -64,7 +64,7 @@ public final class LDClient implements LDClientInterface { final EventProcessor eventProcessor; final DataSource dataSource; final DataStore dataStore; - private final DataStoreStatusProvider dataStoreStatusProvider; + private final DataStoreStatusProviderImpl dataStoreStatusProvider; private final EventBroadcasterImpl flagChangeEventNotifier; private final ScheduledExecutorService sharedExecutor; @@ -127,9 +127,8 @@ public LDClient(String sdkKey, LDConfig config) { DataStoreFactory factory = config.dataStoreFactory == null ? Components.inMemoryDataStore() : config.dataStoreFactory; - this.dataStore = factory.createDataStore(context); - this.dataStoreStatusProvider = new DataStoreStatusProviderImpl(this.dataStore); - + this.dataStore = factory.createDataStore(context, this::updateDataStoreStatus); + this.evaluator = new Evaluator(new Evaluator.Getters() { public DataModel.FeatureFlag getFlag(String key) { return LDClient.getFlag(LDClient.this.dataStore, key); @@ -139,12 +138,20 @@ public DataModel.Segment getSegment(String key) { return LDClient.getSegment(LDClient.this.dataStore, key); } }); - + this.flagChangeEventNotifier = new EventBroadcasterImpl<>(FlagChangeListener::onFlagChange, sharedExecutor); - + EventBroadcasterImpl dataStoreStatusNotifier = + new EventBroadcasterImpl<>(DataStoreStatusProvider.StatusListener::dataStoreStatusChanged, sharedExecutor); + + this.dataStoreStatusProvider = new DataStoreStatusProviderImpl(this.dataStore, dataStoreStatusNotifier); + DataSourceFactory dataSourceFactory = config.dataSourceFactory == null ? Components.streamingDataSource() : config.dataSourceFactory; - DataStoreUpdates dataStoreUpdates = new DataStoreUpdatesImpl(dataStore, flagChangeEventNotifier); + DataStoreUpdates dataStoreUpdates = new DataStoreUpdatesImpl( + dataStore, + flagChangeEventNotifier, + dataStoreStatusProvider + ); this.dataSource = dataSourceFactory.createDataSource(context, dataStoreUpdates); Future startFuture = dataSource.start(); @@ -480,6 +487,12 @@ private ScheduledExecutorService createSharedExecutor() { return Executors.newSingleThreadScheduledExecutor(threadFactory); } + private void updateDataStoreStatus(DataStoreStatusProvider.Status newStatus) { + if (dataStoreStatusProvider != null) { + dataStoreStatusProvider.updateStatus(newStatus); + } + } + private static String getClientVersion() { Class clazz = LDConfig.class; String className = clazz.getSimpleName() + ".class"; diff --git a/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreStatusManager.java b/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreStatusManager.java index 1ad99f34b..bb4833843 100644 --- a/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreStatusManager.java +++ b/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreStatusManager.java @@ -2,7 +2,6 @@ import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider.Status; -import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider.StatusListener; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -11,6 +10,7 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; /** * Used internally to encapsulate the data store status broadcasting mechanism for PersistentDataStoreWrapper. @@ -22,8 +22,8 @@ final class PersistentDataStoreStatusManager { private static final Logger logger = LoggerFactory.getLogger(PersistentDataStoreStatusManager.class); static final int POLL_INTERVAL_MS = 500; // visible for testing + private final Consumer statusUpdater; private final ScheduledExecutorService scheduler; - private final EventBroadcasterImpl statusBroadcaster; private final Callable statusPollFn; private final boolean refreshOnRecovery; private volatile boolean lastAvailable; @@ -33,24 +33,14 @@ final class PersistentDataStoreStatusManager { boolean refreshOnRecovery, boolean availableNow, Callable statusPollFn, + Consumer statusUpdater, ScheduledExecutorService sharedExecutor ) { this.refreshOnRecovery = refreshOnRecovery; this.lastAvailable = availableNow; this.statusPollFn = statusPollFn; + this.statusUpdater = statusUpdater; this.scheduler = sharedExecutor; - this.statusBroadcaster = new EventBroadcasterImpl<>( - DataStoreStatusProvider.StatusListener::dataStoreStatusChanged, - sharedExecutor - ); - } - - void addStatusListener(StatusListener listener) { - statusBroadcaster.register(listener); - } - - synchronized void removeStatusListener(StatusListener listener) { - statusBroadcaster.unregister(listener); } void updateAvailability(boolean available) { @@ -67,7 +57,7 @@ void updateAvailability(boolean available) { logger.warn("Persistent store is available again"); } - statusBroadcaster.broadcast(status); + statusUpdater.accept(status); // If the store has just become unavailable, start a poller to detect when it comes back. If it has // become available, stop any polling we are currently doing. diff --git a/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapper.java b/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapper.java index 4ee659b44..13bb05e8a 100644 --- a/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapper.java +++ b/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapper.java @@ -11,6 +11,7 @@ import com.launchdarkly.sdk.server.integrations.PersistentDataStoreBuilder; import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.CacheStats; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; @@ -30,6 +31,7 @@ import java.util.concurrent.ExecutionException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; import static com.google.common.collect.Iterables.concat; import static com.google.common.collect.Iterables.filter; @@ -43,7 +45,7 @@ *

    * This class is only constructed by {@link PersistentDataStoreBuilder}. */ -final class PersistentDataStoreWrapper implements DataStore, DataStoreStatusProvider { +final class PersistentDataStoreWrapper implements DataStore { private static final Logger logger = LoggerFactory.getLogger(PersistentDataStoreWrapper.class); private final PersistentDataStore core; @@ -61,6 +63,7 @@ final class PersistentDataStoreWrapper implements DataStore, DataStoreStatusProv Duration cacheTtl, PersistentDataStoreBuilder.StaleValuesPolicy staleValuesPolicy, boolean recordCacheStats, + Consumer statusUpdater, ScheduledExecutorService sharedExecutor ) { this.core = core; @@ -110,6 +113,7 @@ public Boolean load(String key) throws Exception { !cacheIndefinitely, true, this::pollAvailabilityAfterOutage, + statusUpdater, sharedExecutor ); } @@ -306,23 +310,12 @@ public boolean upsert(DataKind kind, String key, ItemDescriptor item) { } return updated; } - - @Override - public Status getStoreStatus() { - return new Status(statusManager.isAvailable(), false); - } - + @Override - public boolean addStatusListener(StatusListener listener) { - statusManager.addStatusListener(listener); + public boolean isStatusMonitoringEnabled() { return true; } - @Override - public void removeStatusListener(StatusListener listener) { - statusManager.removeStatusListener(listener); - } - @Override public CacheStats getCacheStats() { if (itemCache == null || allCache == null) { diff --git a/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java index 83b376abf..ab32e5071 100644 --- a/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java @@ -134,8 +134,10 @@ static interface EventSourceCreator { .build(); DataStoreStatusProvider.StatusListener statusListener = this::onStoreStatusChanged; - if (dataStoreUpdates.getStatusProvider().addStatusListener(statusListener)) { - this.statusListener = statusListener; + if (dataStoreUpdates.getStatusProvider() != null && + dataStoreUpdates.getStatusProvider().isStatusMonitoringEnabled()) { + this.statusListener = this::onStoreStatusChanged; + dataStoreUpdates.getStatusProvider().addStatusListener(statusListener); } else { this.statusListener = null; } diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/ClientContext.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/ClientContext.java index 6c43e21c1..944a19aeb 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/ClientContext.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/ClientContext.java @@ -3,10 +3,10 @@ /** * Context information provided by the {@link com.launchdarkly.sdk.server.LDClient} when creating components. *

    - * This is passed as a parameter to {@link DataStoreFactory#createDataStore(ClientContext)}, etc. The - * actual implementation class may contain other properties that are only relevant to the built-in SDK - * components and are therefore not part of the public interface; this allows the SDK to add its own - * context information as needed without disturbing the public API. + * This is passed as a parameter to {@link DataStoreFactory#createDataStore(ClientContext, java.util.function.Consumer)}, + * etc. The actual implementation class may contain other properties that are only relevant to the + * built-in SDK components and are therefore not part of the public interface; this allows the SDK + * to add its own context information as needed without disturbing the public API. * * @since 5.0.0 */ diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStore.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStore.java index 83f8d34cd..986b64e30 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStore.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStore.java @@ -1,5 +1,6 @@ package com.launchdarkly.sdk.server.interfaces; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.CacheStats; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; @@ -78,4 +79,28 @@ public interface DataStore extends Closeable { * @return true if the store contains data */ boolean isInitialized(); + + /** + * Returns true if this data store implementation supports status monitoring. + *

    + * This is normally only true for persistent data stores created with + * {@link com.launchdarkly.sdk.server.Components#persistentDataStore(PersistentDataStoreFactory)}, + * but it could also be true for any custom {@link DataStore} implementation that makes use of the + * {@code statusUpdater} parameter provided to {@link DataStoreFactory#createDataStore(ClientContext, java.util.function.Consumer)}. + * Returning true means that the store guarantees that if it ever enters an invalid state (that is, an + * operation has failed or it knows that operations cannot succeed at the moment), it will publish a + * status update, and will then publish another status update once it has returned to a valid state. + *

    + * The same value will be returned from {@link DataStoreStatusProvider#isStatusMonitoringEnabled()}. + * + * @return true if status monitoring is enabled + */ + boolean isStatusMonitoringEnabled(); + + /** + * Returns statistics about cache usage, if this data store implementation supports caching. + * + * @return a cache statistics object, or null if not applicable + */ + CacheStats getCacheStats(); } diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreFactory.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreFactory.java index 0ed2456ad..b74641a53 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreFactory.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreFactory.java @@ -2,6 +2,8 @@ import com.launchdarkly.sdk.server.Components; +import java.util.function.Consumer; + /** * Interface for a factory that creates some implementation of {@link DataStore}. * @see Components @@ -12,7 +14,9 @@ public interface DataStoreFactory { * Creates an implementation instance. * * @param context allows access to the client configuration + * @param statusUpdater if non-null, the store can call this method to provide an update of its status; + * if the store never calls this method, the SDK will report its status as "available" * @return a {@link DataStore} */ - DataStore createDataStore(ClientContext context); + DataStore createDataStore(ClientContext context, Consumer statusUpdater); } diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProvider.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProvider.java index 25b43b08b..4518ea864 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProvider.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProvider.java @@ -11,17 +11,35 @@ * If the data store is a persistent data store, then these methods are implemented by the SDK; if it is a custom * class that implements this interface, then these methods delegate to the corresponding methods of the class; * if it is the default in-memory data store, then these methods do nothing and return null values. + *

    + * Application code should not implement this interface. * * @since 5.0.0 */ public interface DataStoreStatusProvider { /** * Returns the current status of the store. + *

    + * This is only meaningful for persistent stores, or any other {@link DataStore} implementation that makes use of + * the reporting mechanism provided by {@link DataStoreFactory#createDataStore(ClientContext, java.util.function.Consumer)}. + * For the default in-memory store, the status will always be reported as "available". * - * @return the latest status, or null if not available + * @return the latest status; will never be null */ public Status getStoreStatus(); + /** + * Indicates whether the current data store implementation supports status monitoring. + *

    + * This is normally true for all persistent data stores, and false for the default in-memory store. A true value + * means that any listeners added with {@link #addStatusListener(StatusListener)} can expect to be notified if + * there is any error in storing data, and then notified again when the error condition is resolved. A false + * value means that the status is not meaningful and listeners should not expect to be notified. + * + * @return true if status monitoring is enabled + */ + public boolean isStatusMonitoringEnabled(); + /** * Subscribes for notifications of status changes. *

    @@ -38,10 +56,8 @@ public interface DataStoreStatusProvider { * are using the default in-memory store rather than a persistent store. * * @param listener the listener to add - * @return true if the listener was added, or was already registered; false if the data store does not support - * status tracking */ - public boolean addStatusListener(StatusListener listener); + public void addStatusListener(StatusListener listener); /** * Unsubscribes from notifications of status changes. @@ -60,9 +76,9 @@ public interface DataStoreStatusProvider { * not a persistent store, or because you did not enable cache monitoring with * {@link PersistentDataStoreBuilder#recordCacheStats(boolean)}. * - * @return a {@link CacheStats} instance; null if not applicable + * @return a {@link DataStoreTypes.CacheStats} instance; null if not applicable */ - public CacheStats getCacheStats(); + public DataStoreTypes.CacheStats getCacheStats(); /** * Information about a status change. @@ -105,126 +121,35 @@ public boolean isAvailable() { public boolean isRefreshNeeded() { return refreshNeeded; } - } - - /** - * Interface for receiving status change notifications. - */ - public static interface StatusListener { - /** - * Called when the store status has changed. - * @param newStatus the new status - */ - public void dataStoreStatusChanged(Status newStatus); - } - - /** - * 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. - * - * @see DataStoreStatusProvider#getCacheStats() - * @see PersistentDataStoreBuilder#recordCacheStats(boolean) - * @since 4.12.0 - */ - 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 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) { - 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; + if (other instanceof Status) { + Status o = (Status)other; + return available == o.available && refreshNeeded == o.refreshNeeded; } - CacheStats o = (CacheStats)other; - return hitCount == o.hitCount && missCount == o.missCount && loadSuccessCount == o.loadSuccessCount && - loadExceptionCount == o.loadExceptionCount && totalLoadTime == o.totalLoadTime && evictionCount == o.evictionCount; + return false; } @Override public int hashCode() { - return Objects.hash(hitCount, missCount, loadSuccessCount, loadExceptionCount, totalLoadTime, evictionCount); + return Objects.hash(available, refreshNeeded); } @Override public String toString() { - return "{hit=" + hitCount + ", miss=" + missCount + ", loadSuccess=" + loadSuccessCount + - ", loadException=" + loadExceptionCount + ", totalLoadTime=" + totalLoadTime + ", evictionCount=" + evictionCount + "}"; + return "Status(" + available + "," + refreshNeeded + ")"; } } + + /** + * Interface for receiving status change notifications. + */ + public static interface StatusListener { + /** + * Called when the store status has changed. + * @param newStatus the new status + */ + public void dataStoreStatusChanged(Status newStatus); + } } diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypes.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypes.java index 04fda02f9..123233b97 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypes.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypes.java @@ -1,6 +1,7 @@ package com.launchdarkly.sdk.server.interfaces; import com.google.common.collect.ImmutableList; +import com.launchdarkly.sdk.server.integrations.PersistentDataStoreBuilder; import java.util.Map; import java.util.Objects; @@ -293,4 +294,114 @@ public KeyedItems(Iterable> items) { this.items = items == null ? ImmutableList.of() : items; } } + + /** + * 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. + * + * @see DataStoreStatusProvider#getCacheStats() + * @see PersistentDataStoreBuilder#recordCacheStats(boolean) + * @since 4.12.0 + */ + 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 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) { + 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/test/java/com/launchdarkly/sdk/server/DataStoreUpdatesImplTest.java b/src/test/java/com/launchdarkly/sdk/server/DataStoreUpdatesImplTest.java index d98b083a5..a95f464d5 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataStoreUpdatesImplTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataStoreUpdatesImplTest.java @@ -53,7 +53,7 @@ public void tearDown() { @Test public void doesNotTryToSendEventsIfThereIsNoEventPublisher() { DataStore store = inMemoryDataStore(); - DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, null); + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, null, null); storeUpdates.upsert(DataModel.FEATURES, "key", new ItemDescriptor(1, flagBuilder("key").build())); // the test is just that this doesn't cause an exception } @@ -67,7 +67,7 @@ public void sendsEventsOnInitForNewlyAddedFlags() throws Exception { .addAny(SEGMENTS, segmentBuilder("segment1").version(1).build()); - DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, flagChangeBroadcaster); + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, flagChangeBroadcaster, null); storeUpdates.init(builder.build()); @@ -92,7 +92,7 @@ public void sendsEventOnUpdateForNewlyAddedFlag() throws Exception { .addAny(SEGMENTS, segmentBuilder("segment1").version(1).build()); - DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, flagChangeBroadcaster); + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, flagChangeBroadcaster, null); storeUpdates.init(builder.build()); @@ -115,7 +115,7 @@ public void sendsEventsOnInitForUpdatedFlags() throws Exception { segmentBuilder("segment1").version(1).build(), segmentBuilder("segment2").version(1).build()); - DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, flagChangeBroadcaster); + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, flagChangeBroadcaster, null); storeUpdates.init(builder.build()); @@ -139,7 +139,7 @@ public void sendsEventOnUpdateForUpdatedFlag() throws Exception { .addAny(SEGMENTS, segmentBuilder("segment1").version(1).build()); - DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, flagChangeBroadcaster); + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, flagChangeBroadcaster, null); storeUpdates.init(builder.build()); @@ -161,7 +161,7 @@ public void sendsEventsOnInitForDeletedFlags() throws Exception { .addAny(SEGMENTS, segmentBuilder("segment1").version(1).build()); - DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, flagChangeBroadcaster); + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, flagChangeBroadcaster, null); storeUpdates.init(builder.build()); @@ -187,7 +187,7 @@ public void sendsEventOnUpdateForDeletedFlag() throws Exception { .addAny(SEGMENTS, segmentBuilder("segment1").version(1).build()); - DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, flagChangeBroadcaster); + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, flagChangeBroadcaster, null); storeUpdates.init(builder.build()); @@ -211,7 +211,7 @@ public void sendsEventsOnInitForFlagsWhosePrerequisitesChanged() throws Exceptio flagBuilder("flag5").version(1).prerequisites(prerequisite("flag4", 0)).build(), flagBuilder("flag6").version(1).build()); - DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, flagChangeBroadcaster); + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, flagChangeBroadcaster, null); storeUpdates.init(builder.build()); @@ -236,7 +236,7 @@ public void sendsEventsOnUpdateForFlagsWhosePrerequisitesChanged() throws Except flagBuilder("flag5").version(1).prerequisites(prerequisite("flag4", 0)).build(), flagBuilder("flag6").version(1).build()); - DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, flagChangeBroadcaster); + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, flagChangeBroadcaster, null); storeUpdates.init(builder.build()); @@ -265,7 +265,7 @@ public void sendsEventsOnInitForFlagsWhoseSegmentsChanged() throws Exception { segmentBuilder("segment1").version(1).build(), segmentBuilder("segment2").version(1).build()); - DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, flagChangeBroadcaster); + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, flagChangeBroadcaster, null); storeUpdates.init(builder.build()); @@ -294,7 +294,7 @@ public void sendsEventsOnUpdateForFlagsWhoseSegmentsChanged() throws Exception { segmentBuilder("segment1").version(1).build(), segmentBuilder("segment2").version(1).build()); - DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, flagChangeBroadcaster); + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, flagChangeBroadcaster, null); storeUpdates.init(builder.build()); @@ -318,7 +318,7 @@ public void dataSetIsPassedToDataStoreInCorrectOrder() throws Exception { store.init(EasyMock.capture(captureData)); replay(store); - DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, null); + DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, null, null); storeUpdates.init(DEPENDENCY_ORDERING_TEST_DATA); Map> dataMap = toDataMap(captureData.getValue()); diff --git a/src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java b/src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java index 4da6cc4e5..c6599e9f3 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java @@ -8,6 +8,7 @@ import com.launchdarkly.sdk.server.interfaces.ClientContext; import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider.Status; import com.launchdarkly.sdk.server.interfaces.DiagnosticDescription; import org.junit.Test; @@ -17,6 +18,7 @@ import java.util.Collections; import java.util.List; import java.util.UUID; +import java.util.function.Consumer; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; @@ -234,7 +236,7 @@ public LDValue describeConfiguration(LDConfig config) { } @Override - public DataStore createDataStore(ClientContext context) { + public DataStore createDataStore(ClientContext context, Consumer statusUpdater) { return null; } } diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java new file mode 100644 index 000000000..7d461ba7f --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java @@ -0,0 +1,190 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.DataModel.FeatureFlag; +import com.launchdarkly.sdk.server.DataStoreTestTypes.DataBuilder; +import com.launchdarkly.sdk.server.TestComponents.DataSourceFactoryThatExposesUpdater; +import com.launchdarkly.sdk.server.TestComponents.DataStoreFactoryThatExposesUpdater; +import com.launchdarkly.sdk.server.TestUtil.FlagChangeEventSink; +import com.launchdarkly.sdk.server.TestUtil.FlagValueChangeEventSink; +import com.launchdarkly.sdk.server.integrations.MockPersistentDataStore; +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; +import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; +import com.launchdarkly.sdk.server.interfaces.FlagValueChangeEvent; + +import org.easymock.EasyMockSupport; +import org.junit.Test; + +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; + +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.TestComponents.initedDataStore; +import static com.launchdarkly.sdk.server.TestComponents.specificDataStore; +import static com.launchdarkly.sdk.server.TestComponents.specificPersistentDataStore; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +/** + * This file contains tests for all of the event broadcaster/listener functionality in the client, plus + * related methods for looking at the same kinds of status values that can be broadcast to listeners. + *

    + * Parts of this functionality are also covered by lower-level component tests like + * DataStoreUpdatesImplTest. However, the tests here verify that the client is wiring the components + * together correctly so that they work from an application's point of view. + */ +@SuppressWarnings("javadoc") +public class LDClientListenersTest extends EasyMockSupport { + private final static String SDK_KEY = "SDK_KEY"; + + @Test + public void clientSendsFlagChangeEvents() throws Exception { + DataStore testDataStore = initedDataStore(); + DataBuilder initialData = new DataBuilder().addAny(DataModel.FEATURES, + flagBuilder("flagkey").version(1).build()); + DataSourceFactoryThatExposesUpdater updatableSource = new DataSourceFactoryThatExposesUpdater(initialData.build()); + LDConfig config = new LDConfig.Builder() + .dataStore(specificDataStore(testDataStore)) + .dataSource(updatableSource) + .events(Components.noEvents()) + .build(); + + try (LDClient client = new LDClient(SDK_KEY, config)) { + FlagChangeEventSink eventSink1 = new FlagChangeEventSink(); + FlagChangeEventSink eventSink2 = new FlagChangeEventSink(); + client.registerFlagChangeListener(eventSink1); + client.registerFlagChangeListener(eventSink2); + + eventSink1.expectNoEvents(); + eventSink2.expectNoEvents(); + + updatableSource.updateFlag(flagBuilder("flagkey").version(2).build()); + + FlagChangeEvent event1 = eventSink1.awaitEvent(); + FlagChangeEvent event2 = eventSink2.awaitEvent(); + assertThat(event1.getKey(), equalTo("flagkey")); + assertThat(event2.getKey(), equalTo("flagkey")); + eventSink1.expectNoEvents(); + eventSink2.expectNoEvents(); + + client.unregisterFlagChangeListener(eventSink1); + + updatableSource.updateFlag(flagBuilder("flagkey").version(3).build()); + + FlagChangeEvent event3 = eventSink2.awaitEvent(); + assertThat(event3.getKey(), equalTo("flagkey")); + eventSink1.expectNoEvents(); + eventSink2.expectNoEvents(); + } + } + + @Test + public void clientSendsFlagValueChangeEvents() throws Exception { + String flagKey = "important-flag"; + LDUser user = new LDUser("important-user"); + LDUser otherUser = new LDUser("unimportant-user"); + DataStore testDataStore = initedDataStore(); + + FeatureFlag alwaysFalseFlag = flagBuilder(flagKey).version(1).on(true).variations(false, true) + .fallthroughVariation(0).build(); + DataBuilder initialData = new DataBuilder().addAny(DataModel.FEATURES, alwaysFalseFlag); + + DataSourceFactoryThatExposesUpdater updatableSource = new DataSourceFactoryThatExposesUpdater(initialData.build()); + LDConfig config = new LDConfig.Builder() + .dataStore(specificDataStore(testDataStore)) + .dataSource(updatableSource) + .events(Components.noEvents()) + .build(); + + try (LDClient client = new LDClient(SDK_KEY, config)) { + FlagValueChangeEventSink eventSink1 = new FlagValueChangeEventSink(); + FlagValueChangeEventSink eventSink2 = new FlagValueChangeEventSink(); + client.registerFlagChangeListener(Components.flagValueMonitoringListener(client, flagKey, user, eventSink1)); + client.registerFlagChangeListener(Components.flagValueMonitoringListener(client, flagKey, otherUser, eventSink2)); + + eventSink1.expectNoEvents(); + eventSink2.expectNoEvents(); + + FeatureFlag flagIsTrueForMyUserOnly = flagBuilder(flagKey).version(2).on(true).variations(false, true) + .targets(ModelBuilders.target(1, user.getKey())).fallthroughVariation(0).build(); + updatableSource.updateFlag(flagIsTrueForMyUserOnly); + + // eventSink1 receives a value change event; eventSink2 doesn't because the flag's value hasn't changed for otherUser + FlagValueChangeEvent event1 = eventSink1.awaitEvent(); + assertThat(event1.getKey(), equalTo(flagKey)); + assertThat(event1.getOldValue(), equalTo(LDValue.of(false))); + assertThat(event1.getNewValue(), equalTo(LDValue.of(true))); + eventSink1.expectNoEvents(); + + eventSink2.expectNoEvents(); + } + } + + @Test + public void dataStoreStatusMonitoringIsDisabledForInMemoryStore() throws Exception { + LDConfig config = new LDConfig.Builder() + .dataSource(Components.externalUpdatesOnly()) + .events(Components.noEvents()) + .build(); + try (LDClient client = new LDClient(SDK_KEY, config)) { + assertThat(client.getDataStoreStatusProvider().isStatusMonitoringEnabled(), equalTo(false)); + } + } + + @Test + public void dataStoreStatusMonitoringIsEnabledForPersistentStore() throws Exception { + LDConfig config = new LDConfig.Builder() + .dataSource(Components.externalUpdatesOnly()) + .dataStore( + Components.persistentDataStore(specificPersistentDataStore(new MockPersistentDataStore())) + ) + .events(Components.noEvents()) + .build(); + try (LDClient client = new LDClient(SDK_KEY, config)) { + assertThat(client.getDataStoreStatusProvider().isStatusMonitoringEnabled(), equalTo(true)); + } + } + + @Test + public void dataStoreStatusProviderReturnsLatestStatus() throws Exception { + DataStoreFactory underlyingStoreFactory = Components.persistentDataStore( + specificPersistentDataStore(new MockPersistentDataStore())); + DataStoreFactoryThatExposesUpdater factoryWithUpdater = new DataStoreFactoryThatExposesUpdater(underlyingStoreFactory); + LDConfig config = new LDConfig.Builder() + .dataSource(Components.externalUpdatesOnly()) + .dataStore(factoryWithUpdater) + .events(Components.noEvents()) + .build(); + try (LDClient client = new LDClient(SDK_KEY, config)) { + DataStoreStatusProvider.Status originalStatus = new DataStoreStatusProvider.Status(true, false); + DataStoreStatusProvider.Status newStatus = new DataStoreStatusProvider.Status(false, false); + assertThat(client.getDataStoreStatusProvider().getStoreStatus(), equalTo(originalStatus)); + factoryWithUpdater.statusUpdater.accept(newStatus); + assertThat(client.getDataStoreStatusProvider().getStoreStatus(), equalTo(newStatus)); + } + } + + @Test + public void dataStoreStatusProviderSendsStatusUpdates() throws Exception { + DataStoreFactory underlyingStoreFactory = Components.persistentDataStore( + specificPersistentDataStore(new MockPersistentDataStore())); + DataStoreFactoryThatExposesUpdater factoryWithUpdater = new DataStoreFactoryThatExposesUpdater(underlyingStoreFactory); + LDConfig config = new LDConfig.Builder() + .dataSource(Components.externalUpdatesOnly()) + .dataStore(factoryWithUpdater) + .events(Components.noEvents()) + .build(); + try (LDClient client = new LDClient(SDK_KEY, config)) { + final BlockingQueue statuses = new LinkedBlockingQueue<>(); + client.getDataStoreStatusProvider().addStatusListener(statuses::add); + + DataStoreStatusProvider.Status newStatus = new DataStoreStatusProvider.Status(false, false); + factoryWithUpdater.statusUpdater.accept(newStatus); + + assertThat(statuses.take(), equalTo(newStatus)); + } + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java index 72f326944..e5dc17d11 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java @@ -2,11 +2,6 @@ import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; -import com.launchdarkly.sdk.server.DataModel.FeatureFlag; -import com.launchdarkly.sdk.server.DataStoreTestTypes.DataBuilder; -import com.launchdarkly.sdk.server.TestComponents.DataSourceFactoryThatExposesUpdater; -import com.launchdarkly.sdk.server.TestUtil.FlagChangeEventSink; -import com.launchdarkly.sdk.server.TestUtil.FlagValueChangeEventSink; import com.launchdarkly.sdk.server.interfaces.ClientContext; import com.launchdarkly.sdk.server.interfaces.DataSource; import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; @@ -15,8 +10,6 @@ import com.launchdarkly.sdk.server.interfaces.Event; import com.launchdarkly.sdk.server.interfaces.EventProcessor; import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; -import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; -import com.launchdarkly.sdk.server.interfaces.FlagValueChangeEvent; import org.easymock.Capture; import org.easymock.EasyMock; @@ -31,7 +24,6 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; -import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; import static com.launchdarkly.sdk.server.ModelBuilders.flagWithValue; import static com.launchdarkly.sdk.server.TestComponents.failedDataSource; import static com.launchdarkly.sdk.server.TestComponents.initedDataStore; @@ -44,8 +36,6 @@ import static org.easymock.EasyMock.expect; import static org.easymock.EasyMock.expectLastCall; import static org.easymock.EasyMock.isA; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.equalTo; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; @@ -382,91 +372,6 @@ public void evaluationUsesStoreIfStoreIsInitializedButClientIsNot() throws Excep verifyAll(); } - @Test - public void clientSendsFlagChangeEvents() throws Exception { - // The logic for sending change events is tested in detail in DataStoreUpdatesImplTest, but here we'll - // verify that the client is actually telling DataStoreUpdatesImpl about updates, and managing the - // listener list. - DataStore testDataStore = initedDataStore(); - DataBuilder initialData = new DataBuilder().addAny(DataModel.FEATURES, - flagBuilder("flagkey").version(1).build()); - DataSourceFactoryThatExposesUpdater updatableSource = new DataSourceFactoryThatExposesUpdater(initialData.build()); - LDConfig config = new LDConfig.Builder() - .dataStore(specificDataStore(testDataStore)) - .dataSource(updatableSource) - .events(Components.noEvents()) - .build(); - - client = new LDClient(SDK_KEY, config); - - FlagChangeEventSink eventSink1 = new FlagChangeEventSink(); - FlagChangeEventSink eventSink2 = new FlagChangeEventSink(); - client.registerFlagChangeListener(eventSink1); - client.registerFlagChangeListener(eventSink2); - - eventSink1.expectNoEvents(); - eventSink2.expectNoEvents(); - - updatableSource.updateFlag(flagBuilder("flagkey").version(2).build()); - - FlagChangeEvent event1 = eventSink1.awaitEvent(); - FlagChangeEvent event2 = eventSink2.awaitEvent(); - assertThat(event1.getKey(), equalTo("flagkey")); - assertThat(event2.getKey(), equalTo("flagkey")); - eventSink1.expectNoEvents(); - eventSink2.expectNoEvents(); - - client.unregisterFlagChangeListener(eventSink1); - - updatableSource.updateFlag(flagBuilder("flagkey").version(3).build()); - - FlagChangeEvent event3 = eventSink2.awaitEvent(); - assertThat(event3.getKey(), equalTo("flagkey")); - eventSink1.expectNoEvents(); - eventSink2.expectNoEvents(); - } - - @Test - public void clientSendsFlagValueChangeEvents() throws Exception { - String flagKey = "important-flag"; - LDUser user = new LDUser("important-user"); - LDUser otherUser = new LDUser("unimportant-user"); - DataStore testDataStore = initedDataStore(); - - FeatureFlag alwaysFalseFlag = flagBuilder(flagKey).version(1).on(true).variations(false, true) - .fallthroughVariation(0).build(); - DataBuilder initialData = new DataBuilder().addAny(DataModel.FEATURES, alwaysFalseFlag); - - DataSourceFactoryThatExposesUpdater updatableSource = new DataSourceFactoryThatExposesUpdater(initialData.build()); - LDConfig config = new LDConfig.Builder() - .dataStore(specificDataStore(testDataStore)) - .dataSource(updatableSource) - .events(Components.noEvents()) - .build(); - - client = new LDClient(SDK_KEY, config); - FlagValueChangeEventSink eventSink1 = new FlagValueChangeEventSink(); - FlagValueChangeEventSink eventSink2 = new FlagValueChangeEventSink(); - client.registerFlagChangeListener(Components.flagValueMonitoringListener(client, flagKey, user, eventSink1)); - client.registerFlagChangeListener(Components.flagValueMonitoringListener(client, flagKey, otherUser, eventSink2)); - - eventSink1.expectNoEvents(); - eventSink2.expectNoEvents(); - - FeatureFlag flagIsTrueForMyUserOnly = flagBuilder(flagKey).version(2).on(true).variations(false, true) - .targets(ModelBuilders.target(1, user.getKey())).fallthroughVariation(0).build(); - updatableSource.updateFlag(flagIsTrueForMyUserOnly); - - // eventSink1 receives a value change event; eventSink2 doesn't because the flag's value hasn't changed for otherUser - FlagValueChangeEvent event1 = eventSink1.awaitEvent(); - assertThat(event1.getKey(), equalTo(flagKey)); - assertThat(event1.getOldValue(), equalTo(LDValue.of(false))); - assertThat(event1.getNewValue(), equalTo(LDValue.of(true))); - eventSink1.expectNoEvents(); - - eventSink2.expectNoEvents(); - } - private void expectEventsSent(int count) { eventProcessor.sendEvent(anyObject(Event.class)); if (count > 0) { diff --git a/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperTest.java b/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperTest.java index 306a6babc..b3ec69576 100644 --- a/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperTest.java @@ -7,6 +7,7 @@ import com.launchdarkly.sdk.server.integrations.MockPersistentDataStore; import com.launchdarkly.sdk.server.integrations.PersistentDataStoreBuilder; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.CacheStats; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.SerializedItemDescriptor; @@ -45,6 +46,8 @@ public class PersistentDataStoreWrapperTest { private final TestMode testMode; private final MockPersistentDataStore core; private final PersistentDataStoreWrapper wrapper; + private final EventBroadcasterImpl statusBroadcaster; + private final DataStoreStatusProviderImpl dataStoreStatusProvider; static class TestMode { final boolean cached; @@ -102,8 +105,16 @@ public PersistentDataStoreWrapperTest(TestMode testMode) { testMode.getCacheTtl(), PersistentDataStoreBuilder.StaleValuesPolicy.EVICT, false, + this::updateStatus, sharedExecutor ); + this.statusBroadcaster = new EventBroadcasterImpl<>( + DataStoreStatusProvider.StatusListener::dataStoreStatusChanged, sharedExecutor); + this.dataStoreStatusProvider = new DataStoreStatusProviderImpl(wrapper, statusBroadcaster); + } + + private void updateStatus(DataStoreStatusProvider.Status status) { + dataStoreStatusProvider.updateStatus(status); } @After @@ -458,6 +469,7 @@ public void initializedCanCacheFalseResult() throws Exception { Duration.ofMillis(500), PersistentDataStoreBuilder.StaleValuesPolicy.EVICT, false, + this::updateStatus, sharedExecutor )) { assertThat(wrapper1.isInitialized(), is(false)); @@ -486,11 +498,12 @@ public void canGetCacheStats() throws Exception { Duration.ofSeconds(30), PersistentDataStoreBuilder.StaleValuesPolicy.EVICT, true, + this::updateStatus, sharedExecutor )) { - DataStoreStatusProvider.CacheStats stats = w.getCacheStats(); + CacheStats stats = w.getCacheStats(); - assertThat(stats, equalTo(new DataStoreStatusProvider.CacheStats(0, 0, 0, 0, 0, 0))); + assertThat(stats, equalTo(new CacheStats(0, 0, 0, 0, 0, 0))); // Cause a cache miss w.get(TEST_ITEMS, "key1"); @@ -528,7 +541,7 @@ public void canGetCacheStats() throws Exception { @Test public void statusIsOkInitially() throws Exception { - DataStoreStatusProvider.Status status = wrapper.getStoreStatus(); + DataStoreStatusProvider.Status status = dataStoreStatusProvider.getStoreStatus(); assertThat(status.isAvailable(), is(true)); assertThat(status.isRefreshNeeded(), is(false)); } @@ -537,7 +550,7 @@ public void statusIsOkInitially() throws Exception { public void statusIsUnavailableAfterError() throws Exception { causeStoreError(core, wrapper); - DataStoreStatusProvider.Status status = wrapper.getStoreStatus(); + DataStoreStatusProvider.Status status = dataStoreStatusProvider.getStoreStatus(); assertThat(status.isAvailable(), is(false)); assertThat(status.isRefreshNeeded(), is(false)); } @@ -545,7 +558,7 @@ public void statusIsUnavailableAfterError() throws Exception { @Test public void statusListenerIsNotifiedOnFailureAndRecovery() throws Exception { final BlockingQueue statuses = new LinkedBlockingQueue<>(); - wrapper.addStatusListener(statuses::add); + dataStoreStatusProvider.addStatusListener(statuses::add); causeStoreError(core, wrapper); @@ -569,7 +582,7 @@ public void cacheIsWrittenToStoreAfterRecoveryIfTtlIsInfinite() throws Exception assumeThat(testMode.isCachedIndefinitely(), is(true)); final BlockingQueue statuses = new LinkedBlockingQueue<>(); - wrapper.addStatusListener(statuses::add); + dataStoreStatusProvider.addStatusListener(statuses::add); TestItem item1v1 = new TestItem("key1", 1); TestItem item1v2 = item1v1.withVersion(2); @@ -625,7 +638,7 @@ public void statusRemainsUnavailableIfStoreSaysItIsAvailableButInitFails() throw // Most of this test is identical to cacheIsWrittenToStoreAfterRecoveryIfTtlIsInfinite() except as noted below. final BlockingQueue statuses = new LinkedBlockingQueue<>(); - wrapper.addStatusListener(statuses::add); + dataStoreStatusProvider.addStatusListener(statuses::add); TestItem item1v1 = new TestItem("key1", 1); TestItem item1v2 = item1v1.withVersion(2); diff --git a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java index ce466454f..579c7fa97 100644 --- a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java @@ -5,12 +5,14 @@ import com.launchdarkly.eventsource.EventSource; import com.launchdarkly.eventsource.MessageEvent; import com.launchdarkly.eventsource.UnsuccessfulResponseException; +import com.launchdarkly.sdk.server.TestComponents.MockDataStoreStatusProvider; import com.launchdarkly.sdk.server.TestComponents.MockEventSourceCreator; import com.launchdarkly.sdk.server.integrations.StreamingDataSourceBuilder; import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; import org.easymock.EasyMockSupport; import org.junit.Before; @@ -526,7 +528,8 @@ public void indirectPatchWithFailedPollCausesStreamRestart() throws Exception { @Test public void restartsStreamIfStoreNeedsRefresh() throws Exception { - TestComponents.DataStoreWithStatusUpdates storeWithStatus = new TestComponents.DataStoreWithStatusUpdates(dataStore); + MockDataStoreStatusProvider dataStoreStatusProvider = new MockDataStoreStatusProvider(); + DataStoreUpdates storeUpdates = new DataStoreUpdatesImpl(dataStore, null, dataStoreStatusProvider); CompletableFuture restarted = new CompletableFuture<>(); mockEventSource.start(); @@ -543,11 +546,11 @@ public void restartsStreamIfStoreNeedsRefresh() throws Exception { replayAll(); - try (StreamProcessor sp = createStreamProcessorWithStore(storeWithStatus)) { + try (StreamProcessor sp = createStreamProcessorWithStoreUpdates(storeUpdates)) { sp.start(); - storeWithStatus.broadcastStatusChange(new DataStoreStatusProvider.Status(false, false)); - storeWithStatus.broadcastStatusChange(new DataStoreStatusProvider.Status(true, true)); + dataStoreStatusProvider.updateStatus(new DataStoreStatusProvider.Status(false, false)); + dataStoreStatusProvider.updateStatus(new DataStoreStatusProvider.Status(true, true)); restarted.get(); } @@ -555,7 +558,8 @@ public void restartsStreamIfStoreNeedsRefresh() throws Exception { @Test public void doesNotRestartStreamIfStoreHadOutageButDoesNotNeedRefresh() throws Exception { - TestComponents.DataStoreWithStatusUpdates storeWithStatus = new TestComponents.DataStoreWithStatusUpdates(dataStore); + MockDataStoreStatusProvider dataStoreStatusProvider = new MockDataStoreStatusProvider(); + DataStoreUpdates storeUpdates = new DataStoreUpdatesImpl(dataStore, null, dataStoreStatusProvider); CompletableFuture restarted = new CompletableFuture<>(); mockEventSource.start(); @@ -572,11 +576,11 @@ public void doesNotRestartStreamIfStoreHadOutageButDoesNotNeedRefresh() throws E replayAll(); - try (StreamProcessor sp = createStreamProcessorWithStore(storeWithStatus)) { + try (StreamProcessor sp = createStreamProcessorWithStoreUpdates(storeUpdates)) { sp.start(); - storeWithStatus.broadcastStatusChange(new DataStoreStatusProvider.Status(false, false)); - storeWithStatus.broadcastStatusChange(new DataStoreStatusProvider.Status(true, false)); + dataStoreStatusProvider.updateStatus(new DataStoreStatusProvider.Status(false, false)); + dataStoreStatusProvider.updateStatus(new DataStoreStatusProvider.Status(true, false)); Thread.sleep(500); assertFalse(restarted.isDone()); @@ -831,7 +835,11 @@ private StreamProcessor createStreamProcessorWithRealHttp(LDConfig config, URI s } private StreamProcessor createStreamProcessorWithStore(DataStore store) { - return new StreamProcessor(SDK_KEY, LDConfig.DEFAULT.httpConfig, mockRequestor, dataStoreUpdates(store), + return createStreamProcessorWithStoreUpdates(dataStoreUpdates(store)); + } + + private StreamProcessor createStreamProcessorWithStoreUpdates(DataStoreUpdates storeUpdates) { + return new StreamProcessor(SDK_KEY, LDConfig.DEFAULT.httpConfig, mockRequestor, storeUpdates, mockEventSourceCreator, null, STREAM_URI, DEFAULT_INITIAL_RECONNECT_DELAY); } diff --git a/src/test/java/com/launchdarkly/sdk/server/TestComponents.java b/src/test/java/com/launchdarkly/sdk/server/TestComponents.java index e097d5668..514671b82 100644 --- a/src/test/java/com/launchdarkly/sdk/server/TestComponents.java +++ b/src/test/java/com/launchdarkly/sdk/server/TestComponents.java @@ -10,6 +10,8 @@ import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider.Status; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.CacheStats; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; @@ -18,6 +20,8 @@ import com.launchdarkly.sdk.server.interfaces.Event; import com.launchdarkly.sdk.server.interfaces.EventProcessor; import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; +import com.launchdarkly.sdk.server.interfaces.PersistentDataStore; +import com.launchdarkly.sdk.server.interfaces.PersistentDataStoreFactory; import java.io.IOException; import java.util.ArrayList; @@ -29,6 +33,8 @@ import java.util.concurrent.Future; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; import static com.launchdarkly.sdk.server.DataModel.FEATURES; @@ -53,7 +59,7 @@ public static DataStore dataStoreThatThrowsException(final RuntimeException e) { } public static DataStoreUpdates dataStoreUpdates(final DataStore store) { - return new DataStoreUpdatesImpl(store, null); + return new DataStoreUpdatesImpl(store, null, null); } static EventsConfiguration defaultEventsConfig() { @@ -90,9 +96,13 @@ public static DataSourceFactory specificDataSource(final DataSource up) { } public static DataStoreFactory specificDataStore(final DataStore store) { - return context -> store; + return (context, statusUpdater) -> store; } + public static PersistentDataStoreFactory specificPersistentDataStore(final PersistentDataStore store) { + return context -> store; + } + public static EventProcessorFactory specificEventProcessor(final EventProcessor ep) { return context -> ep; } @@ -166,6 +176,21 @@ public void close() throws IOException { } } + public static class DataStoreFactoryThatExposesUpdater implements DataStoreFactory { + public volatile Consumer statusUpdater; + private final DataStoreFactory wrappedFactory; + + public DataStoreFactoryThatExposesUpdater(DataStoreFactory wrappedFactory) { + this.wrappedFactory = wrappedFactory; + } + + @Override + public DataStore createDataStore(ClientContext context, Consumer statusUpdater) { + this.statusUpdater = statusUpdater; + return wrappedFactory.createDataStore(context, statusUpdater); + } + } + private static class DataStoreThatThrowsException implements DataStore { private final RuntimeException e; @@ -194,72 +219,57 @@ public boolean upsert(DataKind kind, String key, ItemDescriptor item) { public boolean isInitialized() { return true; } + + public boolean isStatusMonitoringEnabled() { + return false; + } + + public CacheStats getCacheStats() { + return null; + } } - public static class DataStoreWithStatusUpdates implements DataStore, DataStoreStatusProvider { - private final DataStore wrappedStore; - private final List listeners = new ArrayList<>(); - volatile Status currentStatus = new Status(true, false); - - DataStoreWithStatusUpdates(DataStore wrappedStore) { - this.wrappedStore = wrappedStore; + public static class MockDataStoreStatusProvider implements DataStoreStatusProvider { + private final EventBroadcasterImpl statusBroadcaster; + private final AtomicReference lastStatus; + + public MockDataStoreStatusProvider() { + this.statusBroadcaster = new EventBroadcasterImpl<>( + DataStoreStatusProvider.StatusListener::dataStoreStatusChanged, sharedExecutor); + this.lastStatus = new AtomicReference<>(new DataStoreStatusProvider.Status(true, false)); } - public void broadcastStatusChange(final Status newStatus) { - currentStatus = newStatus; - final StatusListener[] ls; - synchronized (this) { - ls = listeners.toArray(new StatusListener[listeners.size()]); - } - Thread t = new Thread(() -> { - for (StatusListener l: ls) { - l.dataStoreStatusChanged(newStatus); + // visible for tests + public void updateStatus(DataStoreStatusProvider.Status newStatus) { + if (newStatus != null) { + DataStoreStatusProvider.Status oldStatus = lastStatus.getAndSet(newStatus); + if (!newStatus.equals(oldStatus)) { + statusBroadcaster.broadcast(newStatus); } - }); - t.start(); + } } - public void close() throws IOException { - wrappedStore.close(); - } - - public ItemDescriptor get(DataKind kind, String key) { - return wrappedStore.get(kind, key); - } - - public KeyedItems getAll(DataKind kind) { - return wrappedStore.getAll(kind); - } - - public void init(FullDataSet allData) { - wrappedStore.init(allData); - } - - public boolean upsert(DataKind kind, String key, ItemDescriptor item) { - return wrappedStore.upsert(kind, key, item); + @Override + public Status getStoreStatus() { + return lastStatus.get(); } - public boolean isInitialized() { - return wrappedStore.isInitialized(); + @Override + public void addStatusListener(StatusListener listener) { + statusBroadcaster.register(listener); } - public Status getStoreStatus() { - return currentStatus; + @Override + public void removeStatusListener(StatusListener listener) { + statusBroadcaster.unregister(listener); } - public boolean addStatusListener(StatusListener listener) { - synchronized (this) { - listeners.add(listener); - } + @Override + public boolean isStatusMonitoringEnabled() { return true; } - public void removeStatusListener(StatusListener listener) { - synchronized (this) { - listeners.remove(listener); - } - } - + @Override public CacheStats getCacheStats() { return null; } From a1f0fa6abbe5c51917e5f729117d953dbdd09814 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 5 May 2020 17:26:46 -0700 Subject: [PATCH 407/673] on second thought, leave CacheStats where it was --- .../server/DataStoreStatusProviderImpl.java | 1 - .../sdk/server/InMemoryDataStore.java | 2 +- .../server/PersistentDataStoreWrapper.java | 2 +- .../sdk/server/interfaces/DataStore.java | 2 +- .../interfaces/DataStoreStatusProvider.java | 114 +++++++++++++++++- .../sdk/server/interfaces/DataStoreTypes.java | 114 +----------------- .../PersistentDataStoreWrapperTest.java | 2 +- .../sdk/server/TestComponents.java | 2 +- 8 files changed, 120 insertions(+), 119 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImpl.java b/src/main/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImpl.java index ba31795b3..e753ddb70 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImpl.java @@ -2,7 +2,6 @@ import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.CacheStats; import java.util.concurrent.atomic.AtomicReference; diff --git a/src/main/java/com/launchdarkly/sdk/server/InMemoryDataStore.java b/src/main/java/com/launchdarkly/sdk/server/InMemoryDataStore.java index 60f089b14..b8378a32a 100644 --- a/src/main/java/com/launchdarkly/sdk/server/InMemoryDataStore.java +++ b/src/main/java/com/launchdarkly/sdk/server/InMemoryDataStore.java @@ -4,6 +4,7 @@ import com.google.common.collect.ImmutableMap; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider.CacheStats; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; @@ -13,7 +14,6 @@ import java.io.IOException; import java.util.HashMap; import java.util.Map; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.CacheStats; /** * A thread-safe, versioned store for feature flags and related data based on a diff --git a/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapper.java b/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapper.java index 13bb05e8a..05586f321 100644 --- a/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapper.java +++ b/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapper.java @@ -11,7 +11,7 @@ import com.launchdarkly.sdk.server.integrations.PersistentDataStoreBuilder; import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.CacheStats; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider.CacheStats; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStore.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStore.java index 986b64e30..50b1b543c 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStore.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStore.java @@ -1,6 +1,6 @@ package com.launchdarkly.sdk.server.interfaces; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.CacheStats; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider.CacheStats; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProvider.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProvider.java index 4518ea864..a4fa9ce46 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProvider.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProvider.java @@ -76,9 +76,9 @@ public interface DataStoreStatusProvider { * not a persistent store, or because you did not enable cache monitoring with * {@link PersistentDataStoreBuilder#recordCacheStats(boolean)}. * - * @return a {@link DataStoreTypes.CacheStats} instance; null if not applicable + * @return a {@link CacheStats} instance; null if not applicable */ - public DataStoreTypes.CacheStats getCacheStats(); + public CacheStats getCacheStats(); /** * Information about a status change. @@ -152,4 +152,114 @@ public static interface StatusListener { */ public void dataStoreStatusChanged(Status newStatus); } + + /** + * 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. + * + * @see DataStoreStatusProvider#getCacheStats() + * @see PersistentDataStoreBuilder#recordCacheStats(boolean) + * @since 4.12.0 + */ + 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 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) { + 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/sdk/server/interfaces/DataStoreTypes.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypes.java index 123233b97..c92a3048c 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypes.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypes.java @@ -1,7 +1,6 @@ package com.launchdarkly.sdk.server.interfaces; import com.google.common.collect.ImmutableList; -import com.launchdarkly.sdk.server.integrations.PersistentDataStoreBuilder; import java.util.Map; import java.util.Objects; @@ -9,6 +8,9 @@ /** * Types that are used by the {@link DataStore} interface. + *

    + * Applications should never need to use any of these types unless they are implementing a custom + * data store. * * @since 5.0.0 */ @@ -294,114 +296,4 @@ public KeyedItems(Iterable> items) { this.items = items == null ? ImmutableList.of() : items; } } - - /** - * 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. - * - * @see DataStoreStatusProvider#getCacheStats() - * @see PersistentDataStoreBuilder#recordCacheStats(boolean) - * @since 4.12.0 - */ - 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 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) { - 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/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperTest.java b/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperTest.java index b3ec69576..0b7f39dfa 100644 --- a/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperTest.java @@ -7,7 +7,7 @@ import com.launchdarkly.sdk.server.integrations.MockPersistentDataStore; import com.launchdarkly.sdk.server.integrations.PersistentDataStoreBuilder; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.CacheStats; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider.CacheStats; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.SerializedItemDescriptor; diff --git a/src/test/java/com/launchdarkly/sdk/server/TestComponents.java b/src/test/java/com/launchdarkly/sdk/server/TestComponents.java index 514671b82..65554d7cf 100644 --- a/src/test/java/com/launchdarkly/sdk/server/TestComponents.java +++ b/src/test/java/com/launchdarkly/sdk/server/TestComponents.java @@ -10,8 +10,8 @@ import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider.CacheStats; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider.Status; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.CacheStats; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; From 7675a9343027aada0f56c2d6cfc6e881c686821d Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 5 May 2020 17:32:38 -0700 Subject: [PATCH 408/673] javadoc fixes --- src/main/java/com/launchdarkly/sdk/server/LDClient.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClient.java b/src/main/java/com/launchdarkly/sdk/server/LDClient.java index 0efbe7829..a1a91e6d8 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClient.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClient.java @@ -108,10 +108,10 @@ private static final DataModel.Segment getSegment(DataStore store, String key) { * 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 + * {@link Components#externalUpdatesOnly()}, 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 + * connects, or when the timeout set by {@link LDConfig.Builder#startWait(java.time.Duration)} (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()}. From 8e823cccc3fe191ead8687f3c18135254a5110e8 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 5 May 2020 17:59:35 -0700 Subject: [PATCH 409/673] 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 bcca8b7c8aa21106014410f5320460dda87d39c3 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 5 May 2020 18:13:05 -0700 Subject: [PATCH 410/673] rename DataStoreUpdates to DataSourceUpdates --- .../launchdarkly/sdk/server/Components.java | 16 +++++------ ...esImpl.java => DataSourceUpdatesImpl.java} | 8 +++--- .../com/launchdarkly/sdk/server/LDClient.java | 6 ++-- .../sdk/server/PollingProcessor.java | 10 +++---- .../sdk/server/StreamProcessor.java | 26 ++++++++--------- .../integrations/FileDataSourceBuilder.java | 6 ++-- .../integrations/FileDataSourceImpl.java | 10 +++---- .../server/interfaces/DataSourceFactory.java | 4 +-- ...oreUpdates.java => DataSourceUpdates.java} | 18 ++++++------ ...st.java => DataSourceUpdatesImplTest.java} | 26 ++++++++--------- .../sdk/server/LDClientListenersTest.java | 2 +- .../launchdarkly/sdk/server/LDClientTest.java | 8 +++--- .../sdk/server/PollingProcessorTest.java | 4 +-- .../sdk/server/StreamProcessorTest.java | 20 ++++++------- .../sdk/server/TestComponents.java | 28 +++++++++---------- .../integrations/FileDataSourceTest.java | 4 +-- 16 files changed, 97 insertions(+), 99 deletions(-) rename src/main/java/com/launchdarkly/sdk/server/{DataStoreUpdatesImpl.java => DataSourceUpdatesImpl.java} (96%) rename src/main/java/com/launchdarkly/sdk/server/interfaces/{DataStoreUpdates.java => DataSourceUpdates.java} (71%) rename src/test/java/com/launchdarkly/sdk/server/{DataStoreUpdatesImplTest.java => DataSourceUpdatesImplTest.java} (91%) diff --git a/src/main/java/com/launchdarkly/sdk/server/Components.java b/src/main/java/com/launchdarkly/sdk/server/Components.java index 39f57f697..017a59001 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Components.java +++ b/src/main/java/com/launchdarkly/sdk/server/Components.java @@ -14,7 +14,7 @@ import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; -import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; +import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; import com.launchdarkly.sdk.server.interfaces.DiagnosticDescription; import com.launchdarkly.sdk.server.interfaces.Event; import com.launchdarkly.sdk.server.interfaces.EventProcessor; @@ -331,7 +331,7 @@ private static final class NullDataSourceFactory implements DataSourceFactory, D static final NullDataSourceFactory INSTANCE = new NullDataSourceFactory(); @Override - public DataSource createDataSource(ClientContext context, DataStoreUpdates dataStoreUpdates) { + public DataSource createDataSource(ClientContext context, DataSourceUpdates dataSourceUpdates) { if (context.isOffline()) { // If they have explicitly called offline(true) to disable everything, we'll log this slightly // more specific message. @@ -376,11 +376,11 @@ public void close() throws IOException {} private static final class StreamingDataSourceBuilderImpl extends StreamingDataSourceBuilder implements DiagnosticDescription { @Override - public DataSource createDataSource(ClientContext context, DataStoreUpdates dataStoreUpdates) { + public DataSource createDataSource(ClientContext context, DataSourceUpdates dataSourceUpdates) { // Note, we log startup messages under the LDClient class to keep logs more readable if (context.isOffline()) { - return Components.externalUpdatesOnly().createDataSource(context, dataStoreUpdates); + return Components.externalUpdatesOnly().createDataSource(context, dataSourceUpdates); } LDClient.logger.info("Enabling streaming API"); @@ -406,7 +406,7 @@ public DataSource createDataSource(ClientContext context, DataStoreUpdates dataS context.getSdkKey(), context.getHttpConfiguration(), requestor, - dataStoreUpdates, + dataSourceUpdates, null, ClientContextImpl.get(context).diagnosticAccumulator, streamUri, @@ -434,11 +434,11 @@ public LDValue describeConfiguration(LDConfig config) { private static final class PollingDataSourceBuilderImpl extends PollingDataSourceBuilder implements DiagnosticDescription { @Override - public DataSource createDataSource(ClientContext context, DataStoreUpdates dataStoreUpdates) { + public DataSource createDataSource(ClientContext context, DataSourceUpdates dataSourceUpdates) { // Note, we log startup messages under the LDClient class to keep logs more readable if (context.isOffline()) { - return Components.externalUpdatesOnly().createDataSource(context, dataStoreUpdates); + return Components.externalUpdatesOnly().createDataSource(context, dataSourceUpdates); } LDClient.logger.info("Disabling streaming API"); @@ -452,7 +452,7 @@ public DataSource createDataSource(ClientContext context, DataStoreUpdates dataS ); return new PollingProcessor( requestor, - dataStoreUpdates, + dataSourceUpdates, ClientContextImpl.get(context).sharedExecutor, pollInterval ); diff --git a/src/main/java/com/launchdarkly/sdk/server/DataStoreUpdatesImpl.java b/src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java similarity index 96% rename from src/main/java/com/launchdarkly/sdk/server/DataStoreUpdatesImpl.java rename to src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java index b815654ed..af3fdb96d 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DataStoreUpdatesImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java @@ -9,7 +9,7 @@ import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; -import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; +import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; @@ -30,13 +30,13 @@ * * @since 4.11.0 */ -final class DataStoreUpdatesImpl implements DataStoreUpdates { +final class DataSourceUpdatesImpl implements DataSourceUpdates { private final DataStore store; private final EventBroadcasterImpl flagChangeEventNotifier; private final DataModelDependencies.DependencyTracker dependencyTracker = new DataModelDependencies.DependencyTracker(); private final DataStoreStatusProvider dataStoreStatusProvider; - DataStoreUpdatesImpl( + DataSourceUpdatesImpl( DataStore store, EventBroadcasterImpl flagChangeEventNotifier, DataStoreStatusProvider dataStoreStatusProvider @@ -87,7 +87,7 @@ public void upsert(DataKind kind, String key, ItemDescriptor item) { } @Override - public DataStoreStatusProvider getStatusProvider() { + public DataStoreStatusProvider getDataStoreStatusProvider() { return dataStoreStatusProvider; } diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClient.java b/src/main/java/com/launchdarkly/sdk/server/LDClient.java index 6151ad5bf..0b009b652 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClient.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClient.java @@ -13,7 +13,7 @@ import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; -import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; +import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; import com.launchdarkly.sdk.server.interfaces.Event; import com.launchdarkly.sdk.server.interfaces.EventProcessor; import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; @@ -147,12 +147,12 @@ public DataModel.Segment getSegment(String key) { DataSourceFactory dataSourceFactory = config.dataSourceFactory == null ? Components.streamingDataSource() : config.dataSourceFactory; - DataStoreUpdates dataStoreUpdates = new DataStoreUpdatesImpl( + DataSourceUpdates dataSourceUpdates = new DataSourceUpdatesImpl( dataStore, flagChangeEventNotifier, dataStoreStatusProvider ); - this.dataSource = dataSourceFactory.createDataSource(context, dataStoreUpdates); + this.dataSource = dataSourceFactory.createDataSource(context, dataSourceUpdates); Future startFuture = dataSource.start(); if (!config.startWait.isZero() && !config.startWait.isNegative()) { diff --git a/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java b/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java index 3ebb084b7..c431d62b7 100644 --- a/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java @@ -2,7 +2,7 @@ import com.google.common.annotations.VisibleForTesting; import com.launchdarkly.sdk.server.interfaces.DataSource; -import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; +import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; import com.launchdarkly.sdk.server.interfaces.SerializationException; import org.slf4j.Logger; @@ -23,19 +23,19 @@ final class PollingProcessor implements DataSource { private static final Logger logger = LoggerFactory.getLogger(PollingProcessor.class); @VisibleForTesting final FeatureRequestor requestor; - private final DataStoreUpdates dataStoreUpdates; + private final DataSourceUpdates dataSourceUpdates; private final ScheduledExecutorService scheduler; @VisibleForTesting final Duration pollInterval; private AtomicBoolean initialized = new AtomicBoolean(false); PollingProcessor( FeatureRequestor requestor, - DataStoreUpdates dataStoreUpdates, + DataSourceUpdates dataSourceUpdates, ScheduledExecutorService sharedExecutor, Duration pollInterval ) { this.requestor = requestor; // note that HTTP configuration is applied to the requestor when it is created - this.dataStoreUpdates = dataStoreUpdates; + this.dataSourceUpdates = dataSourceUpdates; this.scheduler = sharedExecutor; this.pollInterval = pollInterval; } @@ -60,7 +60,7 @@ public Future start() { scheduler.scheduleAtFixedRate(() -> { try { FeatureRequestor.AllData allData = requestor.getAllData(); - dataStoreUpdates.init(allData.toFullDataSet()); + dataSourceUpdates.init(allData.toFullDataSet()); if (!initialized.getAndSet(true)) { logger.info("Initialized LaunchDarkly client."); initFuture.complete(null); diff --git a/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java index ab32e5071..82642cbf1 100644 --- a/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java @@ -14,7 +14,7 @@ import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; -import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; +import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; import com.launchdarkly.sdk.server.interfaces.SerializationException; @@ -71,7 +71,7 @@ final class StreamProcessor implements DataSource { private static final Logger logger = LoggerFactory.getLogger(StreamProcessor.class); private static final Duration DEAD_CONNECTION_INTERVAL = Duration.ofSeconds(300); - private final DataStoreUpdates dataStoreUpdates; + private final DataSourceUpdates dataSourceUpdates; private final HttpConfiguration httpConfig; private final Headers headers; @VisibleForTesting final URI streamUri; @@ -115,13 +115,13 @@ static interface EventSourceCreator { String sdkKey, HttpConfiguration httpConfig, FeatureRequestor requestor, - DataStoreUpdates dataStoreUpdates, + DataSourceUpdates dataSourceUpdates, EventSourceCreator eventSourceCreator, DiagnosticAccumulator diagnosticAccumulator, URI streamUri, Duration initialReconnectDelay ) { - this.dataStoreUpdates = dataStoreUpdates; + this.dataSourceUpdates = dataSourceUpdates; this.httpConfig = httpConfig; this.requestor = requestor; this.diagnosticAccumulator = diagnosticAccumulator; @@ -134,10 +134,10 @@ static interface EventSourceCreator { .build(); DataStoreStatusProvider.StatusListener statusListener = this::onStoreStatusChanged; - if (dataStoreUpdates.getStatusProvider() != null && - dataStoreUpdates.getStatusProvider().isStatusMonitoringEnabled()) { + if (dataSourceUpdates.getDataStoreStatusProvider() != null && + dataSourceUpdates.getDataStoreStatusProvider().isStatusMonitoringEnabled()) { this.statusListener = this::onStoreStatusChanged; - dataStoreUpdates.getStatusProvider().addStatusListener(statusListener); + dataSourceUpdates.getDataStoreStatusProvider().addStatusListener(statusListener); } else { this.statusListener = null; } @@ -208,7 +208,7 @@ private void recordStreamInit(boolean failed) { public void close() throws IOException { logger.info("Closing LaunchDarkly StreamProcessor"); if (statusListener != null) { - dataStoreUpdates.getStatusProvider().removeStatusListener(statusListener); + dataSourceUpdates.getDataStoreStatusProvider().removeStatusListener(statusListener); } if (es != null) { es.close(); @@ -295,7 +295,7 @@ private void handlePut(String eventData) throws StreamInputException, StreamStor PutData putData = parseStreamJson(PutData.class, eventData); FullDataSet allData = putData.data.toFullDataSet(); try { - dataStoreUpdates.init(allData); + dataSourceUpdates.init(allData); } catch (Exception e) { throw new StreamStoreException(e); } @@ -315,7 +315,7 @@ private void handlePatch(String eventData) throws StreamInputException, StreamSt String key = kindAndKey.getValue(); VersionedData item = deserializeFromParsedJson(kind, data.data); try { - dataStoreUpdates.upsert(kind, key, new ItemDescriptor(item.getVersion(), item)); + dataSourceUpdates.upsert(kind, key, new ItemDescriptor(item.getVersion(), item)); } catch (Exception e) { throw new StreamStoreException(e); } @@ -331,7 +331,7 @@ private void handleDelete(String eventData) throws StreamInputException, StreamS String key = kindAndKey.getValue(); ItemDescriptor placeholder = new ItemDescriptor(data.version, null); try { - dataStoreUpdates.upsert(kind, key, placeholder); + dataSourceUpdates.upsert(kind, key, placeholder); } catch (Exception e) { throw new StreamStoreException(e); } @@ -346,7 +346,7 @@ private void handleIndirectPut() throws StreamInputException, StreamStoreExcepti } FullDataSet allData = putData.toFullDataSet(); try { - dataStoreUpdates.init(allData); + dataSourceUpdates.init(allData); } catch (Exception e) { throw new StreamStoreException(e); } @@ -370,7 +370,7 @@ private void handleIndirectPatch(String path) throws StreamInputException, Strea // assume that we did not get valid data from LD so we have missed an update. } try { - dataStoreUpdates.upsert(kind, key, new ItemDescriptor(item.getVersion(), item)); + dataSourceUpdates.upsert(kind, key, new ItemDescriptor(item.getVersion(), item)); } catch (Exception e) { throw new StreamStoreException(e); } diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBuilder.java b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBuilder.java index 429f27382..77ba1f215 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBuilder.java @@ -3,7 +3,7 @@ import com.launchdarkly.sdk.server.interfaces.ClientContext; import com.launchdarkly.sdk.server.interfaces.DataSource; import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; -import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; +import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; import java.nio.file.InvalidPathException; import java.nio.file.Path; @@ -77,7 +77,7 @@ public FileDataSourceBuilder autoUpdate(boolean autoUpdate) { * Used internally by the LaunchDarkly client. */ @Override - public DataSource createDataSource(ClientContext context, DataStoreUpdates dataStoreUpdates) { - return new FileDataSourceImpl(dataStoreUpdates, sources, autoUpdate); + public DataSource createDataSource(ClientContext context, DataSourceUpdates dataSourceUpdates) { + return new FileDataSourceImpl(dataSourceUpdates, sources, autoUpdate); } } \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java index f4d93ffcb..7aa77d44e 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java @@ -11,7 +11,7 @@ import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; -import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; +import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -50,13 +50,13 @@ final class FileDataSourceImpl implements DataSource { private static final Logger logger = LoggerFactory.getLogger(FileDataSourceImpl.class); - private final DataStoreUpdates dataStoreUpdates; + private final DataSourceUpdates dataSourceUpdates; private final DataLoader dataLoader; private final AtomicBoolean inited = new AtomicBoolean(false); private final FileWatcher fileWatcher; - FileDataSourceImpl(DataStoreUpdates dataStoreUpdates, List sources, boolean autoUpdate) { - this.dataStoreUpdates = dataStoreUpdates; + FileDataSourceImpl(DataSourceUpdates dataSourceUpdates, List sources, boolean autoUpdate) { + this.dataSourceUpdates = dataSourceUpdates; this.dataLoader = new DataLoader(sources); FileWatcher fw = null; @@ -98,7 +98,7 @@ private boolean reload() { logger.error(e.getDescription()); return false; } - dataStoreUpdates.init(builder.build()); + dataSourceUpdates.init(builder.build()); inited.set(true); return true; } diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceFactory.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceFactory.java index 87f9c1482..e615d5b6b 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceFactory.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceFactory.java @@ -12,8 +12,8 @@ public interface DataSourceFactory { * Creates an implementation instance. * * @param context allows access to the client configuration - * @param dataStoreUpdates the component pushes data into the SDK via this interface + * @param dataSourceUpdates the component pushes data into the SDK via this interface * @return an {@link DataSource} */ - public DataSource createDataSource(ClientContext context, DataStoreUpdates dataStoreUpdates); + public DataSource createDataSource(ClientContext context, DataSourceUpdates dataSourceUpdates); } diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreUpdates.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceUpdates.java similarity index 71% rename from src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreUpdates.java rename to src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceUpdates.java index 409369833..9e75393fd 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreUpdates.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceUpdates.java @@ -5,15 +5,14 @@ import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; /** - * Interface that a data source implementation will use to push data into the underlying - * data store. + * Interface that a data source implementation will use to push data into the SDK. *

    - * This layer of indirection allows the SDK to perform any other necessary operations that must - * happen when data is updated, by providing its own implementation of {@link DataStoreUpdates}. + * The data source interacts with this object, rather than manipulating the data store directly, so + * that the SDK can perform any other necessary operations that must happen when data is updated. * * @since 5.0.0 */ -public interface DataStoreUpdates { +public interface DataSourceUpdates { /** * Overwrites the store's contents with a set of items for each collection. *

    @@ -44,12 +43,11 @@ public interface DataStoreUpdates { /** * Returns an object that provides status tracking for the data store, if applicable. *

    - * For data stores that do not support status tracking (the in-memory store, or a custom implementation - * that is not based on the SDK's usual persistent data store mechanism), it returns a stub - * implementation that returns null from {@link DataStoreStatusProvider#getStoreStatus()} and - * false from {@link DataStoreStatusProvider#addStatusListener(com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider.StatusListener)}. + * This may be useful if the data source needs to be aware of storage problems that might require it + * to take some special action: for instance, if a database outage may have caused some data to be + * lost and therefore the data should be re-requested from LaunchDarkly. * * @return a {@link DataStoreStatusProvider} */ - DataStoreStatusProvider getStatusProvider(); + DataStoreStatusProvider getDataStoreStatusProvider(); } diff --git a/src/test/java/com/launchdarkly/sdk/server/DataStoreUpdatesImplTest.java b/src/test/java/com/launchdarkly/sdk/server/DataSourceUpdatesImplTest.java similarity index 91% rename from src/test/java/com/launchdarkly/sdk/server/DataStoreUpdatesImplTest.java rename to src/test/java/com/launchdarkly/sdk/server/DataSourceUpdatesImplTest.java index a95f464d5..601bdd786 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataStoreUpdatesImplTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataSourceUpdatesImplTest.java @@ -38,7 +38,7 @@ import static org.junit.Assert.fail; @SuppressWarnings("javadoc") -public class DataStoreUpdatesImplTest extends EasyMockSupport { +public class DataSourceUpdatesImplTest extends EasyMockSupport { // Note that these tests must use the actual data model types for flags and segments, rather than the // TestItem type from DataStoreTestTypes, because the dependency behavior is based on the real data model. @@ -53,7 +53,7 @@ public void tearDown() { @Test public void doesNotTryToSendEventsIfThereIsNoEventPublisher() { DataStore store = inMemoryDataStore(); - DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, null, null); + DataSourceUpdatesImpl storeUpdates = new DataSourceUpdatesImpl(store, null, null); storeUpdates.upsert(DataModel.FEATURES, "key", new ItemDescriptor(1, flagBuilder("key").build())); // the test is just that this doesn't cause an exception } @@ -67,7 +67,7 @@ public void sendsEventsOnInitForNewlyAddedFlags() throws Exception { .addAny(SEGMENTS, segmentBuilder("segment1").version(1).build()); - DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, flagChangeBroadcaster, null); + DataSourceUpdatesImpl storeUpdates = new DataSourceUpdatesImpl(store, flagChangeBroadcaster, null); storeUpdates.init(builder.build()); @@ -92,7 +92,7 @@ public void sendsEventOnUpdateForNewlyAddedFlag() throws Exception { .addAny(SEGMENTS, segmentBuilder("segment1").version(1).build()); - DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, flagChangeBroadcaster, null); + DataSourceUpdatesImpl storeUpdates = new DataSourceUpdatesImpl(store, flagChangeBroadcaster, null); storeUpdates.init(builder.build()); @@ -115,7 +115,7 @@ public void sendsEventsOnInitForUpdatedFlags() throws Exception { segmentBuilder("segment1").version(1).build(), segmentBuilder("segment2").version(1).build()); - DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, flagChangeBroadcaster, null); + DataSourceUpdatesImpl storeUpdates = new DataSourceUpdatesImpl(store, flagChangeBroadcaster, null); storeUpdates.init(builder.build()); @@ -139,7 +139,7 @@ public void sendsEventOnUpdateForUpdatedFlag() throws Exception { .addAny(SEGMENTS, segmentBuilder("segment1").version(1).build()); - DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, flagChangeBroadcaster, null); + DataSourceUpdatesImpl storeUpdates = new DataSourceUpdatesImpl(store, flagChangeBroadcaster, null); storeUpdates.init(builder.build()); @@ -161,7 +161,7 @@ public void sendsEventsOnInitForDeletedFlags() throws Exception { .addAny(SEGMENTS, segmentBuilder("segment1").version(1).build()); - DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, flagChangeBroadcaster, null); + DataSourceUpdatesImpl storeUpdates = new DataSourceUpdatesImpl(store, flagChangeBroadcaster, null); storeUpdates.init(builder.build()); @@ -187,7 +187,7 @@ public void sendsEventOnUpdateForDeletedFlag() throws Exception { .addAny(SEGMENTS, segmentBuilder("segment1").version(1).build()); - DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, flagChangeBroadcaster, null); + DataSourceUpdatesImpl storeUpdates = new DataSourceUpdatesImpl(store, flagChangeBroadcaster, null); storeUpdates.init(builder.build()); @@ -211,7 +211,7 @@ public void sendsEventsOnInitForFlagsWhosePrerequisitesChanged() throws Exceptio flagBuilder("flag5").version(1).prerequisites(prerequisite("flag4", 0)).build(), flagBuilder("flag6").version(1).build()); - DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, flagChangeBroadcaster, null); + DataSourceUpdatesImpl storeUpdates = new DataSourceUpdatesImpl(store, flagChangeBroadcaster, null); storeUpdates.init(builder.build()); @@ -236,7 +236,7 @@ public void sendsEventsOnUpdateForFlagsWhosePrerequisitesChanged() throws Except flagBuilder("flag5").version(1).prerequisites(prerequisite("flag4", 0)).build(), flagBuilder("flag6").version(1).build()); - DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, flagChangeBroadcaster, null); + DataSourceUpdatesImpl storeUpdates = new DataSourceUpdatesImpl(store, flagChangeBroadcaster, null); storeUpdates.init(builder.build()); @@ -265,7 +265,7 @@ public void sendsEventsOnInitForFlagsWhoseSegmentsChanged() throws Exception { segmentBuilder("segment1").version(1).build(), segmentBuilder("segment2").version(1).build()); - DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, flagChangeBroadcaster, null); + DataSourceUpdatesImpl storeUpdates = new DataSourceUpdatesImpl(store, flagChangeBroadcaster, null); storeUpdates.init(builder.build()); @@ -294,7 +294,7 @@ public void sendsEventsOnUpdateForFlagsWhoseSegmentsChanged() throws Exception { segmentBuilder("segment1").version(1).build(), segmentBuilder("segment2").version(1).build()); - DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, flagChangeBroadcaster, null); + DataSourceUpdatesImpl storeUpdates = new DataSourceUpdatesImpl(store, flagChangeBroadcaster, null); storeUpdates.init(builder.build()); @@ -318,7 +318,7 @@ public void dataSetIsPassedToDataStoreInCorrectOrder() throws Exception { store.init(EasyMock.capture(captureData)); replay(store); - DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, null, null); + DataSourceUpdatesImpl storeUpdates = new DataSourceUpdatesImpl(store, null, null); storeUpdates.init(DEPENDENCY_ORDERING_TEST_DATA); Map> dataMap = toDataMap(captureData.getValue()); diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java index 7d461ba7f..920c87939 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java @@ -33,7 +33,7 @@ * related methods for looking at the same kinds of status values that can be broadcast to listeners. *

    * Parts of this functionality are also covered by lower-level component tests like - * DataStoreUpdatesImplTest. However, the tests here verify that the client is wiring the components + * DataSourceUpdatesImplTest. However, the tests here verify that the client is wiring the components * together correctly so that they work from an application's point of view. */ @SuppressWarnings("javadoc") diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java index e5dc17d11..32cf32690 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java @@ -6,7 +6,7 @@ import com.launchdarkly.sdk.server.interfaces.DataSource; import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; import com.launchdarkly.sdk.server.interfaces.DataStore; -import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; +import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; import com.launchdarkly.sdk.server.interfaces.Event; import com.launchdarkly.sdk.server.interfaces.EventProcessor; import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; @@ -159,7 +159,7 @@ public void sameDiagnosticAccumulatorPassedToFactoriesWhenSupported() throws IOE Capture capturedDataSourceContext = Capture.newInstance(); expect(mockDataSourceFactory.createDataSource(capture(capturedDataSourceContext), - isA(DataStoreUpdates.class))).andReturn(failedDataSource()); + isA(DataSourceUpdates.class))).andReturn(failedDataSource()); replayAll(); @@ -183,7 +183,7 @@ public void nullDiagnosticAccumulatorPassedToFactoriesWhenOptedOut() throws IOEx Capture capturedDataSourceContext = Capture.newInstance(); expect(mockDataSourceFactory.createDataSource(capture(capturedDataSourceContext), - isA(DataStoreUpdates.class))).andReturn(failedDataSource()); + isA(DataSourceUpdates.class))).andReturn(failedDataSource()); replayAll(); @@ -212,7 +212,7 @@ public void nullDiagnosticAccumulatorPassedToUpdateFactoryWhenEventProcessorDoes Capture capturedDataSourceContext = Capture.newInstance(); expect(mockEventProcessorFactory.createEventProcessor(capture(capturedEventContext))).andReturn(mockEventProcessor); expect(mockDataSourceFactory.createDataSource(capture(capturedDataSourceContext), - isA(DataStoreUpdates.class))).andReturn(failedDataSource()); + isA(DataSourceUpdates.class))).andReturn(failedDataSource()); replayAll(); diff --git a/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java index 9918b5318..84b658d4b 100644 --- a/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java @@ -15,7 +15,7 @@ import java.util.concurrent.TimeoutException; import static com.launchdarkly.sdk.server.TestComponents.clientContext; -import static com.launchdarkly.sdk.server.TestComponents.dataStoreUpdates; +import static com.launchdarkly.sdk.server.TestComponents.dataSourceUpdates; import static com.launchdarkly.sdk.server.TestComponents.sharedExecutor; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; @@ -29,7 +29,7 @@ public class PollingProcessorTest { private static final Duration LENGTHY_INTERVAL = Duration.ofSeconds(60); private PollingProcessor makeProcessor(FeatureRequestor requestor, DataStore store) { - return new PollingProcessor(requestor, dataStoreUpdates(store), sharedExecutor, LENGTHY_INTERVAL); + return new PollingProcessor(requestor, dataSourceUpdates(store), sharedExecutor, LENGTHY_INTERVAL); } @Test diff --git a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java index 579c7fa97..945b3cdf5 100644 --- a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java @@ -12,7 +12,7 @@ import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; -import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; +import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; import org.easymock.EasyMockSupport; import org.junit.Before; @@ -38,7 +38,7 @@ import static com.launchdarkly.sdk.server.ModelBuilders.segmentBuilder; import static com.launchdarkly.sdk.server.TestComponents.clientContext; import static com.launchdarkly.sdk.server.TestComponents.dataStoreThatThrowsException; -import static com.launchdarkly.sdk.server.TestComponents.dataStoreUpdates; +import static com.launchdarkly.sdk.server.TestComponents.dataSourceUpdates; import static com.launchdarkly.sdk.server.TestHttpUtil.eventStreamResponse; import static com.launchdarkly.sdk.server.TestHttpUtil.makeStartedServer; import static com.launchdarkly.sdk.server.TestUtil.upsertFlag; @@ -92,7 +92,7 @@ public void setup() { public void builderHasDefaultConfiguration() throws Exception { DataSourceFactory f = Components.streamingDataSource(); try (StreamProcessor sp = (StreamProcessor)f.createDataSource(clientContext(SDK_KEY, LDConfig.DEFAULT), - dataStoreUpdates(dataStore))) { + dataSourceUpdates(dataStore))) { assertThat(sp.initialReconnectDelay, equalTo(StreamingDataSourceBuilder.DEFAULT_INITIAL_RECONNECT_DELAY)); assertThat(sp.streamUri, equalTo(LDConfig.DEFAULT_STREAM_URI)); assertThat(((DefaultFeatureRequestor)sp.requestor).baseUri, equalTo(LDConfig.DEFAULT_BASE_URI)); @@ -108,7 +108,7 @@ public void builderCanSpecifyConfiguration() throws Exception { .initialReconnectDelay(Duration.ofMillis(5555)) .pollingBaseURI(pollUri); try (StreamProcessor sp = (StreamProcessor)f.createDataSource(clientContext(SDK_KEY, LDConfig.DEFAULT), - dataStoreUpdates(dataStore))) { + dataSourceUpdates(dataStore))) { assertThat(sp.initialReconnectDelay, equalTo(Duration.ofMillis(5555))); assertThat(sp.streamUri, equalTo(streamUri)); assertThat(((DefaultFeatureRequestor)sp.requestor).baseUri, equalTo(pollUri)); @@ -529,7 +529,7 @@ public void indirectPatchWithFailedPollCausesStreamRestart() throws Exception { @Test public void restartsStreamIfStoreNeedsRefresh() throws Exception { MockDataStoreStatusProvider dataStoreStatusProvider = new MockDataStoreStatusProvider(); - DataStoreUpdates storeUpdates = new DataStoreUpdatesImpl(dataStore, null, dataStoreStatusProvider); + DataSourceUpdates storeUpdates = new DataSourceUpdatesImpl(dataStore, null, dataStoreStatusProvider); CompletableFuture restarted = new CompletableFuture<>(); mockEventSource.start(); @@ -559,7 +559,7 @@ public void restartsStreamIfStoreNeedsRefresh() throws Exception { @Test public void doesNotRestartStreamIfStoreHadOutageButDoesNotNeedRefresh() throws Exception { MockDataStoreStatusProvider dataStoreStatusProvider = new MockDataStoreStatusProvider(); - DataStoreUpdates storeUpdates = new DataStoreUpdatesImpl(dataStore, null, dataStoreStatusProvider); + DataSourceUpdates storeUpdates = new DataSourceUpdatesImpl(dataStore, null, dataStoreStatusProvider); CompletableFuture restarted = new CompletableFuture<>(); mockEventSource.start(); @@ -824,21 +824,21 @@ private StreamProcessor createStreamProcessor(LDConfig config, URI streamUri) { } private StreamProcessor createStreamProcessor(LDConfig config, URI streamUri, DiagnosticAccumulator diagnosticAccumulator) { - return new StreamProcessor(SDK_KEY, config.httpConfig, mockRequestor, dataStoreUpdates(dataStore), + return new StreamProcessor(SDK_KEY, config.httpConfig, mockRequestor, dataSourceUpdates(dataStore), mockEventSourceCreator, diagnosticAccumulator, streamUri, DEFAULT_INITIAL_RECONNECT_DELAY); } private StreamProcessor createStreamProcessorWithRealHttp(LDConfig config, URI streamUri) { - return new StreamProcessor(SDK_KEY, config.httpConfig, mockRequestor, dataStoreUpdates(dataStore), null, null, + return new StreamProcessor(SDK_KEY, config.httpConfig, mockRequestor, dataSourceUpdates(dataStore), null, null, streamUri, DEFAULT_INITIAL_RECONNECT_DELAY); } private StreamProcessor createStreamProcessorWithStore(DataStore store) { - return createStreamProcessorWithStoreUpdates(dataStoreUpdates(store)); + return createStreamProcessorWithStoreUpdates(dataSourceUpdates(store)); } - private StreamProcessor createStreamProcessorWithStoreUpdates(DataStoreUpdates storeUpdates) { + private StreamProcessor createStreamProcessorWithStoreUpdates(DataSourceUpdates storeUpdates) { return new StreamProcessor(SDK_KEY, LDConfig.DEFAULT.httpConfig, mockRequestor, storeUpdates, mockEventSourceCreator, null, STREAM_URI, DEFAULT_INITIAL_RECONNECT_DELAY); } diff --git a/src/test/java/com/launchdarkly/sdk/server/TestComponents.java b/src/test/java/com/launchdarkly/sdk/server/TestComponents.java index 65554d7cf..10cb76b07 100644 --- a/src/test/java/com/launchdarkly/sdk/server/TestComponents.java +++ b/src/test/java/com/launchdarkly/sdk/server/TestComponents.java @@ -16,7 +16,7 @@ import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; -import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; +import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; import com.launchdarkly.sdk.server.interfaces.Event; import com.launchdarkly.sdk.server.interfaces.EventProcessor; import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; @@ -51,15 +51,15 @@ public static ClientContext clientContext(final String sdkKey, final LDConfig co } public static DataSourceFactory dataSourceWithData(FullDataSet data) { - return (context, dataStoreUpdates) -> new DataSourceWithData(data, dataStoreUpdates); + return (context, dataSourceUpdates) -> new DataSourceWithData(data, dataSourceUpdates); } public static DataStore dataStoreThatThrowsException(final RuntimeException e) { return new DataStoreThatThrowsException(e); } - public static DataStoreUpdates dataStoreUpdates(final DataStore store) { - return new DataStoreUpdatesImpl(store, null, null); + public static DataSourceUpdates dataSourceUpdates(final DataStore store) { + return new DataSourceUpdatesImpl(store, null, null); } static EventsConfiguration defaultEventsConfig() { @@ -92,7 +92,7 @@ static EventsConfiguration makeEventsConfig(boolean allAttributesPrivate, boolea } public static DataSourceFactory specificDataSource(final DataSource up) { - return (context, dataStoreUpdates) -> up; + return (context, dataSourceUpdates) -> up; } public static DataStoreFactory specificDataStore(final DataStore store) { @@ -124,20 +124,20 @@ public void flush() {} public static class DataSourceFactoryThatExposesUpdater implements DataSourceFactory { private final FullDataSet initialData; - private DataStoreUpdates dataStoreUpdates; + private DataSourceUpdates dataSourceUpdates; public DataSourceFactoryThatExposesUpdater(FullDataSet initialData) { this.initialData = initialData; } @Override - public DataSource createDataSource(ClientContext context, DataStoreUpdates dataStoreUpdates) { - this.dataStoreUpdates = dataStoreUpdates; - return dataSourceWithData(initialData).createDataSource(context, dataStoreUpdates); + public DataSource createDataSource(ClientContext context, DataSourceUpdates dataSourceUpdates) { + this.dataSourceUpdates = dataSourceUpdates; + return dataSourceWithData(initialData).createDataSource(context, dataSourceUpdates); } public void updateFlag(FeatureFlag flag) { - dataStoreUpdates.upsert(FEATURES, flag.getKey(), new ItemDescriptor(flag.getVersion(), flag)); + dataSourceUpdates.upsert(FEATURES, flag.getKey(), new ItemDescriptor(flag.getVersion(), flag)); } } @@ -156,15 +156,15 @@ public void close() throws IOException { private static class DataSourceWithData implements DataSource { private final FullDataSet data; - private final DataStoreUpdates dataStoreUpdates; + private final DataSourceUpdates dataSourceUpdates; - DataSourceWithData(FullDataSet data, DataStoreUpdates dataStoreUpdates) { + DataSourceWithData(FullDataSet data, DataSourceUpdates dataSourceUpdates) { this.data = data; - this.dataStoreUpdates = dataStoreUpdates; + this.dataSourceUpdates = dataSourceUpdates; } public Future start() { - dataStoreUpdates.init(data); + dataSourceUpdates.init(data); return CompletableFuture.completedFuture(null); } diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java index 0861c178c..b2c02bc86 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java @@ -17,7 +17,7 @@ import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; import static com.launchdarkly.sdk.server.DataStoreTestTypes.toItemsMap; import static com.launchdarkly.sdk.server.TestComponents.clientContext; -import static com.launchdarkly.sdk.server.TestComponents.dataStoreUpdates; +import static com.launchdarkly.sdk.server.TestComponents.dataSourceUpdates; import static com.launchdarkly.sdk.server.TestComponents.inMemoryDataStore; import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.ALL_FLAG_KEYS; import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.ALL_SEGMENT_KEYS; @@ -44,7 +44,7 @@ private static FileDataSourceBuilder makeFactoryWithFile(Path path) { } private DataSource makeDataSource(FileDataSourceBuilder builder) { - return builder.createDataSource(clientContext("", config), dataStoreUpdates(store)); + return builder.createDataSource(clientContext("", config), dataSourceUpdates(store)); } @Test From 691354d3ea4bff1483c906be31e139b98d1e4eea Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 5 May 2020 18:41:35 -0700 Subject: [PATCH 411/673] add a new DataStoreUpdates that's for the data store to use --- .../launchdarkly/sdk/server/Components.java | 11 ++++--- .../server/DataStoreStatusProviderImpl.java | 28 ++++-------------- .../sdk/server/DataStoreUpdatesImpl.java | 29 +++++++++++++++++++ .../com/launchdarkly/sdk/server/LDClient.java | 17 ++++------- .../server/PersistentDataStoreWrapper.java | 7 ++--- .../sdk/server/interfaces/ClientContext.java | 9 +++--- .../sdk/server/interfaces/DataStore.java | 2 +- .../server/interfaces/DataStoreFactory.java | 8 ++--- .../interfaces/DataStoreStatusProvider.java | 2 +- .../server/interfaces/DataStoreUpdates.java | 20 +++++++++++++ .../sdk/server/DiagnosticEventTest.java | 5 ++-- .../sdk/server/LDClientListenersTest.java | 4 +-- .../PersistentDataStoreWrapperTest.java | 8 +++-- .../sdk/server/TestComponents.java | 13 ++++----- 14 files changed, 94 insertions(+), 69 deletions(-) create mode 100644 src/main/java/com/launchdarkly/sdk/server/DataStoreUpdatesImpl.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreUpdates.java diff --git a/src/main/java/com/launchdarkly/sdk/server/Components.java b/src/main/java/com/launchdarkly/sdk/server/Components.java index 017a59001..460f54bec 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Components.java +++ b/src/main/java/com/launchdarkly/sdk/server/Components.java @@ -11,10 +11,10 @@ import com.launchdarkly.sdk.server.interfaces.ClientContext; import com.launchdarkly.sdk.server.interfaces.DataSource; import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; +import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; -import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; -import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; +import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; import com.launchdarkly.sdk.server.interfaces.DiagnosticDescription; import com.launchdarkly.sdk.server.interfaces.Event; import com.launchdarkly.sdk.server.interfaces.EventProcessor; @@ -32,7 +32,6 @@ import java.net.URI; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Future; -import java.util.function.Consumer; import okhttp3.Credentials; @@ -294,7 +293,7 @@ public static FlagChangeListener flagValueMonitoringListener(LDClientInterface c private static final class InMemoryDataStoreFactory implements DataStoreFactory, DiagnosticDescription { static final DataStoreFactory INSTANCE = new InMemoryDataStoreFactory(); @Override - public DataStore createDataStore(ClientContext context, Consumer statusUpdater) { + public DataStore createDataStore(ClientContext context, DataStoreUpdates dataStoreUpdates) { return new InMemoryDataStore(); } @@ -565,14 +564,14 @@ public LDValue describeConfiguration(LDConfig config) { * Called by the SDK to create the data store instance. */ @Override - public DataStore createDataStore(ClientContext context, Consumer statusUpdater) { + public DataStore createDataStore(ClientContext context, DataStoreUpdates dataStoreUpdates) { PersistentDataStore core = persistentDataStoreFactory.createPersistentDataStore(context); return new PersistentDataStoreWrapper( core, cacheTime, staleValuesPolicy, recordCacheStats, - statusUpdater, + dataStoreUpdates, ClientContextImpl.get(context).sharedExecutor ); } diff --git a/src/main/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImpl.java b/src/main/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImpl.java index e753ddb70..26b67e426 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImpl.java @@ -3,47 +3,31 @@ import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; -import java.util.concurrent.atomic.AtomicReference; - -// Simple delegator to ensure that LDClient.getDataStoreStatusProvider() never returns null and that -// the application isn't given direct access to the store. final class DataStoreStatusProviderImpl implements DataStoreStatusProvider { private final DataStore store; - private final EventBroadcasterImpl statusBroadcaster; - private final AtomicReference lastStatus; + private final DataStoreUpdatesImpl dataStoreUpdates; DataStoreStatusProviderImpl( DataStore store, - EventBroadcasterImpl statusBroadcaster + DataStoreUpdatesImpl dataStoreUpdates ) { this.store = store; - this.statusBroadcaster = statusBroadcaster; - this.lastStatus = new AtomicReference<>(new DataStoreStatusProvider.Status(true, false)); // initially "available" - } - - // package-private - void updateStatus(DataStoreStatusProvider.Status newStatus) { - if (newStatus != null) { - DataStoreStatusProvider.Status oldStatus = lastStatus.getAndSet(newStatus); - if (!newStatus.equals(oldStatus)) { - statusBroadcaster.broadcast(newStatus); - } - } + this.dataStoreUpdates = dataStoreUpdates; } @Override public Status getStoreStatus() { - return lastStatus.get(); + return dataStoreUpdates.lastStatus.get(); } @Override public void addStatusListener(StatusListener listener) { - statusBroadcaster.register(listener); + dataStoreUpdates.statusBroadcaster.register(listener); } @Override public void removeStatusListener(StatusListener listener) { - statusBroadcaster.unregister(listener); + dataStoreUpdates.statusBroadcaster.unregister(listener); } @Override diff --git a/src/main/java/com/launchdarkly/sdk/server/DataStoreUpdatesImpl.java b/src/main/java/com/launchdarkly/sdk/server/DataStoreUpdatesImpl.java new file mode 100644 index 000000000..93b01eb38 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/DataStoreUpdatesImpl.java @@ -0,0 +1,29 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; + +import java.util.concurrent.atomic.AtomicReference; + +class DataStoreUpdatesImpl implements DataStoreUpdates { + // package-private because it's convenient to use these from DataStoreStatusProviderImpl + final EventBroadcasterImpl statusBroadcaster; + final AtomicReference lastStatus; + + DataStoreUpdatesImpl( + EventBroadcasterImpl statusBroadcaster + ) { + this.statusBroadcaster = statusBroadcaster; + this.lastStatus = new AtomicReference<>(new DataStoreStatusProvider.Status(true, false)); // initially "available" + } + + @Override + public void updateStatus(DataStoreStatusProvider.Status newStatus) { + if (newStatus != null) { + DataStoreStatusProvider.Status oldStatus = lastStatus.getAndSet(newStatus); + if (!newStatus.equals(oldStatus)) { + statusBroadcaster.broadcast(newStatus); + } + } + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClient.java b/src/main/java/com/launchdarkly/sdk/server/LDClient.java index e6d0521c0..6ed3e4701 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClient.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClient.java @@ -8,12 +8,12 @@ import com.launchdarkly.sdk.server.integrations.EventProcessorBuilder; import com.launchdarkly.sdk.server.interfaces.DataSource; import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; +import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; -import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; import com.launchdarkly.sdk.server.interfaces.Event; import com.launchdarkly.sdk.server.interfaces.EventProcessor; import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; @@ -158,7 +158,10 @@ public LDClient(String sdkKey, LDConfig config) { DataStoreFactory factory = config.dataStoreFactory == null ? Components.inMemoryDataStore() : config.dataStoreFactory; - this.dataStore = factory.createDataStore(context, this::updateDataStoreStatus); + EventBroadcasterImpl dataStoreStatusNotifier = + new EventBroadcasterImpl<>(DataStoreStatusProvider.StatusListener::dataStoreStatusChanged, sharedExecutor); + DataStoreUpdatesImpl dataStoreUpdates = new DataStoreUpdatesImpl(dataStoreStatusNotifier); + this.dataStore = factory.createDataStore(context, dataStoreUpdates); this.evaluator = new Evaluator(new Evaluator.Getters() { public DataModel.FeatureFlag getFlag(String key) { @@ -171,10 +174,8 @@ public DataModel.Segment getSegment(String key) { }); this.flagChangeEventNotifier = new EventBroadcasterImpl<>(FlagChangeListener::onFlagChange, sharedExecutor); - EventBroadcasterImpl dataStoreStatusNotifier = - new EventBroadcasterImpl<>(DataStoreStatusProvider.StatusListener::dataStoreStatusChanged, sharedExecutor); - this.dataStoreStatusProvider = new DataStoreStatusProviderImpl(this.dataStore, dataStoreStatusNotifier); + this.dataStoreStatusProvider = new DataStoreStatusProviderImpl(this.dataStore, dataStoreUpdates); DataSourceFactory dataSourceFactory = config.dataSourceFactory == null ? Components.streamingDataSource() : config.dataSourceFactory; @@ -518,12 +519,6 @@ private ScheduledExecutorService createSharedExecutor() { return Executors.newSingleThreadScheduledExecutor(threadFactory); } - private void updateDataStoreStatus(DataStoreStatusProvider.Status newStatus) { - if (dataStoreStatusProvider != null) { - dataStoreStatusProvider.updateStatus(newStatus); - } - } - private static String getClientVersion() { Class clazz = LDConfig.class; String className = clazz.getSimpleName() + ".class"; diff --git a/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapper.java b/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapper.java index 05586f321..b202dd453 100644 --- a/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapper.java +++ b/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapper.java @@ -10,13 +10,13 @@ import com.google.common.util.concurrent.UncheckedExecutionException; import com.launchdarkly.sdk.server.integrations.PersistentDataStoreBuilder; import com.launchdarkly.sdk.server.interfaces.DataStore; -import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider.CacheStats; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.SerializedItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; import com.launchdarkly.sdk.server.interfaces.PersistentDataStore; import org.slf4j.Logger; @@ -31,7 +31,6 @@ import java.util.concurrent.ExecutionException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.atomic.AtomicBoolean; -import java.util.function.Consumer; import static com.google.common.collect.Iterables.concat; import static com.google.common.collect.Iterables.filter; @@ -63,7 +62,7 @@ final class PersistentDataStoreWrapper implements DataStore { Duration cacheTtl, PersistentDataStoreBuilder.StaleValuesPolicy staleValuesPolicy, boolean recordCacheStats, - Consumer statusUpdater, + DataStoreUpdates dataStoreUpdates, ScheduledExecutorService sharedExecutor ) { this.core = core; @@ -113,7 +112,7 @@ public Boolean load(String key) throws Exception { !cacheIndefinitely, true, this::pollAvailabilityAfterOutage, - statusUpdater, + dataStoreUpdates::updateStatus, sharedExecutor ); } diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/ClientContext.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/ClientContext.java index 944a19aeb..9d22b2dc0 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/ClientContext.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/ClientContext.java @@ -3,10 +3,11 @@ /** * Context information provided by the {@link com.launchdarkly.sdk.server.LDClient} when creating components. *

    - * This is passed as a parameter to {@link DataStoreFactory#createDataStore(ClientContext, java.util.function.Consumer)}, - * etc. The actual implementation class may contain other properties that are only relevant to the - * built-in SDK components and are therefore not part of the public interface; this allows the SDK - * to add its own context information as needed without disturbing the public API. + * This is passed as a parameter to component factory methods such as + * {@link DataStoreFactory#createDataStore(ClientContext, DataStoreUpdates)}. The actual implementation + * class may contain other properties that are only relevant to the built-in SDK components and are + * therefore not part of the public interface; this allows the SDK to add its own context information as + * needed without disturbing the public API. * * @since 5.0.0 */ diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStore.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStore.java index 50b1b543c..fbfb648a3 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStore.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStore.java @@ -86,7 +86,7 @@ public interface DataStore extends Closeable { * This is normally only true for persistent data stores created with * {@link com.launchdarkly.sdk.server.Components#persistentDataStore(PersistentDataStoreFactory)}, * but it could also be true for any custom {@link DataStore} implementation that makes use of the - * {@code statusUpdater} parameter provided to {@link DataStoreFactory#createDataStore(ClientContext, java.util.function.Consumer)}. + * {@code statusUpdater} parameter provided to {@link DataStoreFactory#createDataStore(ClientContext, DataStoreUpdates)}. * Returning true means that the store guarantees that if it ever enters an invalid state (that is, an * operation has failed or it knows that operations cannot succeed at the moment), it will publish a * status update, and will then publish another status update once it has returned to a valid state. diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreFactory.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreFactory.java index b74641a53..adfb1e06e 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreFactory.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreFactory.java @@ -2,8 +2,6 @@ import com.launchdarkly.sdk.server.Components; -import java.util.function.Consumer; - /** * Interface for a factory that creates some implementation of {@link DataStore}. * @see Components @@ -14,9 +12,9 @@ public interface DataStoreFactory { * Creates an implementation instance. * * @param context allows access to the client configuration - * @param statusUpdater if non-null, the store can call this method to provide an update of its status; - * if the store never calls this method, the SDK will report its status as "available" + * @param dataStoreUpdates the data store can use this object to report information back to + * the SDK if desired * @return a {@link DataStore} */ - DataStore createDataStore(ClientContext context, Consumer statusUpdater); + DataStore createDataStore(ClientContext context, DataStoreUpdates dataStoreUpdates); } diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProvider.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProvider.java index a4fa9ce46..b117510f4 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProvider.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProvider.java @@ -21,7 +21,7 @@ public interface DataStoreStatusProvider { * Returns the current status of the store. *

    * This is only meaningful for persistent stores, or any other {@link DataStore} implementation that makes use of - * the reporting mechanism provided by {@link DataStoreFactory#createDataStore(ClientContext, java.util.function.Consumer)}. + * the reporting mechanism provided by {@link DataStoreFactory#createDataStore(ClientContext, DataStoreUpdates)}. * For the default in-memory store, the status will always be reported as "available". * * @return the latest status; will never be null diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreUpdates.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreUpdates.java new file mode 100644 index 000000000..bf7646d67 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreUpdates.java @@ -0,0 +1,20 @@ +package com.launchdarkly.sdk.server.interfaces; + +/** + * Interface that a data store implementation can use to report information back to the SDK. + *

    + * The {@link DataStoreFactory} receives an implementation of this interface and can pass it to the + * data store that it creates, if desired. + * + * @since 5.0.0 + */ +public interface DataStoreUpdates { + /** + * Reports a change in the data store's operational status. + *

    + * This is what makes the status monitoring mechanisms in {@link DataStoreStatusProvider} work. + * + * @param newStatus the updated status properties + */ + void updateStatus(DataStoreStatusProvider.Status newStatus); +} diff --git a/src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java b/src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java index c6599e9f3..6014086bd 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java @@ -8,7 +8,7 @@ import com.launchdarkly.sdk.server.interfaces.ClientContext; import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; -import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider.Status; +import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; import com.launchdarkly.sdk.server.interfaces.DiagnosticDescription; import org.junit.Test; @@ -18,7 +18,6 @@ import java.util.Collections; import java.util.List; import java.util.UUID; -import java.util.function.Consumer; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; @@ -236,7 +235,7 @@ public LDValue describeConfiguration(LDConfig config) { } @Override - public DataStore createDataStore(ClientContext context, Consumer statusUpdater) { + public DataStore createDataStore(ClientContext context, DataStoreUpdates dataStoreUpdates) { return null; } } diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java index 920c87939..961a7baa6 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java @@ -162,7 +162,7 @@ public void dataStoreStatusProviderReturnsLatestStatus() throws Exception { DataStoreStatusProvider.Status originalStatus = new DataStoreStatusProvider.Status(true, false); DataStoreStatusProvider.Status newStatus = new DataStoreStatusProvider.Status(false, false); assertThat(client.getDataStoreStatusProvider().getStoreStatus(), equalTo(originalStatus)); - factoryWithUpdater.statusUpdater.accept(newStatus); + factoryWithUpdater.dataStoreUpdates.updateStatus(newStatus); assertThat(client.getDataStoreStatusProvider().getStoreStatus(), equalTo(newStatus)); } } @@ -182,7 +182,7 @@ public void dataStoreStatusProviderSendsStatusUpdates() throws Exception { client.getDataStoreStatusProvider().addStatusListener(statuses::add); DataStoreStatusProvider.Status newStatus = new DataStoreStatusProvider.Status(false, false); - factoryWithUpdater.statusUpdater.accept(newStatus); + factoryWithUpdater.dataStoreUpdates.updateStatus(newStatus); assertThat(statuses.take(), equalTo(newStatus)); } diff --git a/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperTest.java b/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperTest.java index 0b7f39dfa..bca0f7000 100644 --- a/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperTest.java @@ -47,7 +47,8 @@ public class PersistentDataStoreWrapperTest { private final MockPersistentDataStore core; private final PersistentDataStoreWrapper wrapper; private final EventBroadcasterImpl statusBroadcaster; - private final DataStoreStatusProviderImpl dataStoreStatusProvider; + private final DataStoreUpdatesImpl dataStoreUpdates; + private final DataStoreStatusProvider dataStoreStatusProvider; static class TestMode { final boolean cached; @@ -110,11 +111,12 @@ public PersistentDataStoreWrapperTest(TestMode testMode) { ); this.statusBroadcaster = new EventBroadcasterImpl<>( DataStoreStatusProvider.StatusListener::dataStoreStatusChanged, sharedExecutor); - this.dataStoreStatusProvider = new DataStoreStatusProviderImpl(wrapper, statusBroadcaster); + this.dataStoreUpdates = new DataStoreUpdatesImpl(statusBroadcaster); + this.dataStoreStatusProvider = new DataStoreStatusProviderImpl(wrapper, dataStoreUpdates); } private void updateStatus(DataStoreStatusProvider.Status status) { - dataStoreStatusProvider.updateStatus(status); + dataStoreUpdates.updateStatus(status); } @After diff --git a/src/test/java/com/launchdarkly/sdk/server/TestComponents.java b/src/test/java/com/launchdarkly/sdk/server/TestComponents.java index 10cb76b07..4f1e932fa 100644 --- a/src/test/java/com/launchdarkly/sdk/server/TestComponents.java +++ b/src/test/java/com/launchdarkly/sdk/server/TestComponents.java @@ -7,16 +7,16 @@ import com.launchdarkly.sdk.server.interfaces.ClientContext; import com.launchdarkly.sdk.server.interfaces.DataSource; import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; +import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider.CacheStats; -import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider.Status; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; -import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; +import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; import com.launchdarkly.sdk.server.interfaces.Event; import com.launchdarkly.sdk.server.interfaces.EventProcessor; import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; @@ -34,7 +34,6 @@ import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Consumer; import static com.launchdarkly.sdk.server.DataModel.FEATURES; @@ -177,7 +176,7 @@ public void close() throws IOException { } public static class DataStoreFactoryThatExposesUpdater implements DataStoreFactory { - public volatile Consumer statusUpdater; + public volatile DataStoreUpdates dataStoreUpdates; private final DataStoreFactory wrappedFactory; public DataStoreFactoryThatExposesUpdater(DataStoreFactory wrappedFactory) { @@ -185,9 +184,9 @@ public DataStoreFactoryThatExposesUpdater(DataStoreFactory wrappedFactory) { } @Override - public DataStore createDataStore(ClientContext context, Consumer statusUpdater) { - this.statusUpdater = statusUpdater; - return wrappedFactory.createDataStore(context, statusUpdater); + public DataStore createDataStore(ClientContext context, DataStoreUpdates dataStoreUpdates) { + this.dataStoreUpdates = dataStoreUpdates; + return wrappedFactory.createDataStore(context, dataStoreUpdates); } } From f8f76da8d6ca87430fc1e72aa91dc225b8551aaf Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 5 May 2020 20:42:59 -0700 Subject: [PATCH 412/673] implement data source status monitoring --- .../sdk/server/ClientContextImpl.java | 1 + .../launchdarkly/sdk/server/Components.java | 2 + .../server/DataSourceStatusProviderImpl.java | 31 ++ .../sdk/server/DataSourceUpdatesImpl.java | 52 ++- .../sdk/server/EventBroadcasterImpl.java | 19 ++ .../com/launchdarkly/sdk/server/LDClient.java | 24 +- .../sdk/server/LDClientInterface.java | 14 + .../PersistentDataStoreStatusManager.java | 7 +- .../sdk/server/PollingProcessor.java | 38 ++- .../sdk/server/StreamProcessor.java | 41 ++- .../integrations/FileDataSourceImpl.java | 16 +- .../sdk/server/interfaces/DataSource.java | 13 + .../interfaces/DataSourceStatusProvider.java | 299 ++++++++++++++++++ .../server/interfaces/DataSourceUpdates.java | 38 ++- .../sdk/server/interfaces/DataStoreTypes.java | 111 +++++++ .../sdk/server/interfaces/package-info.java | 9 +- .../sdk/server/DataSourceUpdatesImplTest.java | 39 +-- .../sdk/server/EventBroadcasterImplTest.java | 82 +++++ .../LDClientExternalUpdatesOnlyTest.java | 3 + .../sdk/server/LDClientListenersTest.java | 57 +++- .../sdk/server/LDClientOfflineTest.java | 8 +- .../PersistentDataStoreWrapperTest.java | 5 +- .../sdk/server/PollingProcessorTest.java | 138 ++++++-- .../sdk/server/StreamProcessorTest.java | 120 ++++--- .../sdk/server/TestComponents.java | 67 +++- .../com/launchdarkly/sdk/server/TestUtil.java | 70 ++++ .../integrations/FileDataSourceTest.java | 85 +++-- 27 files changed, 1216 insertions(+), 173 deletions(-) create mode 100644 src/main/java/com/launchdarkly/sdk/server/DataSourceStatusProviderImpl.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProvider.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/EventBroadcasterImplTest.java diff --git a/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java b/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java index 4f45d2da0..97fb84463 100644 --- a/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java @@ -17,6 +17,7 @@ * implementation of {@link ClientContext}, which might have been created for instance in application * test code). */ + final class ClientContextImpl implements ClientContext { private static volatile ScheduledExecutorService fallbackSharedExecutor = null; diff --git a/src/main/java/com/launchdarkly/sdk/server/Components.java b/src/main/java/com/launchdarkly/sdk/server/Components.java index 460f54bec..f2e828537 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Components.java +++ b/src/main/java/com/launchdarkly/sdk/server/Components.java @@ -11,6 +11,7 @@ import com.launchdarkly.sdk.server.interfaces.ClientContext; import com.launchdarkly.sdk.server.interfaces.DataSource; import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; @@ -338,6 +339,7 @@ public DataSource createDataSource(ClientContext context, DataSourceUpdates data } else { LDClient.logger.info("LaunchDarkly client will not connect to Launchdarkly for feature flag data"); } + dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.VALID, null); return NullDataSource.INSTANCE; } diff --git a/src/main/java/com/launchdarkly/sdk/server/DataSourceStatusProviderImpl.java b/src/main/java/com/launchdarkly/sdk/server/DataSourceStatusProviderImpl.java new file mode 100644 index 000000000..cadb09ca5 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/DataSourceStatusProviderImpl.java @@ -0,0 +1,31 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; + +import java.util.function.Supplier; + +final class DataSourceStatusProviderImpl implements DataSourceStatusProvider { + private final EventBroadcasterImpl dataSourceStatusNotifier; + private final Supplier statusSupplier; + + DataSourceStatusProviderImpl(EventBroadcasterImpl dataSourceStatusNotifier, + Supplier statusSupplier) { + this.dataSourceStatusNotifier = dataSourceStatusNotifier; + this.statusSupplier = statusSupplier; + } + + @Override + public Status getStatus() { + return statusSupplier.get(); + } + + @Override + public void addStatusListener(StatusListener listener) { + dataSourceStatusNotifier.register(listener); + } + + @Override + public void removeStatusListener(StatusListener listener) { + dataSourceStatusNotifier.unregister(listener); + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java b/src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java index af3fdb96d..639880d25 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java @@ -3,16 +3,19 @@ import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import com.launchdarkly.sdk.server.DataModelDependencies.KindAndKey; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.Status; +import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; -import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; +import java.time.Instant; import java.util.HashMap; import java.util.HashSet; import java.util.Map; @@ -33,17 +36,28 @@ final class DataSourceUpdatesImpl implements DataSourceUpdates { private final DataStore store; private final EventBroadcasterImpl flagChangeEventNotifier; + private final EventBroadcasterImpl dataSourceStatusNotifier; private final DataModelDependencies.DependencyTracker dependencyTracker = new DataModelDependencies.DependencyTracker(); private final DataStoreStatusProvider dataStoreStatusProvider; + private volatile DataSourceStatusProvider.Status currentStatus; + DataSourceUpdatesImpl( DataStore store, + DataStoreStatusProvider dataStoreStatusProvider, EventBroadcasterImpl flagChangeEventNotifier, - DataStoreStatusProvider dataStoreStatusProvider + EventBroadcasterImpl dataSourceStatusNotifier ) { this.store = store; this.flagChangeEventNotifier = flagChangeEventNotifier; + this.dataSourceStatusNotifier = dataSourceStatusNotifier; this.dataStoreStatusProvider = dataStoreStatusProvider; + + currentStatus = new DataSourceStatusProvider.Status( + DataSourceStatusProvider.State.STARTING, + Instant.now(), + null + ); } @Override @@ -91,14 +105,40 @@ public DataStoreStatusProvider getDataStoreStatusProvider() { return dataStoreStatusProvider; } + @Override + public void updateStatus(DataSourceStatusProvider.State newState, DataSourceStatusProvider.ErrorInfo newError) { + if (newState == null) { + return; + } + DataSourceStatusProvider.Status newStatus; + synchronized (this) { + if (newState == DataSourceStatusProvider.State.INTERRUPTED && currentStatus.getState() == DataSourceStatusProvider.State.STARTING) { + newState = DataSourceStatusProvider.State.STARTING; // see comment on updateStatus in the DataSourceUpdates interface + } + if (newState == currentStatus.getState() && newError == null) { + return; + } + currentStatus = new DataSourceStatusProvider.Status( + newState, + newState == currentStatus.getState() ? currentStatus.getStateSince() : Instant.now(), + newError == null ? currentStatus.getLastError() : newError + ); + newStatus = currentStatus; + } + dataSourceStatusNotifier.broadcast(newStatus); + } + + Status getLastStatus() { + synchronized (this) { + return currentStatus; + } + } + private boolean hasFlagChangeEventListeners() { - return flagChangeEventNotifier != null && flagChangeEventNotifier.hasListeners(); + return flagChangeEventNotifier.hasListeners(); } private void sendChangeEvents(Iterable affectedItems) { - if (flagChangeEventNotifier == null) { - return; - } for (KindAndKey item: affectedItems) { if (item.kind == FEATURES) { flagChangeEventNotifier.broadcast(new FlagChangeEvent(item.key)); diff --git a/src/main/java/com/launchdarkly/sdk/server/EventBroadcasterImpl.java b/src/main/java/com/launchdarkly/sdk/server/EventBroadcasterImpl.java index 543755da5..e280184d1 100644 --- a/src/main/java/com/launchdarkly/sdk/server/EventBroadcasterImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/EventBroadcasterImpl.java @@ -1,5 +1,10 @@ package com.launchdarkly.sdk.server; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; +import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; +import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; + import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.ExecutorService; import java.util.function.BiConsumer; @@ -30,6 +35,20 @@ final class EventBroadcasterImpl { this.executor = executor; } + static EventBroadcasterImpl forFlagChangeEvents(ExecutorService executor) { + return new EventBroadcasterImpl<>(FlagChangeListener::onFlagChange, executor); + } + + static EventBroadcasterImpl + forDataSourceStatus(ExecutorService executor) { + return new EventBroadcasterImpl<>(DataSourceStatusProvider.StatusListener::dataSourceStatusChanged, executor); + } + + static EventBroadcasterImpl + forDataStoreStatus(ExecutorService executor) { + return new EventBroadcasterImpl<>(DataStoreStatusProvider.StatusListener::dataStoreStatusChanged, executor); + } + /** * Registers a listener for this type of event. This method is thread-safe. * diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClient.java b/src/main/java/com/launchdarkly/sdk/server/LDClient.java index 6ed3e4701..1cdebab77 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClient.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClient.java @@ -8,7 +8,7 @@ import com.launchdarkly.sdk.server.integrations.EventProcessorBuilder; import com.launchdarkly.sdk.server.interfaces.DataSource; import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; -import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; @@ -65,6 +65,7 @@ public final class LDClient implements LDClientInterface { final DataSource dataSource; final DataStore dataStore; private final DataStoreStatusProviderImpl dataStoreStatusProvider; + private final DataSourceStatusProviderImpl dataSourceStatusProvider; private final EventBroadcasterImpl flagChangeEventNotifier; private final ScheduledExecutorService sharedExecutor; @@ -159,7 +160,7 @@ public LDClient(String sdkKey, LDConfig config) { DataStoreFactory factory = config.dataStoreFactory == null ? Components.inMemoryDataStore() : config.dataStoreFactory; EventBroadcasterImpl dataStoreStatusNotifier = - new EventBroadcasterImpl<>(DataStoreStatusProvider.StatusListener::dataStoreStatusChanged, sharedExecutor); + EventBroadcasterImpl.forDataStoreStatus(sharedExecutor); DataStoreUpdatesImpl dataStoreUpdates = new DataStoreUpdatesImpl(dataStoreStatusNotifier); this.dataStore = factory.createDataStore(context, dataStoreUpdates); @@ -173,19 +174,23 @@ public DataModel.Segment getSegment(String key) { } }); - this.flagChangeEventNotifier = new EventBroadcasterImpl<>(FlagChangeListener::onFlagChange, sharedExecutor); + this.flagChangeEventNotifier = EventBroadcasterImpl.forFlagChangeEvents(sharedExecutor); this.dataStoreStatusProvider = new DataStoreStatusProviderImpl(this.dataStore, dataStoreUpdates); DataSourceFactory dataSourceFactory = config.dataSourceFactory == null ? Components.streamingDataSource() : config.dataSourceFactory; - DataSourceUpdates dataSourceUpdates = new DataSourceUpdatesImpl( + EventBroadcasterImpl dataSourceStatusNotifier = + EventBroadcasterImpl.forDataSourceStatus(sharedExecutor); + DataSourceUpdatesImpl dataSourceUpdates = new DataSourceUpdatesImpl( dataStore, + dataStoreStatusProvider, flagChangeEventNotifier, - dataStoreStatusProvider + dataSourceStatusNotifier ); - this.dataSource = dataSourceFactory.createDataSource(context, dataSourceUpdates); - + this.dataSource = dataSourceFactory.createDataSource(context, dataSourceUpdates); + this.dataSourceStatusProvider = new DataSourceStatusProviderImpl(dataSourceStatusNotifier, dataSourceUpdates::getLastStatus); + Future startFuture = dataSource.start(); if (!config.startWait.isZero() && !config.startWait.isNegative()) { if (!(dataSource instanceof Components.NullDataSource)) { @@ -460,6 +465,11 @@ public void unregisterFlagChangeListener(FlagChangeListener listener) { public DataStoreStatusProvider getDataStoreStatusProvider() { return dataStoreStatusProvider; } + + @Override + public DataSourceStatusProvider getDataSourceStatusProvider() { + return dataSourceStatusProvider; + } @Override public void close() throws IOException { diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClientInterface.java b/src/main/java/com/launchdarkly/sdk/server/LDClientInterface.java index 20220e432..440ef3571 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClientInterface.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClientInterface.java @@ -3,6 +3,7 @@ import com.launchdarkly.sdk.EvaluationDetail; import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; @@ -263,6 +264,19 @@ public interface LDClientInterface extends Closeable { * @since 5.0.0 */ void unregisterFlagChangeListener(FlagChangeListener listener); + + /** + * Returns an interface for tracking the status of the data source. + *

    + * The data source is the mechanism that the SDK uses to get feature flag configurations, such as a + * streaming connection (the default) or poll requests. The {@link DataSourceStatusProvider} has methods + * for checking whether the data source is (as far as the SDK knows) currently operational and tracking + * changes in this status. + * + * @return a {@link DataSourceStatusProvider} + * @since 5.0.0 + */ + DataSourceStatusProvider getDataSourceStatusProvider(); /** * Returns an interface for tracking the status of a persistent data store. diff --git a/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreStatusManager.java b/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreStatusManager.java index bb4833843..ffb30daab 100644 --- a/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreStatusManager.java +++ b/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreStatusManager.java @@ -85,7 +85,12 @@ public void run() { }; synchronized (this) { if (pollerFuture == null) { - pollerFuture = scheduler.scheduleAtFixedRate(pollerTask, POLL_INTERVAL_MS, POLL_INTERVAL_MS, TimeUnit.MILLISECONDS); + pollerFuture = scheduler.scheduleAtFixedRate( + pollerTask, + POLL_INTERVAL_MS, + POLL_INTERVAL_MS, + TimeUnit.MILLISECONDS + ); } } } diff --git a/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java b/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java index c431d62b7..f95be5b01 100644 --- a/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java @@ -2,6 +2,7 @@ import com.google.common.annotations.VisibleForTesting; import com.launchdarkly.sdk.server.interfaces.DataSource; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; import com.launchdarkly.sdk.server.interfaces.SerializationException; @@ -10,6 +11,7 @@ import java.io.IOException; import java.time.Duration; +import java.time.Instant; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Future; import java.util.concurrent.ScheduledExecutorService; @@ -63,18 +65,52 @@ public Future start() { dataSourceUpdates.init(allData.toFullDataSet()); if (!initialized.getAndSet(true)) { logger.info("Initialized LaunchDarkly client."); + dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.VALID, null); initFuture.complete(null); } } catch (HttpErrorException e) { + DataSourceStatusProvider.ErrorInfo errorInfo = new DataSourceStatusProvider.ErrorInfo( + DataSourceStatusProvider.ErrorKind.ERROR_RESPONSE, + e.getStatus(), + null, + Instant.now() + ); logger.error(httpErrorMessage(e.getStatus(), "polling request", "will retry")); - if (!isHttpErrorRecoverable(e.getStatus())) { + if (isHttpErrorRecoverable(e.getStatus())) { + dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.INTERRUPTED, errorInfo); + } else { + dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.OFF, errorInfo); initFuture.complete(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.toString()); logger.debug(e.toString(), e); + DataSourceStatusProvider.ErrorInfo errorInfo = new DataSourceStatusProvider.ErrorInfo( + DataSourceStatusProvider.ErrorKind.NETWORK_ERROR, + 0, + e.toString(), + Instant.now() + ); + dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.INTERRUPTED, errorInfo); } catch (SerializationException e) { logger.error("Polling request received malformed data: {}", e.toString()); + DataSourceStatusProvider.ErrorInfo errorInfo = new DataSourceStatusProvider.ErrorInfo( + DataSourceStatusProvider.ErrorKind.INVALID_DATA, + 0, + e.toString(), + Instant.now() + ); + dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.INTERRUPTED, errorInfo); + } catch (Exception e) { + logger.error("Unexpected error from polling processor: {}", e.toString()); + logger.debug(e.toString(), e); + DataSourceStatusProvider.ErrorInfo errorInfo = new DataSourceStatusProvider.ErrorInfo( + DataSourceStatusProvider.ErrorKind.UNKNOWN, + 0, + e.toString(), + Instant.now() + ); + dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.INTERRUPTED, errorInfo); } }, 0L, pollInterval.toMillis(), TimeUnit.MILLISECONDS); diff --git a/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java index 82642cbf1..9ba14cc0c 100644 --- a/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java @@ -10,11 +10,12 @@ import com.launchdarkly.eventsource.UnsuccessfulResponseException; import com.launchdarkly.sdk.server.DataModel.VersionedData; import com.launchdarkly.sdk.server.interfaces.DataSource; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; -import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; import com.launchdarkly.sdk.server.interfaces.SerializationException; @@ -24,6 +25,7 @@ import java.io.IOException; import java.net.URI; import java.time.Duration; +import java.time.Instant; import java.util.AbstractMap; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -133,7 +135,6 @@ static interface EventSourceCreator { .add("Accept", "text/event-stream") .build(); - DataStoreStatusProvider.StatusListener statusListener = this::onStoreStatusChanged; if (dataSourceUpdates.getDataStoreStatusProvider() != null && dataSourceUpdates.getDataStoreStatusProvider().isStatusMonitoringEnabled()) { this.statusListener = this::onStoreStatusChanged; @@ -160,15 +161,38 @@ private void onStoreStatusChanged(DataStoreStatusProvider.Status newStatus) { private ConnectionErrorHandler createDefaultConnectionErrorHandler() { return (Throwable t) -> { recordStreamInit(true); + if (t instanceof UnsuccessfulResponseException) { int status = ((UnsuccessfulResponseException)t).getCode(); + logger.error(httpErrorMessage(status, "streaming connection", "will retry")); + + DataSourceStatusProvider.ErrorInfo errorInfo = new DataSourceStatusProvider.ErrorInfo( + DataSourceStatusProvider.ErrorKind.ERROR_RESPONSE, + status, + null, + Instant.now() + ); + if (!isHttpErrorRecoverable(status)) { + dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.OFF, errorInfo); return Action.SHUTDOWN; } + + dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.INTERRUPTED, errorInfo); esStarted = System.currentTimeMillis(); return Action.PROCEED; } + + DataSourceStatusProvider.ErrorInfo errorInfo= new DataSourceStatusProvider.ErrorInfo( + t instanceof IOException ? + DataSourceStatusProvider.ErrorKind.NETWORK_ERROR : + DataSourceStatusProvider.ErrorKind.UNKNOWN, + 0, + t.toString(), + Instant.now() + ); + dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.INTERRUPTED, errorInfo); return Action.PROCEED; }; } @@ -214,6 +238,7 @@ public void close() throws IOException { es.close(); } requestor.close(); + dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.OFF, null); } @Override @@ -265,9 +290,21 @@ public void onMessage(String name, MessageEvent event) throws Exception { break; } lastStoreUpdateFailed = false; + dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.VALID, null); } catch (StreamInputException e) { logger.error("LaunchDarkly service request failed or received invalid data: {}", e.toString()); logger.debug(e.toString(), e); + + DataSourceStatusProvider.ErrorInfo errorInfo = new DataSourceStatusProvider.ErrorInfo( + e.getCause() instanceof IOException ? + DataSourceStatusProvider.ErrorKind.NETWORK_ERROR : + DataSourceStatusProvider.ErrorKind.INVALID_DATA, + 0, + e.getCause() == null ? e.getMessage() : e.getCause().toString(), + Instant.now() + ); + dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.INTERRUPTED, errorInfo); + es.restart(); } catch (StreamStoreException e) { // See item 2 in error handling comments at top of class diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java index 7aa77d44e..58d2dba29 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java @@ -7,11 +7,12 @@ import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FlagFileParser; import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FlagFileRep; import com.launchdarkly.sdk.server.interfaces.DataSource; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; -import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -26,6 +27,7 @@ import java.nio.file.WatchKey; import java.nio.file.WatchService; import java.nio.file.Watchable; +import java.time.Instant; import java.util.AbstractMap; import java.util.ArrayList; import java.util.HashMap; @@ -82,9 +84,7 @@ public Future start() { // if we are told to reload by the file watcher. if (fileWatcher != null) { - fileWatcher.start(() -> { - FileDataSourceImpl.this.reload(); - }); + fileWatcher.start(this::reload); } return initFuture; @@ -96,9 +96,17 @@ private boolean reload() { dataLoader.load(builder); } catch (FileDataException e) { logger.error(e.getDescription()); + dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.INTERRUPTED, + new DataSourceStatusProvider.ErrorInfo( + DataSourceStatusProvider.ErrorKind.INVALID_DATA, + 0, + e.getDescription(), + Instant.now() + )); return false; } dataSourceUpdates.init(builder.build()); + dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.VALID, null); inited.set(true); return true; } diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSource.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSource.java index 848420cb3..87075dad3 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSource.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSource.java @@ -7,6 +7,19 @@ /** * Interface for an object that receives updates to feature flags, user segments, and anything * else that might come from LaunchDarkly, and passes them to a {@link DataStore}. + *

    + * The standard implementations are: + *

      + *
    • {@link com.launchdarkly.sdk.server.Components#streamingDataSource()} (the default), which + * maintains a streaming connection to LaunchDarkly; + *
    • {@link com.launchdarkly.sdk.server.Components#pollingDataSource()}, which polls for + * updates at regular intervals; + *
    • {@link com.launchdarkly.sdk.server.Components#externalUpdatesOnly()}, which does nothing + * (on the assumption that another process will update the data store); + *
    • {@link com.launchdarkly.sdk.server.integrations.FileData}, which reads flag data from + * the filesystem. + *
    + * * @since 5.0.0 */ public interface DataSource extends Closeable { diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProvider.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProvider.java new file mode 100644 index 000000000..b10ed8c2a --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProvider.java @@ -0,0 +1,299 @@ +package com.launchdarkly.sdk.server.interfaces; + +import java.time.Instant; +import java.util.Objects; + +/** + * An interface for querying the status of a {@link DataSource}. + *

    + * An implementation of this interface is returned by {@link com.launchdarkly.sdk.server.LDClientInterface#getDataSourceStatusProvider}. + * Application code never needs to implement this interface. + * + * @since 5.0.0 + */ +public interface DataSourceStatusProvider { + /** + * Returns the current status of the data source. + *

    + * All of the built-in data source implementations are guaranteed to update this status whenever they + * successfully initialize, encounter an error, or recover after an error. + *

    + * For a custom data source implementation, it is the responsibility of the data source to report its + * status via {@link DataSourceUpdates}; if it does not do so, the status will always be reported as + * {@link State#STARTING}. + * + * @return the latest status; will never be null + */ + public Status getStatus(); + + /** + * Subscribes for notifications of status changes. + *

    + * The listener will be notified whenever any property of the status has changed. See {@link Status} for an + * explanation of the meaning of each property and what could cause it to change. + *

    + * Notifications will be dispatched on a worker thread. It is the listener's responsibility to return as soon as + * possible so as not to block subsequent notifications. + * + * @param listener the listener to add + */ + public void addStatusListener(StatusListener listener); + + /** + * Unsubscribes from notifications of status changes. + * + * @param listener the listener to remove; if no such listener was added, this does nothing + */ + public void removeStatusListener(StatusListener listener); + + /** + * An enumeration of possible values for {@link DataSourceStatusProvider.Status#getState()}. + */ + public enum State { + /** + * The initial state of the data source when the SDK is initialized. + *

    + * If it encounters an error that requires it to retry initialization, the state will remain at + * {@link #STARTING} until it either succeeds and becomes {@link #VALID}, or permanently fails and + * becomes {@link #OFF}. + */ + STARTING, + + /** + * Indicates that the data source is currently operational and has not had any problems since the + * last time it received data. + *

    + * In streaming mode, this means that there is currently an open stream connection and that at least + * one initial message has been received on the stream. In polling mode, it means that the last poll + * request succeeded. + */ + VALID, + + /** + * Indicates that the data source encountered an error that it will attempt to recover from. + *

    + * In streaming mode, this means that the stream connection failed, or had to be dropped due to some + * other error, and will be retried after a backoff delay. In polling mode, it means that the last poll + * request failed, and a new poll request will be made after the configured polling interval. + */ + INTERRUPTED, + + /** + * Indicates that the data source has been permanently shut down. + *

    + * This could be because it encountered an unrecoverable error (for instance, the LaunchDarkly service + * rejected the SDK key; an invald SDK key will never become vavlid), or because the SDK client was + * explicitly shut own. + */ + OFF; + } + + /** + * An enumeration describing the general type of an error reported in {@link ErrorInfo}. + * + * @see ErrorInfo#getKind() + */ + public static enum ErrorKind { + /** + * An unexpected error, such as an uncaught exception, further described by {@link ErrorInfo#getMessage()}. + */ + UNKNOWN, + + /** + * An I/O error such as a dropped connection. + */ + NETWORK_ERROR, + + /** + * The LaunchDarkly service returned an HTTP response with an error status, available with + * {@link ErrorInfo#getStatusCode()}. + */ + ERROR_RESPONSE, + + /** + * The SDK received malformed data from the LaunchDarkly service. + */ + INVALID_DATA + } + + /** + * A description of an error condition that the data source encountered, + * + * @see Status#getLastError() + */ + public static final class ErrorInfo { + private final ErrorKind kind; + private final int statusCode; + private final String message; + private final Instant time; + + /** + * Constructs an instance. + * + * @param kind the general category of the error + * @param statusCode an HTTP status or zero + * @param message an error message if applicable, or null + * @param time the error timestamp + */ + public ErrorInfo(ErrorKind kind, int statusCode, String message, Instant time) { + this.kind = kind; + this.statusCode = statusCode; + this.message = message; + this.time = time; + } + + /** + * Returns an enumerated value representing the general category of the error. + * + * @return the general category of the error + */ + public ErrorKind getKind() { + return kind; + } + + /** + * Returns the HTTP status code if the error was {@link ErrorKind#ERROR_RESPONSE}, or zero otherwise. + * + * @return an HTTP status or zero + */ + public int getStatusCode() { + return statusCode; + } + + /** + * Returns any additional human-readable information relevant to the error. The format of this message + * is subject to change and should not be relied on programmatically. + * + * @return an error message if applicable, or null + */ + public String getMessage() { + return message; + } + + /** + * Returns the date/time that the error occurred. + * + * @return the error timestamp + */ + public Instant getTime() { + return time; + } + + @Override + public boolean equals(Object other) { + if (other instanceof ErrorInfo) { + ErrorInfo o = (ErrorInfo)other; + return kind == o.kind && statusCode == o.statusCode && Objects.equals(message, o.message) && + Objects.equals(time, o.time); + } + return false; + } + + @Override + public int hashCode() { + return Objects.hash(kind, statusCode, message, time); + } + + @Override + public String toString() { + return "ErrorInfo(" + kind + "," + statusCode + "," + message + "," + time + ")"; + } + } + + /** + * Information about the data source's status and about the last status change. + */ + public static final class Status { + private final State state; + private final Instant stateSince; + private final ErrorInfo lastError; + + /** + * Constructs a new instance. + * + * @param state the basic state as an enumeration + * @param stateSince timestamp of the last state transition + * @param lastError a description of the last error, or null if no errors have occurred since startup + */ + public Status(State state, Instant stateSince, ErrorInfo lastError) { + this.state = state; + this.stateSince = stateSince; + this.lastError = lastError; + } + + /** + * Returns an enumerated value representing the overall current state of the data source. + * + * @return the basic state + */ + public State getState() { + return state; + } + + /** + * Returns the date/time that the value of {@link #getState()} most recently changed. + *

    + * The meaning of this depends on the current state: + *

      + *
    • For {@link State#STARTING}, it is the time that the SDK started initializing. + *
    • For {@link State#VALID}, it is the time that the data source most recently entered a valid + * state, after previously having been either {@link State#STARTING} or {@link State#INTERRUPTED}. + *
    • For {@link State#INTERRUPTED}, it is the time that the data source most recently entered an + * error state, after previously having been {@link State#VALID}. + *
    • For {@link State#OFF}, it is the time that the data source encountered an unrecoverable error + * or that the SDK was explicitly shut down. + * + * @return the timestamp of the last state change + */ + public Instant getStateSince() { + return stateSince; + } + + /** + * Returns information about the last error that the data source encountered, if any. + *

      + * This property should be updated whenever the data source encounters a problem, even if it does + * not cause {@link #getState()} to change. For instance, if a stream connection fails and the + * state changes to {@link State#INTERRUPTED}, and then subsequent attempts to restart the + * connection also fail, the state will remain {@link State#INTERRUPTED} but the error information + * will be updated each time-- and the last error will still be reported in this property even if + * the state later becomes {@link State#VALID}. + * + * @return a description of the last error, or null if no errors have occurred since startup + */ + public ErrorInfo getLastError() { + return lastError; + } + + @Override + public boolean equals(Object other) { + if (other instanceof Status) { + Status o = (Status)other; + return state == o.state && Objects.equals(stateSince, o.stateSince) && Objects.equals(lastError, o.lastError); + } + return false; + } + + @Override + public int hashCode() { + return Objects.hash(state, stateSince, lastError); + } + + @Override + public String toString() { + return "Status(" + state + "," + stateSince + "," + lastError + ")"; + } + } + + /** + * Interface for receiving status change notifications. + */ + public static interface StatusListener { + /** + * Called when any property of the data source status has changed. + * + * @param newStatus the new status + */ + public void dataSourceStatusChanged(Status newStatus); + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceUpdates.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceUpdates.java index 9e75393fd..34e3228ed 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceUpdates.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceUpdates.java @@ -14,15 +14,10 @@ */ public interface DataSourceUpdates { /** - * Overwrites the store's contents with a set of items for each collection. - *

      - * All previous data should be discarded, regardless of versioning. - *

      - * The update should be done atomically. If it cannot be done atomically, then the store - * must first add or update each item in the same order that they are given in the input - * data, and then delete any previously stored items that were not in the input data. + * Completely overwrites the current contents of the data store with a set of items for each collection. * * @param allData a list of {@link DataStoreTypes.DataKind} instances and their corresponding data sets + * @see DataStore#init(FullDataSet) */ void init(FullDataSet allData); @@ -30,13 +25,14 @@ public interface DataSourceUpdates { * Updates or inserts an item in the specified collection. For updates, the object will only be * updated if the existing version is less than the new version. *

      - * The SDK may pass an {@link ItemDescriptor} that contains a null, to represent a placeholder - * for a deleted item. In that case, assuming the version is greater than any existing version of - * that item, the store should retain that placeholder rather than simply not storing anything. + * To mark an item as deleted, pass an {@link ItemDescriptor} that contains a null, with a version + * number (you may use {@link ItemDescriptor#deletedItem(int)}). Deletions must be versioned so that + * they do not overwrite a later update in case updates are received out of order. * * @param kind specifies which collection to use * @param key the unique key for the item within that collection * @param item the item to insert or update + * @see DataStore#upsert(DataKind, String, ItemDescriptor) */ void upsert(DataKind kind, String key, ItemDescriptor item); @@ -50,4 +46,26 @@ public interface DataSourceUpdates { * @return a {@link DataStoreStatusProvider} */ DataStoreStatusProvider getDataStoreStatusProvider(); + + /** + * Informs the SDK of a change in the data source's status. + *

      + * Data source implementations should use this method if they have any concept of being in a valid + * state, a temporarily disconnected state, or a permanently stopped state. + *

      + * If {@code newState} is different from the previous state, and/or {@code newError} is non-null, the + * SDK will start returning the new status (adding a timestamp for the change) from + * {@link DataSourceStatusProvider#getStatus()}, and will trigger status change events to any + * registered listeners. + *

      + * A special case is that if {@code newState} is {@link DataSourceStatusProvider.State#INTERRUPTED}, + * but the previous state was {@link DataSourceStatusProvider.State#STARTING}, the state will remain + * at {@link DataSourceStatusProvider.State#STARTING} because {@link DataSourceStatusProvider.State#INTERRUPTED} + * is only meaningful after a successful startup. + * + * @param newState the data source state + * @param newError information about a new error, if any + * @see DataSourceStatusProvider + */ + void updateStatus(DataSourceStatusProvider.State newState, DataSourceStatusProvider.ErrorInfo newError); } diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypes.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypes.java index c92a3048c..47f746604 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypes.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypes.java @@ -1,6 +1,7 @@ package com.launchdarkly.sdk.server.interfaces; import com.google.common.collect.ImmutableList; +import com.launchdarkly.sdk.server.integrations.PersistentDataStoreBuilder; import java.util.Map; import java.util.Objects; @@ -296,4 +297,114 @@ public KeyedItems(Iterable> items) { this.items = items == null ? ImmutableList.of() : items; } } + + /** + * 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. + * + * @see DataStoreStatusProvider#getCacheStats() + * @see PersistentDataStoreBuilder#recordCacheStats(boolean) + * @since 4.12.0 + */ + 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 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) { + 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/sdk/server/interfaces/package-info.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/package-info.java index 5d38c8803..537034709 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/package-info.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/package-info.java @@ -1,7 +1,10 @@ /** - * The package for interfaces that allow customization of LaunchDarkly components. + * The package for interfaces that allow customization of LaunchDarkly components, and interfaces + * to other advanced SDK features. *

      - * 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. + * Most application will not need to refer to these types. You will use them if you are creating a + * plug-in component, such as a database integration, or if you use advanced features such as + * {@link com.launchdarkly.sdk.server.LDClientInterface#getDataStoreStatusProvider()} or + * {@link com.launchdarkly.sdk.server.LDClientInterface#registerFlagChangeListener(FlagChangeListener)}. */ package com.launchdarkly.sdk.server.interfaces; diff --git a/src/test/java/com/launchdarkly/sdk/server/DataSourceUpdatesImplTest.java b/src/test/java/com/launchdarkly/sdk/server/DataSourceUpdatesImplTest.java index 601bdd786..323b38211 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataSourceUpdatesImplTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataSourceUpdatesImplTest.java @@ -21,8 +21,6 @@ import java.util.List; import java.util.Map; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; import static com.google.common.collect.Iterables.transform; import static com.launchdarkly.sdk.server.DataModel.FEATURES; @@ -42,21 +40,8 @@ public class DataSourceUpdatesImplTest extends EasyMockSupport { // Note that these tests must use the actual data model types for flags and segments, rather than the // TestItem type from DataStoreTestTypes, because the dependency behavior is based on the real data model. - private ExecutorService executorService = Executors.newSingleThreadExecutor(); private EventBroadcasterImpl flagChangeBroadcaster = - new EventBroadcasterImpl<>(FlagChangeListener::onFlagChange, executorService); - - public void tearDown() { - executorService.shutdown(); - } - - @Test - public void doesNotTryToSendEventsIfThereIsNoEventPublisher() { - DataStore store = inMemoryDataStore(); - DataSourceUpdatesImpl storeUpdates = new DataSourceUpdatesImpl(store, null, null); - storeUpdates.upsert(DataModel.FEATURES, "key", new ItemDescriptor(1, flagBuilder("key").build())); - // the test is just that this doesn't cause an exception - } + EventBroadcasterImpl.forFlagChangeEvents(TestComponents.sharedExecutor); @Test public void sendsEventsOnInitForNewlyAddedFlags() throws Exception { @@ -67,7 +52,7 @@ public void sendsEventsOnInitForNewlyAddedFlags() throws Exception { .addAny(SEGMENTS, segmentBuilder("segment1").version(1).build()); - DataSourceUpdatesImpl storeUpdates = new DataSourceUpdatesImpl(store, flagChangeBroadcaster, null); + DataSourceUpdatesImpl storeUpdates = new DataSourceUpdatesImpl(store, null, flagChangeBroadcaster, null); storeUpdates.init(builder.build()); @@ -92,7 +77,7 @@ public void sendsEventOnUpdateForNewlyAddedFlag() throws Exception { .addAny(SEGMENTS, segmentBuilder("segment1").version(1).build()); - DataSourceUpdatesImpl storeUpdates = new DataSourceUpdatesImpl(store, flagChangeBroadcaster, null); + DataSourceUpdatesImpl storeUpdates = new DataSourceUpdatesImpl(store, null, flagChangeBroadcaster, null); storeUpdates.init(builder.build()); @@ -115,7 +100,7 @@ public void sendsEventsOnInitForUpdatedFlags() throws Exception { segmentBuilder("segment1").version(1).build(), segmentBuilder("segment2").version(1).build()); - DataSourceUpdatesImpl storeUpdates = new DataSourceUpdatesImpl(store, flagChangeBroadcaster, null); + DataSourceUpdatesImpl storeUpdates = new DataSourceUpdatesImpl(store, null, flagChangeBroadcaster, null); storeUpdates.init(builder.build()); @@ -139,7 +124,7 @@ public void sendsEventOnUpdateForUpdatedFlag() throws Exception { .addAny(SEGMENTS, segmentBuilder("segment1").version(1).build()); - DataSourceUpdatesImpl storeUpdates = new DataSourceUpdatesImpl(store, flagChangeBroadcaster, null); + DataSourceUpdatesImpl storeUpdates = new DataSourceUpdatesImpl(store, null, flagChangeBroadcaster, null); storeUpdates.init(builder.build()); @@ -161,7 +146,7 @@ public void sendsEventsOnInitForDeletedFlags() throws Exception { .addAny(SEGMENTS, segmentBuilder("segment1").version(1).build()); - DataSourceUpdatesImpl storeUpdates = new DataSourceUpdatesImpl(store, flagChangeBroadcaster, null); + DataSourceUpdatesImpl storeUpdates = new DataSourceUpdatesImpl(store, null, flagChangeBroadcaster, null); storeUpdates.init(builder.build()); @@ -187,7 +172,7 @@ public void sendsEventOnUpdateForDeletedFlag() throws Exception { .addAny(SEGMENTS, segmentBuilder("segment1").version(1).build()); - DataSourceUpdatesImpl storeUpdates = new DataSourceUpdatesImpl(store, flagChangeBroadcaster, null); + DataSourceUpdatesImpl storeUpdates = new DataSourceUpdatesImpl(store, null, flagChangeBroadcaster, null); storeUpdates.init(builder.build()); @@ -211,7 +196,7 @@ public void sendsEventsOnInitForFlagsWhosePrerequisitesChanged() throws Exceptio flagBuilder("flag5").version(1).prerequisites(prerequisite("flag4", 0)).build(), flagBuilder("flag6").version(1).build()); - DataSourceUpdatesImpl storeUpdates = new DataSourceUpdatesImpl(store, flagChangeBroadcaster, null); + DataSourceUpdatesImpl storeUpdates = new DataSourceUpdatesImpl(store, null, flagChangeBroadcaster, null); storeUpdates.init(builder.build()); @@ -236,7 +221,7 @@ public void sendsEventsOnUpdateForFlagsWhosePrerequisitesChanged() throws Except flagBuilder("flag5").version(1).prerequisites(prerequisite("flag4", 0)).build(), flagBuilder("flag6").version(1).build()); - DataSourceUpdatesImpl storeUpdates = new DataSourceUpdatesImpl(store, flagChangeBroadcaster, null); + DataSourceUpdatesImpl storeUpdates = new DataSourceUpdatesImpl(store, null, flagChangeBroadcaster, null); storeUpdates.init(builder.build()); @@ -265,7 +250,7 @@ public void sendsEventsOnInitForFlagsWhoseSegmentsChanged() throws Exception { segmentBuilder("segment1").version(1).build(), segmentBuilder("segment2").version(1).build()); - DataSourceUpdatesImpl storeUpdates = new DataSourceUpdatesImpl(store, flagChangeBroadcaster, null); + DataSourceUpdatesImpl storeUpdates = new DataSourceUpdatesImpl(store, null, flagChangeBroadcaster, null); storeUpdates.init(builder.build()); @@ -294,7 +279,7 @@ public void sendsEventsOnUpdateForFlagsWhoseSegmentsChanged() throws Exception { segmentBuilder("segment1").version(1).build(), segmentBuilder("segment2").version(1).build()); - DataSourceUpdatesImpl storeUpdates = new DataSourceUpdatesImpl(store, flagChangeBroadcaster, null); + DataSourceUpdatesImpl storeUpdates = new DataSourceUpdatesImpl(store, null, flagChangeBroadcaster, null); storeUpdates.init(builder.build()); @@ -318,7 +303,7 @@ public void dataSetIsPassedToDataStoreInCorrectOrder() throws Exception { store.init(EasyMock.capture(captureData)); replay(store); - DataSourceUpdatesImpl storeUpdates = new DataSourceUpdatesImpl(store, null, null); + DataSourceUpdatesImpl storeUpdates = new DataSourceUpdatesImpl(store, null, flagChangeBroadcaster, null); storeUpdates.init(DEPENDENCY_ORDERING_TEST_DATA); Map> dataMap = toDataMap(captureData.getValue()); diff --git a/src/test/java/com/launchdarkly/sdk/server/EventBroadcasterImplTest.java b/src/test/java/com/launchdarkly/sdk/server/EventBroadcasterImplTest.java new file mode 100644 index 000000000..66dcd5b13 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/EventBroadcasterImplTest.java @@ -0,0 +1,82 @@ +package com.launchdarkly.sdk.server; + +import org.junit.Test; + +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; + +import static com.launchdarkly.sdk.server.TestComponents.sharedExecutor; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; + +@SuppressWarnings("javadoc") +public class EventBroadcasterImplTest { + private EventBroadcasterImpl broadcaster = + new EventBroadcasterImpl<>(FakeListener::sendEvent, sharedExecutor); + + @Test + public void sendingEventWithNoListenersDoesNotCauseError() { + broadcaster.broadcast(new FakeEvent()); + } + + @Test + public void allListenersReceiveEvent() throws Exception { + BlockingQueue events1 = new LinkedBlockingQueue<>(); + BlockingQueue events2 = new LinkedBlockingQueue<>(); + FakeListener listener1 = events1::add; + FakeListener listener2 = events2::add; + broadcaster.register(listener1); + broadcaster.register(listener2); + + FakeEvent e1 = new FakeEvent(); + FakeEvent e2 = new FakeEvent(); + + broadcaster.broadcast(e1); + broadcaster.broadcast(e2); + + assertThat(events1.take(), is(e1)); + assertThat(events1.take(), is(e2)); + assertThat(events1.isEmpty(), is(true)); + + assertThat(events2.take(), is(e1)); + assertThat(events2.take(), is(e2)); + assertThat(events2.isEmpty(), is(true)); + } + + @Test + public void canUnregisterListener() throws Exception { + BlockingQueue events1 = new LinkedBlockingQueue<>(); + BlockingQueue events2 = new LinkedBlockingQueue<>(); + FakeListener listener1 = events1::add; + FakeListener listener2 = events2::add; + broadcaster.register(listener1); + broadcaster.register(listener2); + + FakeEvent e1 = new FakeEvent(); + FakeEvent e2 = new FakeEvent(); + FakeEvent e3 = new FakeEvent(); + + broadcaster.broadcast(e1); + + broadcaster.unregister(listener2); + broadcaster.broadcast(e2); + + broadcaster.register(listener2); + broadcaster.broadcast(e3); + + assertThat(events1.take(), is(e1)); + assertThat(events1.take(), is(e2)); + assertThat(events1.take(), is(e3)); + assertThat(events1.isEmpty(), is(true)); + + assertThat(events2.take(), is(e1)); + assertThat(events2.take(), is(e3)); // did not get e2 + assertThat(events2.isEmpty(), is(true)); + } + + static class FakeEvent {} + + static interface FakeListener { + void sendEvent(FakeEvent e); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientExternalUpdatesOnlyTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientExternalUpdatesOnlyTest.java index 089b3ec12..0da231105 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientExternalUpdatesOnlyTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientExternalUpdatesOnlyTest.java @@ -2,6 +2,7 @@ import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStore; import org.junit.Test; @@ -44,6 +45,8 @@ public void externalUpdatesOnlyClientIsInitialized() throws Exception { .build(); try (LDClient client = new LDClient("SDK_KEY", config)) { assertTrue(client.initialized()); + + assertEquals(DataSourceStatusProvider.State.VALID, client.getDataSourceStatusProvider().getStatus().getState()); } } diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java index 961a7baa6..81e9ba234 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java @@ -9,6 +9,7 @@ import com.launchdarkly.sdk.server.TestUtil.FlagChangeEventSink; import com.launchdarkly.sdk.server.TestUtil.FlagValueChangeEventSink; import com.launchdarkly.sdk.server.integrations.MockPersistentDataStore; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; @@ -18,6 +19,7 @@ import org.easymock.EasyMockSupport; import org.junit.Test; +import java.time.Instant; import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; @@ -27,10 +29,14 @@ import static com.launchdarkly.sdk.server.TestComponents.specificPersistentDataStore; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.hamcrest.Matchers.nullValue; /** * This file contains tests for all of the event broadcaster/listener functionality in the client, plus * related methods for looking at the same kinds of status values that can be broadcast to listeners. + * It uses mock implementations of the data source and data store, so that it is only the status + * monitoring mechanisms that are being tested, not the status behavior of specific real components. *

      * Parts of this functionality are also covered by lower-level component tests like * DataSourceUpdatesImplTest. However, the tests here verify that the client is wiring the components @@ -123,6 +129,55 @@ public void clientSendsFlagValueChangeEvents() throws Exception { } } + @Test + public void dataSourceStatusProviderReturnsLatestStatus() throws Exception { + DataSourceFactoryThatExposesUpdater updatableSource = new DataSourceFactoryThatExposesUpdater(new DataBuilder().build()); + LDConfig config = new LDConfig.Builder() + .dataSource(updatableSource) + .events(Components.noEvents()) + .build(); + + Instant timeBeforeStarting = Instant.now(); + try (LDClient client = new LDClient(SDK_KEY, config)) { + DataSourceStatusProvider.Status initialStatus = client.getDataSourceStatusProvider().getStatus(); + assertThat(initialStatus.getState(), equalTo(DataSourceStatusProvider.State.STARTING)); + assertThat(initialStatus.getStateSince(), greaterThanOrEqualTo(timeBeforeStarting)); + assertThat(initialStatus.getLastError(), nullValue()); + + DataSourceStatusProvider.ErrorInfo errorInfo = new DataSourceStatusProvider.ErrorInfo( + DataSourceStatusProvider.ErrorKind.ERROR_RESPONSE, 401, null, Instant.now()); + updatableSource.dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.OFF, errorInfo); + + DataSourceStatusProvider.Status newStatus = client.getDataSourceStatusProvider().getStatus(); + assertThat(newStatus.getState(), equalTo(DataSourceStatusProvider.State.OFF)); + assertThat(newStatus.getStateSince(), greaterThanOrEqualTo(errorInfo.getTime())); + assertThat(newStatus.getLastError(), equalTo(errorInfo)); + } + } + + @Test + public void dataSourceStatusProviderSendsStatusUpdates() throws Exception { + DataSourceFactoryThatExposesUpdater updatableSource = new DataSourceFactoryThatExposesUpdater(new DataBuilder().build()); + LDConfig config = new LDConfig.Builder() + .dataSource(updatableSource) + .events(Components.noEvents()) + .build(); + + try (LDClient client = new LDClient(SDK_KEY, config)) { + BlockingQueue statuses = new LinkedBlockingQueue<>(); + client.getDataSourceStatusProvider().addStatusListener(statuses::add); + + DataSourceStatusProvider.ErrorInfo errorInfo = new DataSourceStatusProvider.ErrorInfo( + DataSourceStatusProvider.ErrorKind.ERROR_RESPONSE, 401, null, Instant.now()); + updatableSource.dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.OFF, errorInfo); + + DataSourceStatusProvider.Status newStatus = statuses.take(); + assertThat(newStatus.getState(), equalTo(DataSourceStatusProvider.State.OFF)); + assertThat(newStatus.getStateSince(), greaterThanOrEqualTo(errorInfo.getTime())); + assertThat(newStatus.getLastError(), equalTo(errorInfo)); + } + } + @Test public void dataStoreStatusMonitoringIsDisabledForInMemoryStore() throws Exception { LDConfig config = new LDConfig.Builder() @@ -178,7 +233,7 @@ public void dataStoreStatusProviderSendsStatusUpdates() throws Exception { .events(Components.noEvents()) .build(); try (LDClient client = new LDClient(SDK_KEY, config)) { - final BlockingQueue statuses = new LinkedBlockingQueue<>(); + BlockingQueue statuses = new LinkedBlockingQueue<>(); client.getDataStoreStatusProvider().addStatusListener(statuses::add); DataStoreStatusProvider.Status newStatus = new DataStoreStatusProvider.Status(false, false); diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientOfflineTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientOfflineTest.java index 3565554d7..3fd0b79e4 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientOfflineTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientOfflineTest.java @@ -3,11 +3,7 @@ import com.google.common.collect.ImmutableMap; import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; -import com.launchdarkly.sdk.server.Components; -import com.launchdarkly.sdk.server.FeatureFlagsState; -import com.launchdarkly.sdk.server.LDClient; -import com.launchdarkly.sdk.server.LDClientInterface; -import com.launchdarkly.sdk.server.LDConfig; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStore; import org.junit.Test; @@ -52,6 +48,8 @@ public void offlineClientIsInitialized() throws IOException { .build(); try (LDClient client = new LDClient("SDK_KEY", config)) { assertTrue(client.initialized()); + + assertEquals(DataSourceStatusProvider.State.VALID, client.getDataSourceStatusProvider().getStatus().getState()); } } diff --git a/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperTest.java b/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperTest.java index bca0f7000..60821ea2a 100644 --- a/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperTest.java @@ -2,6 +2,8 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; +import com.launchdarkly.sdk.server.PersistentDataStoreStatusManager; +import com.launchdarkly.sdk.server.PersistentDataStoreWrapper; import com.launchdarkly.sdk.server.DataStoreTestTypes.DataBuilder; import com.launchdarkly.sdk.server.DataStoreTestTypes.TestItem; import com.launchdarkly.sdk.server.integrations.MockPersistentDataStore; @@ -109,8 +111,7 @@ public PersistentDataStoreWrapperTest(TestMode testMode) { this::updateStatus, sharedExecutor ); - this.statusBroadcaster = new EventBroadcasterImpl<>( - DataStoreStatusProvider.StatusListener::dataStoreStatusChanged, sharedExecutor); + this.statusBroadcaster = EventBroadcasterImpl.forDataStoreStatus(sharedExecutor); this.dataStoreUpdates = new DataStoreUpdatesImpl(statusBroadcaster); this.dataStoreStatusProvider = new DataStoreStatusProviderImpl(wrapper, dataStoreUpdates); } diff --git a/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java index 84b658d4b..eeee7131f 100644 --- a/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java @@ -1,25 +1,36 @@ package com.launchdarkly.sdk.server; +import com.launchdarkly.sdk.server.TestComponents.MockDataSourceUpdates; +import com.launchdarkly.sdk.server.TestComponents.MockDataStoreStatusProvider; import com.launchdarkly.sdk.server.integrations.PollingDataSourceBuilder; import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; -import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; +import org.junit.Before; import org.junit.Test; import java.io.IOException; import java.net.URI; import java.time.Duration; import java.util.HashMap; +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 static com.launchdarkly.sdk.server.TestComponents.clientContext; -import static com.launchdarkly.sdk.server.TestComponents.dataSourceUpdates; import static com.launchdarkly.sdk.server.TestComponents.sharedExecutor; +import static com.launchdarkly.sdk.server.TestUtil.requireDataSourceStatus; +import static com.launchdarkly.sdk.server.TestUtil.requireDataSourceStatusEventually; +import static com.launchdarkly.sdk.server.TestUtil.shouldNotTimeOut; +import static com.launchdarkly.sdk.server.TestUtil.shouldTimeOut; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNotSame; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; @@ -27,11 +38,26 @@ public class PollingProcessorTest { private static final String SDK_KEY = "sdk-key"; private static final Duration LENGTHY_INTERVAL = Duration.ofSeconds(60); - - private PollingProcessor makeProcessor(FeatureRequestor requestor, DataStore store) { - return new PollingProcessor(requestor, dataSourceUpdates(store), sharedExecutor, LENGTHY_INTERVAL); + + private InMemoryDataStore store; + private MockDataSourceUpdates dataSourceUpdates; + private MockFeatureRequestor requestor; + + @Before + public void setup() { + store = new InMemoryDataStore(); + dataSourceUpdates = TestComponents.dataSourceUpdates(store, new MockDataStoreStatusProvider()); + requestor = new MockFeatureRequestor(); } - + + private PollingProcessor makeProcessor() { + return makeProcessor(LENGTHY_INTERVAL); + } + + private PollingProcessor makeProcessor(Duration pollInterval) { + return new PollingProcessor(requestor, dataSourceUpdates, sharedExecutor, pollInterval); + } + @Test public void builderHasDefaultConfiguration() throws Exception { DataSourceFactory f = Components.pollingDataSource(); @@ -55,25 +81,29 @@ public void builderCanSpecifyConfiguration() throws Exception { @Test public void testConnectionOk() throws Exception { - MockFeatureRequestor requestor = new MockFeatureRequestor(); requestor.allData = new FeatureRequestor.AllData(new HashMap<>(), new HashMap<>()); - DataStore store = new InMemoryDataStore(); - try (PollingProcessor pollingProcessor = makeProcessor(requestor, store)) { + BlockingQueue statuses = new LinkedBlockingQueue<>(); + dataSourceUpdates.statusBroadcaster.register(statuses::add); + + try (PollingProcessor pollingProcessor = makeProcessor()) { Future initFuture = pollingProcessor.start(); initFuture.get(1000, TimeUnit.MILLISECONDS); assertTrue(pollingProcessor.isInitialized()); assertTrue(store.isInitialized()); + + requireDataSourceStatus(statuses, DataSourceStatusProvider.State.VALID); } } @Test public void testConnectionProblem() throws Exception { - MockFeatureRequestor requestor = new MockFeatureRequestor(); requestor.ioException = new IOException("This exception is part of a test and yes you should be seeing it."); - DataStore store = new InMemoryDataStore(); - try (PollingProcessor pollingProcessor = makeProcessor(requestor, store)) { + BlockingQueue statuses = new LinkedBlockingQueue<>(); + dataSourceUpdates.statusBroadcaster.register(statuses::add); + + try (PollingProcessor pollingProcessor = makeProcessor()) { Future initFuture = pollingProcessor.start(); try { initFuture.get(200L, TimeUnit.MILLISECONDS); @@ -83,6 +113,10 @@ public void testConnectionProblem() throws Exception { assertFalse(initFuture.isDone()); assertFalse(pollingProcessor.isInitialized()); assertFalse(store.isInitialized()); + + DataSourceStatusProvider.Status status = requireDataSourceStatus(statuses, DataSourceStatusProvider.State.STARTING); + assertNotNull(status.getLastError()); + assertEquals(DataSourceStatusProvider.ErrorKind.NETWORK_ERROR, status.getLastError().getKind()); } } @@ -116,46 +150,80 @@ public void http500ErrorIsRecoverable() throws Exception { testRecoverableHttpError(500); } - private void testUnrecoverableHttpError(int status) throws Exception { - MockFeatureRequestor requestor = new MockFeatureRequestor(); - requestor.httpException = new HttpErrorException(status); - DataStore store = new InMemoryDataStore(); + private void testUnrecoverableHttpError(int statusCode) throws Exception { + requestor.httpException = new HttpErrorException(statusCode); - try (PollingProcessor pollingProcessor = makeProcessor(requestor, store)) { + BlockingQueue statuses = new LinkedBlockingQueue<>(); + dataSourceUpdates.statusBroadcaster.register(statuses::add); + + try (PollingProcessor pollingProcessor = makeProcessor()) { long startTime = System.currentTimeMillis(); Future initFuture = pollingProcessor.start(); - try { - initFuture.get(10, TimeUnit.SECONDS); - } catch (TimeoutException ignored) { - fail("Should not have timed out"); - } + + shouldNotTimeOut(initFuture, Duration.ofSeconds(2)); assertTrue((System.currentTimeMillis() - startTime) < 9000); assertTrue(initFuture.isDone()); assertFalse(pollingProcessor.isInitialized()); + + DataSourceStatusProvider.Status status = requireDataSourceStatus(statuses, DataSourceStatusProvider.State.OFF); + assertNotNull(status.getLastError()); + assertEquals(DataSourceStatusProvider.ErrorKind.ERROR_RESPONSE, status.getLastError().getKind()); + assertEquals(statusCode, status.getLastError().getStatusCode()); } } - private void testRecoverableHttpError(int status) throws Exception { - MockFeatureRequestor requestor = new MockFeatureRequestor(); - requestor.httpException = new HttpErrorException(status); - DataStore store = new InMemoryDataStore(); + private void testRecoverableHttpError(int statusCode) throws Exception { + HttpErrorException httpError = new HttpErrorException(statusCode); + Duration shortInterval = Duration.ofMillis(20); + requestor.httpException = httpError; - try (PollingProcessor pollingProcessor = makeProcessor(requestor, store)) { + BlockingQueue statuses = new LinkedBlockingQueue<>(); + dataSourceUpdates.statusBroadcaster.register(statuses::add); + + try (PollingProcessor pollingProcessor = makeProcessor(shortInterval)) { Future initFuture = pollingProcessor.start(); - try { - initFuture.get(200, TimeUnit.MILLISECONDS); - fail("expected timeout"); - } catch (TimeoutException ignored) { - } + + // first poll gets an error + shouldTimeOut(initFuture, Duration.ofMillis(200)); assertFalse(initFuture.isDone()); assertFalse(pollingProcessor.isInitialized()); + + DataSourceStatusProvider.Status status1 = requireDataSourceStatus(statuses, DataSourceStatusProvider.State.STARTING); + assertNotNull(status1.getLastError()); + assertEquals(DataSourceStatusProvider.ErrorKind.ERROR_RESPONSE, status1.getLastError().getKind()); + assertEquals(statusCode, status1.getLastError().getStatusCode()); + + // now make it so the requestor will succeed + requestor.allData = new FeatureRequestor.AllData(new HashMap<>(), new HashMap<>()); + requestor.httpException = null; + + shouldNotTimeOut(initFuture, Duration.ofSeconds(2)); + assertTrue(initFuture.isDone()); + assertTrue(pollingProcessor.isInitialized()); + + // status should now be VALID (although there might have been more failed polls before that) + DataSourceStatusProvider.Status status2 = requireDataSourceStatusEventually(statuses, + DataSourceStatusProvider.State.VALID, DataSourceStatusProvider.State.STARTING); + assertNotNull(status2.getLastError()); + assertEquals(DataSourceStatusProvider.ErrorKind.ERROR_RESPONSE, status2.getLastError().getKind()); + assertEquals(statusCode, status2.getLastError().getStatusCode()); + + // simulate another error of the same kind - the difference is now the state will be INTERRUPTED + requestor.httpException = httpError; + + DataSourceStatusProvider.Status status3 = requireDataSourceStatusEventually(statuses, + DataSourceStatusProvider.State.INTERRUPTED, DataSourceStatusProvider.State.VALID); + assertNotNull(status3.getLastError()); + assertEquals(DataSourceStatusProvider.ErrorKind.ERROR_RESPONSE, status3.getLastError().getKind()); + assertEquals(statusCode, status3.getLastError().getStatusCode()); + assertNotSame(status1.getLastError(), status3.getLastError()); // it's a new error object of the same kind } } private static class MockFeatureRequestor implements FeatureRequestor { - AllData allData; - HttpErrorException httpException; - IOException ioException; + volatile AllData allData; + volatile HttpErrorException httpException; + volatile IOException ioException; public void close() throws IOException {} diff --git a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java index 945b3cdf5..b067024d6 100644 --- a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java @@ -5,14 +5,16 @@ import com.launchdarkly.eventsource.EventSource; import com.launchdarkly.eventsource.MessageEvent; import com.launchdarkly.eventsource.UnsuccessfulResponseException; +import com.launchdarkly.sdk.server.StreamProcessor.EventSourceParams; +import com.launchdarkly.sdk.server.TestComponents.MockDataSourceUpdates; import com.launchdarkly.sdk.server.TestComponents.MockDataStoreStatusProvider; import com.launchdarkly.sdk.server.TestComponents.MockEventSourceCreator; import com.launchdarkly.sdk.server.integrations.StreamingDataSourceBuilder; import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; -import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; import org.easymock.EasyMockSupport; import org.junit.Before; @@ -26,8 +28,6 @@ import java.util.concurrent.CompletableFuture; 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; @@ -37,10 +37,13 @@ import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; import static com.launchdarkly.sdk.server.ModelBuilders.segmentBuilder; import static com.launchdarkly.sdk.server.TestComponents.clientContext; -import static com.launchdarkly.sdk.server.TestComponents.dataStoreThatThrowsException; import static com.launchdarkly.sdk.server.TestComponents.dataSourceUpdates; +import static com.launchdarkly.sdk.server.TestComponents.dataStoreThatThrowsException; import static com.launchdarkly.sdk.server.TestHttpUtil.eventStreamResponse; import static com.launchdarkly.sdk.server.TestHttpUtil.makeStartedServer; +import static com.launchdarkly.sdk.server.TestUtil.requireDataSourceStatus; +import static com.launchdarkly.sdk.server.TestUtil.shouldNotTimeOut; +import static com.launchdarkly.sdk.server.TestUtil.shouldTimeOut; import static com.launchdarkly.sdk.server.TestUtil.upsertFlag; import static com.launchdarkly.sdk.server.TestUtil.upsertSegment; import static com.launchdarkly.sdk.server.integrations.StreamingDataSourceBuilder.DEFAULT_INITIAL_RECONNECT_DELAY; @@ -53,9 +56,10 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNotSame; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; import okhttp3.HttpUrl; import okhttp3.mockwebserver.MockWebServer; @@ -76,6 +80,8 @@ public class StreamProcessorTest extends EasyMockSupport { "data: {\"data\":{\"flags\":{},\"segments\":{}}}\n\n"; private InMemoryDataStore dataStore; + private MockDataSourceUpdates dataSourceUpdates; + private MockDataStoreStatusProvider dataStoreStatusProvider; private FeatureRequestor mockRequestor; private EventSource mockEventSource; private MockEventSourceCreator mockEventSourceCreator; @@ -83,6 +89,8 @@ public class StreamProcessorTest extends EasyMockSupport { @Before public void setup() { dataStore = new InMemoryDataStore(); + dataStoreStatusProvider = new MockDataStoreStatusProvider(); + dataSourceUpdates = TestComponents.dataSourceUpdates(dataStore, dataStoreStatusProvider); mockRequestor = createStrictMock(FeatureRequestor.class); mockEventSource = createMock(EventSource.class); mockEventSourceCreator = new MockEventSourceCreator(mockEventSource); @@ -92,7 +100,7 @@ public void setup() { public void builderHasDefaultConfiguration() throws Exception { DataSourceFactory f = Components.streamingDataSource(); try (StreamProcessor sp = (StreamProcessor)f.createDataSource(clientContext(SDK_KEY, LDConfig.DEFAULT), - dataSourceUpdates(dataStore))) { + dataSourceUpdates)) { assertThat(sp.initialReconnectDelay, equalTo(StreamingDataSourceBuilder.DEFAULT_INITIAL_RECONNECT_DELAY)); assertThat(sp.streamUri, equalTo(LDConfig.DEFAULT_STREAM_URI)); assertThat(((DefaultFeatureRequestor)sp.requestor).baseUri, equalTo(LDConfig.DEFAULT_BASE_URI)); @@ -476,22 +484,22 @@ public void http500ErrorIsRecoverable() throws Exception { @Test public void putEventWithInvalidJsonCausesStreamRestart() throws Exception { - verifyEventCausesStreamRestartWithInMemoryStore("put", "{sorry"); + verifyInvalidDataEvent("put", "{sorry"); } @Test public void putEventWithWellFormedJsonButInvalidDataCausesStreamRestart() throws Exception { - verifyEventCausesStreamRestartWithInMemoryStore("put", "{\"data\":{\"flags\":3}}"); + verifyInvalidDataEvent("put", "{\"data\":{\"flags\":3}}"); } @Test public void patchEventWithInvalidJsonCausesStreamRestart() throws Exception { - verifyEventCausesStreamRestartWithInMemoryStore("patch", "{sorry"); + verifyInvalidDataEvent("patch", "{sorry"); } @Test public void patchEventWithWellFormedJsonButInvalidDataCausesStreamRestart() throws Exception { - verifyEventCausesStreamRestartWithInMemoryStore("patch", "{\"path\":\"/flags/flagkey\", \"data\":{\"rules\":3}}"); + verifyInvalidDataEvent("patch", "{\"path\":\"/flags/flagkey\", \"data\":{\"rules\":3}}"); } @Test @@ -501,7 +509,7 @@ public void patchEventWithInvalidPathCausesNoStreamRestart() throws Exception { @Test public void deleteEventWithInvalidJsonCausesStreamRestart() throws Exception { - verifyEventCausesStreamRestartWithInMemoryStore("delete", "{sorry"); + verifyInvalidDataEvent("delete", "{sorry"); } @Test @@ -528,9 +536,6 @@ public void indirectPatchWithFailedPollCausesStreamRestart() throws Exception { @Test public void restartsStreamIfStoreNeedsRefresh() throws Exception { - MockDataStoreStatusProvider dataStoreStatusProvider = new MockDataStoreStatusProvider(); - DataSourceUpdates storeUpdates = new DataSourceUpdatesImpl(dataStore, null, dataStoreStatusProvider); - CompletableFuture restarted = new CompletableFuture<>(); mockEventSource.start(); expectLastCall(); @@ -546,7 +551,7 @@ public void restartsStreamIfStoreNeedsRefresh() throws Exception { replayAll(); - try (StreamProcessor sp = createStreamProcessorWithStoreUpdates(storeUpdates)) { + try (StreamProcessor sp = createStreamProcessor(STREAM_URI)) { sp.start(); dataStoreStatusProvider.updateStatus(new DataStoreStatusProvider.Status(false, false)); @@ -558,9 +563,6 @@ public void restartsStreamIfStoreNeedsRefresh() throws Exception { @Test public void doesNotRestartStreamIfStoreHadOutageButDoesNotNeedRefresh() throws Exception { - MockDataStoreStatusProvider dataStoreStatusProvider = new MockDataStoreStatusProvider(); - DataSourceUpdates storeUpdates = new DataSourceUpdatesImpl(dataStore, null, dataStoreStatusProvider); - CompletableFuture restarted = new CompletableFuture<>(); mockEventSource.start(); expectLastCall(); @@ -576,7 +578,7 @@ public void doesNotRestartStreamIfStoreHadOutageButDoesNotNeedRefresh() throws E replayAll(); - try (StreamProcessor sp = createStreamProcessorWithStoreUpdates(storeUpdates)) { + try (StreamProcessor sp = createStreamProcessor(STREAM_URI)) { sp.start(); dataStoreStatusProvider.updateStatus(new DataStoreStatusProvider.Status(false, false)); @@ -682,6 +684,19 @@ private void verifyEventBehavior(String eventName, String eventData) throws Exce verifyAll(); } + private void verifyInvalidDataEvent(String eventName, String eventData) throws Exception { + BlockingQueue statuses = new LinkedBlockingQueue<>(); + dataSourceUpdates.statusBroadcaster.register(statuses::add); + + verifyEventCausesStreamRestartWithInMemoryStore(eventName, eventData); + + // We did not allow the stream to successfully process an event before causing the error, so the + // state will still be STARTING, but we should be able to see that an error happened. + DataSourceStatusProvider.Status status = requireDataSourceStatus(statuses, DataSourceStatusProvider.State.STARTING); + assertNotNull(status.getLastError()); + assertEquals(DataSourceStatusProvider.ErrorKind.INVALID_DATA, status.getLastError().getKind()); + } + private void expectNoStreamRestart() throws Exception { mockEventSource.start(); expectLastCall().times(1); @@ -775,44 +790,71 @@ public Action onConnectionError(Throwable t) { } } - private void testUnrecoverableHttpError(int status) throws Exception { - UnsuccessfulResponseException e = new UnsuccessfulResponseException(status); + private void testUnrecoverableHttpError(int statusCode) throws Exception { + UnsuccessfulResponseException e = new UnsuccessfulResponseException(statusCode); long startTime = System.currentTimeMillis(); StreamProcessor sp = createStreamProcessor(STREAM_URI); Future initFuture = sp.start(); + BlockingQueue statuses = new LinkedBlockingQueue<>(); + dataSourceUpdates.statusBroadcaster.register(statuses::add); + ConnectionErrorHandler errorHandler = mockEventSourceCreator.getNextReceivedParams().errorHandler; ConnectionErrorHandler.Action action = errorHandler.onConnectionError(e); assertEquals(ConnectionErrorHandler.Action.SHUTDOWN, action); - try { - initFuture.get(10, TimeUnit.SECONDS); - } catch (TimeoutException ignored) { - fail("Should not have timed out"); - } + shouldNotTimeOut(initFuture, Duration.ofSeconds(2)); assertTrue((System.currentTimeMillis() - startTime) < 9000); assertTrue(initFuture.isDone()); assertFalse(sp.isInitialized()); + + DataSourceStatusProvider.Status newStatus = requireDataSourceStatus(statuses, DataSourceStatusProvider.State.OFF); + assertEquals(DataSourceStatusProvider.ErrorKind.ERROR_RESPONSE, newStatus.getLastError().getKind()); + assertEquals(statusCode, newStatus.getLastError().getStatusCode()); } - private void testRecoverableHttpError(int status) throws Exception { - UnsuccessfulResponseException e = new UnsuccessfulResponseException(status); + private void testRecoverableHttpError(int statusCode) throws Exception { + UnsuccessfulResponseException e = new UnsuccessfulResponseException(statusCode); long startTime = System.currentTimeMillis(); StreamProcessor sp = createStreamProcessor(STREAM_URI); Future initFuture = sp.start(); - ConnectionErrorHandler errorHandler = mockEventSourceCreator.getNextReceivedParams().errorHandler; + BlockingQueue statuses = new LinkedBlockingQueue<>(); + dataSourceUpdates.statusBroadcaster.register(statuses::add); + + // simulate error + EventSourceParams eventSourceParams = mockEventSourceCreator.getNextReceivedParams(); + ConnectionErrorHandler errorHandler = eventSourceParams.errorHandler; ConnectionErrorHandler.Action action = errorHandler.onConnectionError(e); assertEquals(ConnectionErrorHandler.Action.PROCEED, action); - try { - initFuture.get(200, TimeUnit.MILLISECONDS); - fail("Expected timeout"); - } catch (TimeoutException ignored) { - } + shouldTimeOut(initFuture, Duration.ofMillis(200)); assertTrue((System.currentTimeMillis() - startTime) >= 200); assertFalse(initFuture.isDone()); assertFalse(sp.isInitialized()); + + DataSourceStatusProvider.Status failureStatus1 = requireDataSourceStatus(statuses, DataSourceStatusProvider.State.STARTING); + assertEquals(DataSourceStatusProvider.ErrorKind.ERROR_RESPONSE, failureStatus1.getLastError().getKind()); + assertEquals(statusCode, failureStatus1.getLastError().getStatusCode()); + + // simulate successful retry + eventSourceParams.handler.onMessage("put", emptyPutEvent()); + + shouldNotTimeOut(initFuture, Duration.ofSeconds(2)); + assertTrue(initFuture.isDone()); + assertTrue(sp.isInitialized()); + + DataSourceStatusProvider.Status successStatus = requireDataSourceStatus(statuses, DataSourceStatusProvider.State.VALID); + assertSame(failureStatus1.getLastError(), successStatus.getLastError()); + + // simulate another error of the same kind - the difference is now the state will be INTERRUPTED + action = errorHandler.onConnectionError(e); + assertEquals(ConnectionErrorHandler.Action.PROCEED, action); + + DataSourceStatusProvider.Status failureStatus2 = requireDataSourceStatus(statuses, DataSourceStatusProvider.State.INTERRUPTED); + assertEquals(DataSourceStatusProvider.ErrorKind.ERROR_RESPONSE, failureStatus2.getLastError().getKind()); + assertEquals(statusCode, failureStatus2.getLastError().getStatusCode()); + assertNotSame(failureStatus2.getLastError(), failureStatus1.getLastError()); // a new instance of the same kind of error } private StreamProcessor createStreamProcessor(URI streamUri) { @@ -824,22 +866,18 @@ private StreamProcessor createStreamProcessor(LDConfig config, URI streamUri) { } private StreamProcessor createStreamProcessor(LDConfig config, URI streamUri, DiagnosticAccumulator diagnosticAccumulator) { - return new StreamProcessor(SDK_KEY, config.httpConfig, mockRequestor, dataSourceUpdates(dataStore), + return new StreamProcessor(SDK_KEY, config.httpConfig, mockRequestor, dataSourceUpdates, mockEventSourceCreator, diagnosticAccumulator, streamUri, DEFAULT_INITIAL_RECONNECT_DELAY); } private StreamProcessor createStreamProcessorWithRealHttp(LDConfig config, URI streamUri) { - return new StreamProcessor(SDK_KEY, config.httpConfig, mockRequestor, dataSourceUpdates(dataStore), null, null, + return new StreamProcessor(SDK_KEY, config.httpConfig, mockRequestor, dataSourceUpdates, null, null, streamUri, DEFAULT_INITIAL_RECONNECT_DELAY); } private StreamProcessor createStreamProcessorWithStore(DataStore store) { - return createStreamProcessorWithStoreUpdates(dataSourceUpdates(store)); - } - - private StreamProcessor createStreamProcessorWithStoreUpdates(DataSourceUpdates storeUpdates) { - return new StreamProcessor(SDK_KEY, LDConfig.DEFAULT.httpConfig, mockRequestor, storeUpdates, + return new StreamProcessor(SDK_KEY, LDConfig.DEFAULT.httpConfig, mockRequestor, TestComponents.dataSourceUpdates(store), mockEventSourceCreator, null, STREAM_URI, DEFAULT_INITIAL_RECONNECT_DELAY); } diff --git a/src/test/java/com/launchdarkly/sdk/server/TestComponents.java b/src/test/java/com/launchdarkly/sdk/server/TestComponents.java index 4f1e932fa..088ad5dd7 100644 --- a/src/test/java/com/launchdarkly/sdk/server/TestComponents.java +++ b/src/test/java/com/launchdarkly/sdk/server/TestComponents.java @@ -7,6 +7,9 @@ import com.launchdarkly.sdk.server.interfaces.ClientContext; import com.launchdarkly.sdk.server.interfaces.DataSource; import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorInfo; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.State; import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; @@ -20,6 +23,8 @@ import com.launchdarkly.sdk.server.interfaces.Event; import com.launchdarkly.sdk.server.interfaces.EventProcessor; import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; +import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; +import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; import com.launchdarkly.sdk.server.interfaces.PersistentDataStore; import com.launchdarkly.sdk.server.interfaces.PersistentDataStoreFactory; @@ -53,14 +58,18 @@ public static DataSourceFactory dataSourceWithData(FullDataSet d return (context, dataSourceUpdates) -> new DataSourceWithData(data, dataSourceUpdates); } - public static DataStore dataStoreThatThrowsException(final RuntimeException e) { + public static DataStore dataStoreThatThrowsException(RuntimeException e) { return new DataStoreThatThrowsException(e); } - public static DataSourceUpdates dataSourceUpdates(final DataStore store) { - return new DataSourceUpdatesImpl(store, null, null); + public static MockDataSourceUpdates dataSourceUpdates(DataStore store) { + return dataSourceUpdates(store, null); } + public static MockDataSourceUpdates dataSourceUpdates(DataStore store, DataStoreStatusProvider dataStoreStatusProvider) { + return new MockDataSourceUpdates(store, dataStoreStatusProvider); + } + static EventsConfiguration defaultEventsConfig() { return makeEventsConfig(false, false, null); } @@ -123,7 +132,7 @@ public void flush() {} public static class DataSourceFactoryThatExposesUpdater implements DataSourceFactory { private final FullDataSet initialData; - private DataSourceUpdates dataSourceUpdates; + DataSourceUpdates dataSourceUpdates; public DataSourceFactoryThatExposesUpdater(FullDataSet initialData) { this.initialData = initialData; @@ -175,6 +184,51 @@ public void close() throws IOException { } } + public static class MockDataSourceUpdates implements DataSourceUpdates { + private final DataSourceUpdates wrappedInstance; + private final DataStoreStatusProvider dataStoreStatusProvider; + public final EventBroadcasterImpl flagChangeEventBroadcaster; + public final EventBroadcasterImpl + statusBroadcaster; + + public MockDataSourceUpdates(DataStore store, DataStoreStatusProvider dataStoreStatusProvider) { + this.dataStoreStatusProvider = dataStoreStatusProvider; + this.flagChangeEventBroadcaster = EventBroadcasterImpl.forFlagChangeEvents(sharedExecutor); + this.statusBroadcaster = EventBroadcasterImpl.forDataSourceStatus(sharedExecutor); + this.wrappedInstance = new DataSourceUpdatesImpl( + store, + dataStoreStatusProvider, + flagChangeEventBroadcaster, + statusBroadcaster + ); + } + + @Override + public void init(FullDataSet allData) { + wrappedInstance.init(allData); + } + + @Override + public void upsert(DataKind kind, String key, ItemDescriptor item) { + wrappedInstance.upsert(kind, key, item); + } + + @Override + public DataStoreStatusProvider getDataStoreStatusProvider() { + return dataStoreStatusProvider; + } + + @Override + public void updateStatus(State newState, ErrorInfo newError) { + wrappedInstance.updateStatus(newState, newError); + } + + // this method is surfaced for use by tests in other packages that can't see the EventBroadcasterImpl class + public void register(DataSourceStatusProvider.StatusListener listener) { + statusBroadcaster.register(listener); + } + } + public static class DataStoreFactoryThatExposesUpdater implements DataStoreFactory { public volatile DataStoreUpdates dataStoreUpdates; private final DataStoreFactory wrappedFactory; @@ -229,12 +283,11 @@ public CacheStats getCacheStats() { } public static class MockDataStoreStatusProvider implements DataStoreStatusProvider { - private final EventBroadcasterImpl statusBroadcaster; + public final EventBroadcasterImpl statusBroadcaster; private final AtomicReference lastStatus; public MockDataStoreStatusProvider() { - this.statusBroadcaster = new EventBroadcasterImpl<>( - DataStoreStatusProvider.StatusListener::dataStoreStatusChanged, sharedExecutor); + this.statusBroadcaster = EventBroadcasterImpl.forDataStoreStatus(sharedExecutor); this.lastStatus = new AtomicReference<>(new DataStoreStatusProvider.Status(true, false)); } diff --git a/src/test/java/com/launchdarkly/sdk/server/TestUtil.java b/src/test/java/com/launchdarkly/sdk/server/TestUtil.java index c81630b5c..95789c978 100644 --- a/src/test/java/com/launchdarkly/sdk/server/TestUtil.java +++ b/src/test/java/com/launchdarkly/sdk/server/TestUtil.java @@ -7,6 +7,7 @@ import com.launchdarkly.sdk.LDValueType; import com.launchdarkly.sdk.server.DataModel.FeatureFlag; import com.launchdarkly.sdk.server.DataModel.Segment; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; @@ -18,16 +19,23 @@ import org.hamcrest.Matcher; import org.hamcrest.TypeSafeDiagnosingMatcher; +import java.time.Duration; +import java.time.Instant; import java.util.HashSet; import java.util.Set; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.function.Supplier; import static com.launchdarkly.sdk.server.DataModel.FEATURES; import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.fail; @@ -44,6 +52,23 @@ public class TestUtil { */ public static final Gson TEST_GSON_INSTANCE = new Gson(); + // repeats until action returns non-null value, throws exception on timeout + public static T repeatWithTimeout(Duration timeout, Duration interval, Supplier action) { + Instant deadline = Instant.now().plus(timeout); + while (Instant.now().isBefore(deadline)) { + T result = action.get(); + if (result != null) { + return result; + } + try { + Thread.sleep(interval.toMillis()); + } catch (InterruptedException e) { // it's annoying to have to keep declaring this exception further up the call chain + throw new RuntimeException(e); + } + } + throw new RuntimeException("timed out after " + timeout); + } + public static void upsertFlag(DataStore store, FeatureFlag flag) { store.upsert(FEATURES, flag.getKey(), new ItemDescriptor(flag.getVersion(), flag)); } @@ -51,6 +76,51 @@ public static void upsertFlag(DataStore store, FeatureFlag flag) { public static void upsertSegment(DataStore store, Segment segment) { store.upsert(SEGMENTS, segment.getKey(), new ItemDescriptor(segment.getVersion(), segment)); } + + public static void shouldNotTimeOut(Future future, Duration interval) throws ExecutionException, InterruptedException { + try { + future.get(interval.toMillis(), TimeUnit.MILLISECONDS); + } catch (TimeoutException ignored) { + fail("Should not have timed out"); + } + } + + public static void shouldTimeOut(Future future, Duration interval) throws ExecutionException, InterruptedException { + try { + future.get(interval.toMillis(), TimeUnit.MILLISECONDS); + fail("Expected timeout"); + } catch (TimeoutException ignored) { + } + } + + public static DataSourceStatusProvider.Status requireDataSourceStatus(BlockingQueue statuses) { + try { + DataSourceStatusProvider.Status status = statuses.poll(1, TimeUnit.SECONDS); + assertNotNull(status); + return status; + } catch (InterruptedException e) { // it's annoying to have to keep declaring this exception further up the call chain + throw new RuntimeException(e); + } + } + + public static DataSourceStatusProvider.Status requireDataSourceStatus(BlockingQueue statuses, + DataSourceStatusProvider.State expectedState) { + DataSourceStatusProvider.Status status = requireDataSourceStatus(statuses); + assertEquals(expectedState, status.getState()); + return status; + } + + public static DataSourceStatusProvider.Status requireDataSourceStatusEventually(BlockingQueue statuses, + DataSourceStatusProvider.State expectedState, DataSourceStatusProvider.State possibleStateBeforeThat) { + return repeatWithTimeout(Duration.ofSeconds(2), Duration.ZERO, () -> { + DataSourceStatusProvider.Status status = requireDataSourceStatus(statuses); + if (status.getState() == expectedState) { + return status; + } + assertEquals(possibleStateBeforeThat, status.getState()); + return null; + }); + } public static class FlagChangeEventSink extends FlagChangeEventSinkBase implements FlagChangeListener { @Override diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java index b2c02bc86..58efd3a56 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java @@ -1,7 +1,10 @@ package com.launchdarkly.sdk.server.integrations; import com.launchdarkly.sdk.server.LDConfig; +import com.launchdarkly.sdk.server.TestComponents; +import com.launchdarkly.sdk.server.TestComponents.MockDataSourceUpdates; import com.launchdarkly.sdk.server.interfaces.DataSource; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStore; import org.junit.Test; @@ -10,32 +13,41 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.time.Duration; +import java.util.concurrent.BlockingQueue; import java.util.concurrent.Future; +import java.util.concurrent.LinkedBlockingQueue; import static com.google.common.collect.Iterables.size; import static com.launchdarkly.sdk.server.DataModel.FEATURES; import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; import static com.launchdarkly.sdk.server.DataStoreTestTypes.toItemsMap; import static com.launchdarkly.sdk.server.TestComponents.clientContext; -import static com.launchdarkly.sdk.server.TestComponents.dataSourceUpdates; import static com.launchdarkly.sdk.server.TestComponents.inMemoryDataStore; +import static com.launchdarkly.sdk.server.TestUtil.repeatWithTimeout; +import static com.launchdarkly.sdk.server.TestUtil.requireDataSourceStatus; +import static com.launchdarkly.sdk.server.TestUtil.requireDataSourceStatusEventually; import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.ALL_FLAG_KEYS; import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.ALL_SEGMENT_KEYS; import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.getResourceContents; import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.resourceFilePath; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; -import static org.junit.Assert.fail; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; @SuppressWarnings("javadoc") public class FileDataSourceTest { private static final Path badFilePath = Paths.get("no-such-file.json"); - private final DataStore store = inMemoryDataStore(); + private final DataStore store; + private MockDataSourceUpdates dataSourceUpdates; private final LDConfig config = new LDConfig.Builder().build(); private final FileDataSourceBuilder factory; public FileDataSourceTest() throws Exception { + store = inMemoryDataStore(); + dataSourceUpdates = TestComponents.dataSourceUpdates(store); factory = makeFactoryWithFile(resourceFilePath("all-properties.json")); } @@ -44,7 +56,7 @@ private static FileDataSourceBuilder makeFactoryWithFile(Path path) { } private DataSource makeDataSource(FileDataSourceBuilder builder) { - return builder.createDataSource(clientContext("", config), dataSourceUpdates(store)); + return builder.createDataSource(clientContext("", config), dataSourceUpdates); } @Test @@ -81,7 +93,20 @@ public void initializedIsTrueAfterSuccessfulLoad() throws Exception { assertThat(fp.isInitialized(), equalTo(true)); } } - + + @Test + public void statusIsValidAfterSuccessfulLoad() throws Exception { + BlockingQueue statuses = new LinkedBlockingQueue<>(); + dataSourceUpdates.register(statuses::add); + + try (DataSource fp = makeDataSource(factory)) { + fp.start(); + assertThat(fp.isInitialized(), equalTo(true)); + + requireDataSourceStatus(statuses, DataSourceStatusProvider.State.VALID); + } + } + @Test public void startFutureIsCompletedAfterUnsuccessfulLoad() throws Exception { factory.filePaths(badFilePath); @@ -99,6 +124,22 @@ public void initializedIsFalseAfterUnsuccessfulLoad() throws Exception { assertThat(fp.isInitialized(), equalTo(false)); } } + + @Test + public void statusIsStartingAfterUnsuccessfulLoad() throws Exception { + BlockingQueue statuses = new LinkedBlockingQueue<>(); + dataSourceUpdates.register(statuses::add); + + factory.filePaths(badFilePath); + try (DataSource fp = makeDataSource(factory)) { + fp.start(); + assertThat(fp.isInitialized(), equalTo(false)); + + DataSourceStatusProvider.Status status = requireDataSourceStatus(statuses, DataSourceStatusProvider.State.STARTING); + assertNotNull(status.getLastError()); + assertEquals(DataSourceStatusProvider.ErrorKind.INVALID_DATA, status.getLastError().getKind()); + } + } @Test public void modifiedFileIsNotReloadedIfAutoUpdateIsOff() throws Exception { @@ -125,22 +166,19 @@ public void modifiedFileIsNotReloadedIfAutoUpdateIsOff() throws Exception { public void modifiedFileIsReloadedIfAutoUpdateIsOn() throws Exception { File file = makeTempFlagFile(); FileDataSourceBuilder factory1 = makeFactoryWithFile(file.toPath()).autoUpdate(true); - long maxMsToWait = 10000; try { setFileContents(file, getResourceContents("flag-only.json")); // this file has 1 flag try (DataSource fp = makeDataSource(factory1)) { 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) { + repeatWithTimeout(Duration.ofSeconds(10), Duration.ofMillis(500), () -> { if (toItemsMap(store.getAll(FEATURES)).size() == ALL_FLAG_KEYS.size()) { - // success - return; + // success - return a non-null value to make repeatWithTimeout end + return fp; } - Thread.sleep(500); - } - fail("Waited " + maxMsToWait + "ms after modifying file and it did not reload"); + return null; + }); } } finally { file.delete(); @@ -149,24 +187,29 @@ public void modifiedFileIsReloadedIfAutoUpdateIsOn() throws Exception { @Test public void ifFilesAreBadAtStartTimeAutoUpdateCanStillLoadGoodDataLater() throws Exception { + BlockingQueue statuses = new LinkedBlockingQueue<>(); + dataSourceUpdates.register(statuses::add); + File file = makeTempFlagFile(); setFileContents(file, "not valid"); FileDataSourceBuilder factory1 = makeFactoryWithFile(file.toPath()).autoUpdate(true); - long maxMsToWait = 10000; try { try (DataSource fp = makeDataSource(factory1)) { 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) { + repeatWithTimeout(Duration.ofSeconds(10), Duration.ofMillis(500), () -> { if (toItemsMap(store.getAll(FEATURES)).size() > 0) { - // success - return; + // success - status is now VALID, after having first been STARTING - can still see that an error occurred + DataSourceStatusProvider.Status status = requireDataSourceStatusEventually(statuses, + DataSourceStatusProvider.State.VALID, DataSourceStatusProvider.State.STARTING); + assertNotNull(status.getLastError()); + assertEquals(DataSourceStatusProvider.ErrorKind.INVALID_DATA, status.getLastError().getKind()); + + return status; } - Thread.sleep(500); - } - fail("Waited " + maxMsToWait + "ms after modifying file and it did not reload"); + return null; + }); } } finally { file.delete(); From d5a3284bc93a899dc34e3d98b1826c8319f68e54 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 6 May 2020 11:06:01 -0700 Subject: [PATCH 413/673] typo --- src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java b/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java index 97fb84463..4f45d2da0 100644 --- a/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java @@ -17,7 +17,6 @@ * implementation of {@link ClientContext}, which might have been created for instance in application * test code). */ - final class ClientContextImpl implements ClientContext { private static volatile ScheduledExecutorService fallbackSharedExecutor = null; From 85db8f0f34cba6b1ebe7f005da72086967fcaf03 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 6 May 2020 11:06:40 -0700 Subject: [PATCH 414/673] rm duplicate type --- .../sdk/server/interfaces/DataStoreTypes.java | 111 ------------------ 1 file changed, 111 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypes.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypes.java index 47f746604..c92a3048c 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypes.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypes.java @@ -1,7 +1,6 @@ package com.launchdarkly.sdk.server.interfaces; import com.google.common.collect.ImmutableList; -import com.launchdarkly.sdk.server.integrations.PersistentDataStoreBuilder; import java.util.Map; import java.util.Objects; @@ -297,114 +296,4 @@ public KeyedItems(Iterable> items) { this.items = items == null ? ImmutableList.of() : items; } } - - /** - * 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. - * - * @see DataStoreStatusProvider#getCacheStats() - * @see PersistentDataStoreBuilder#recordCacheStats(boolean) - * @since 4.12.0 - */ - 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 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) { - 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 + "}"; - } - } } From 02cc39bdfc6dd4b3e84c9962f75a3ecd4c17198a Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 6 May 2020 11:29:05 -0700 Subject: [PATCH 415/673] javadoc fixes --- .../sdk/server/interfaces/DataSourceStatusProvider.java | 1 + .../launchdarkly/sdk/server/interfaces/DataSourceUpdates.java | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProvider.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProvider.java index b10ed8c2a..ca1fb6735 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProvider.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProvider.java @@ -242,6 +242,7 @@ public State getState() { * error state, after previously having been {@link State#VALID}. *

    • For {@link State#OFF}, it is the time that the data source encountered an unrecoverable error * or that the SDK was explicitly shut down. + *
    * * @return the timestamp of the last state change */ diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceUpdates.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceUpdates.java index 34e3228ed..69eaac82c 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceUpdates.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceUpdates.java @@ -17,7 +17,6 @@ public interface DataSourceUpdates { * Completely overwrites the current contents of the data store with a set of items for each collection. * * @param allData a list of {@link DataStoreTypes.DataKind} instances and their corresponding data sets - * @see DataStore#init(FullDataSet) */ void init(FullDataSet allData); @@ -32,7 +31,6 @@ public interface DataSourceUpdates { * @param kind specifies which collection to use * @param key the unique key for the item within that collection * @param item the item to insert or update - * @see DataStore#upsert(DataKind, String, ItemDescriptor) */ void upsert(DataKind kind, String key, ItemDescriptor item); From f8d5b204cba819238fc5785c43c49c385fb4eb11 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 6 May 2020 17:05:21 -0700 Subject: [PATCH 416/673] better cleanup of scheduled tasks from shared executor --- .../sdk/server/DefaultEventProcessor.java | 15 ++-- .../sdk/server/PollingProcessor.java | 68 ++++++++++++------- 2 files changed, 53 insertions(+), 30 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java index 7ab2e9ae4..984fca0eb 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java @@ -23,6 +23,7 @@ import java.util.concurrent.BlockingQueue; import java.util.concurrent.ExecutorService; import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; import java.util.concurrent.Semaphore; import java.util.concurrent.ThreadFactory; import java.util.concurrent.TimeUnit; @@ -54,6 +55,7 @@ final class DefaultEventProcessor implements EventProcessor { private final BlockingQueue inbox; private final ScheduledExecutorService scheduler; private final AtomicBoolean closed = new AtomicBoolean(false); + private final List> scheduledTasks = new ArrayList<>(); private volatile boolean inputCapacityExceeded = false; DefaultEventProcessor( @@ -82,19 +84,19 @@ final class DefaultEventProcessor implements EventProcessor { Runnable flusher = () -> { postMessageAsync(MessageType.FLUSH, null); }; - this.scheduler.scheduleAtFixedRate(flusher, eventsConfig.flushInterval.toMillis(), - eventsConfig.flushInterval.toMillis(), TimeUnit.MILLISECONDS); + scheduledTasks.add(this.scheduler.scheduleAtFixedRate(flusher, eventsConfig.flushInterval.toMillis(), + eventsConfig.flushInterval.toMillis(), TimeUnit.MILLISECONDS)); Runnable userKeysFlusher = () -> { postMessageAsync(MessageType.FLUSH_USERS, null); }; - this.scheduler.scheduleAtFixedRate(userKeysFlusher, eventsConfig.userKeysFlushInterval.toMillis(), - eventsConfig.userKeysFlushInterval.toMillis(), TimeUnit.MILLISECONDS); + scheduledTasks.add(this.scheduler.scheduleAtFixedRate(userKeysFlusher, eventsConfig.userKeysFlushInterval.toMillis(), + eventsConfig.userKeysFlushInterval.toMillis(), TimeUnit.MILLISECONDS)); if (diagnosticAccumulator != null) { Runnable diagnosticsTrigger = () -> { postMessageAsync(MessageType.DIAGNOSTIC, null); }; - this.scheduler.scheduleAtFixedRate(diagnosticsTrigger, eventsConfig.diagnosticRecordingInterval.toMillis(), - eventsConfig.diagnosticRecordingInterval.toMillis(), TimeUnit.MILLISECONDS); + scheduledTasks.add(this.scheduler.scheduleAtFixedRate(diagnosticsTrigger, eventsConfig.diagnosticRecordingInterval.toMillis(), + eventsConfig.diagnosticRecordingInterval.toMillis(), TimeUnit.MILLISECONDS)); } } @@ -115,6 +117,7 @@ public void flush() { @Override public void close() throws IOException { if (closed.compareAndSet(false, true)) { + scheduledTasks.forEach(task -> task.cancel(false)); postMessageAsync(MessageType.FLUSH, null); postMessageAndWait(MessageType.SHUTDOWN, null); } diff --git a/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java b/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java index 3ebb084b7..3b8200b43 100644 --- a/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java @@ -13,6 +13,7 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.Future; import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -26,7 +27,9 @@ final class PollingProcessor implements DataSource { private final DataStoreUpdates dataStoreUpdates; private final ScheduledExecutorService scheduler; @VisibleForTesting final Duration pollInterval; - private AtomicBoolean initialized = new AtomicBoolean(false); + private final AtomicBoolean initialized = new AtomicBoolean(false); + private volatile ScheduledFuture task; + private volatile CompletableFuture initFuture; PollingProcessor( FeatureRequestor requestor, @@ -49,35 +52,52 @@ public boolean isInitialized() { public void close() throws IOException { logger.info("Closing LaunchDarkly PollingProcessor"); requestor.close(); + + // Even though the shared executor will be shut down when the LDClient is closed, it's still good + // behavior to remove our polling task now - especially because we might be running in a test + // environment where there isn't actually an LDClient. + synchronized (this) { + if (task != null) { + task.cancel(false); + task = null; + } + } } @Override public Future start() { logger.info("Starting LaunchDarkly polling client with interval: " + pollInterval.toMillis() + " milliseconds"); - final CompletableFuture initFuture = new CompletableFuture<>(); - - scheduler.scheduleAtFixedRate(() -> { - try { - FeatureRequestor.AllData allData = requestor.getAllData(); - dataStoreUpdates.init(allData.toFullDataSet()); - if (!initialized.getAndSet(true)) { - logger.info("Initialized LaunchDarkly client."); - initFuture.complete(null); - } - } catch (HttpErrorException e) { - logger.error(httpErrorMessage(e.getStatus(), "polling request", "will retry")); - if (!isHttpErrorRecoverable(e.getStatus())) { - initFuture.complete(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.toString()); - logger.debug(e.toString(), e); - } catch (SerializationException e) { - logger.error("Polling request received malformed data: {}", e.toString()); + + synchronized (this) { + if (initFuture != null) { + return initFuture; } - }, 0L, pollInterval.toMillis(), TimeUnit.MILLISECONDS); - + initFuture = new CompletableFuture<>(); + task = scheduler.scheduleAtFixedRate(this::poll, 0L, pollInterval.toMillis(), TimeUnit.MILLISECONDS); + } + return initFuture; } -} \ No newline at end of file + + private void poll() { + try { + FeatureRequestor.AllData allData = requestor.getAllData(); + dataStoreUpdates.init(allData.toFullDataSet()); + if (!initialized.getAndSet(true)) { + logger.info("Initialized LaunchDarkly client."); + initFuture.complete(null); + } + } catch (HttpErrorException e) { + logger.error(httpErrorMessage(e.getStatus(), "polling request", "will retry")); + if (!isHttpErrorRecoverable(e.getStatus())) { + initFuture.complete(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.toString()); + logger.debug(e.toString(), e); + } catch (SerializationException e) { + logger.error("Polling request received malformed data: {}", e.toString()); + } + } +} From 27b50b54f9d0a60e108ce12362bc032f35bc05e3 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 7 May 2020 19:45:34 -0700 Subject: [PATCH 417/673] typos --- .../sdk/server/interfaces/DataSourceStatusProvider.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProvider.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProvider.java index 3d17c58f7..f539f532b 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProvider.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProvider.java @@ -82,8 +82,8 @@ public enum State { * Indicates that the data source has been permanently shut down. *

    * This could be because it encountered an unrecoverable error (for instance, the LaunchDarkly service - * rejected the SDK key; an invald SDK key will never become vavlid), or because the SDK client was - * explicitly shut own. + * rejected the SDK key; an invald SDK key will never become valid), or because the SDK client was + * explicitly shut down. */ OFF; } From adf8cce631066e2475bc6e8abffb50e8922fa3a3 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 7 May 2020 19:56:45 -0700 Subject: [PATCH 418/673] rename STARTING to INITIALIZING --- .../sdk/server/DataSourceUpdatesImpl.java | 6 ++-- .../interfaces/DataSourceStatusProvider.java | 12 ++++---- .../server/interfaces/DataSourceUpdates.java | 4 +-- .../sdk/server/LDClientListenersTest.java | 2 +- .../sdk/server/PollingProcessorTest.java | 6 ++-- .../sdk/server/StreamProcessorTest.java | 30 ++++++++++--------- .../integrations/FileDataSourceTest.java | 6 ++-- 7 files changed, 34 insertions(+), 32 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java b/src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java index 639880d25..be418c106 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java @@ -54,7 +54,7 @@ final class DataSourceUpdatesImpl implements DataSourceUpdates { this.dataStoreStatusProvider = dataStoreStatusProvider; currentStatus = new DataSourceStatusProvider.Status( - DataSourceStatusProvider.State.STARTING, + DataSourceStatusProvider.State.INITIALIZING, Instant.now(), null ); @@ -112,8 +112,8 @@ public void updateStatus(DataSourceStatusProvider.State newState, DataSourceStat } DataSourceStatusProvider.Status newStatus; synchronized (this) { - if (newState == DataSourceStatusProvider.State.INTERRUPTED && currentStatus.getState() == DataSourceStatusProvider.State.STARTING) { - newState = DataSourceStatusProvider.State.STARTING; // see comment on updateStatus in the DataSourceUpdates interface + if (newState == DataSourceStatusProvider.State.INTERRUPTED && currentStatus.getState() == DataSourceStatusProvider.State.INITIALIZING) { + newState = DataSourceStatusProvider.State.INITIALIZING; // see comment on updateStatus in the DataSourceUpdates interface } if (newState == currentStatus.getState() && newError == null) { return; diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProvider.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProvider.java index f539f532b..47f9f29be 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProvider.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProvider.java @@ -20,7 +20,7 @@ public interface DataSourceStatusProvider { *

    * For a custom data source implementation, it is the responsibility of the data source to report its * status via {@link DataSourceUpdates}; if it does not do so, the status will always be reported as - * {@link State#STARTING}. + * {@link State#INITIALIZING}. * * @return the latest status; will never be null */ @@ -51,13 +51,13 @@ public interface DataSourceStatusProvider { */ public enum State { /** - * The initial state of the data source when the SDK is initialized. + * The initial state of the data source when the SDK is being initialized. *

    * If it encounters an error that requires it to retry initialization, the state will remain at - * {@link #STARTING} until it either succeeds and becomes {@link #VALID}, or permanently fails and + * {@link #INITIALIZING} until it either succeeds and becomes {@link #VALID}, or permanently fails and * becomes {@link #OFF}. */ - STARTING, + INITIALIZING, /** * Indicates that the data source is currently operational and has not had any problems since the @@ -256,9 +256,9 @@ public State getState() { *

    * The meaning of this depends on the current state: *

      - *
    • For {@link State#STARTING}, it is the time that the SDK started initializing. + *
    • For {@link State#INITIALIZING}, it is the time that the SDK started initializing. *
    • For {@link State#VALID}, it is the time that the data source most recently entered a valid - * state, after previously having been either {@link State#STARTING} or {@link State#INTERRUPTED}. + * state, after previously having been either {@link State#INITIALIZING} or {@link State#INTERRUPTED}. *
    • For {@link State#INTERRUPTED}, it is the time that the data source most recently entered an * error state, after previously having been {@link State#VALID}. *
    • For {@link State#OFF}, it is the time that the data source encountered an unrecoverable error diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceUpdates.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceUpdates.java index 69eaac82c..20e192b1d 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceUpdates.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceUpdates.java @@ -57,8 +57,8 @@ public interface DataSourceUpdates { * registered listeners. *

      * A special case is that if {@code newState} is {@link DataSourceStatusProvider.State#INTERRUPTED}, - * but the previous state was {@link DataSourceStatusProvider.State#STARTING}, the state will remain - * at {@link DataSourceStatusProvider.State#STARTING} because {@link DataSourceStatusProvider.State#INTERRUPTED} + * but the previous state was {@link DataSourceStatusProvider.State#INITIALIZING}, the state will remain + * at {@link DataSourceStatusProvider.State#INITIALIZING} because {@link DataSourceStatusProvider.State#INTERRUPTED} * is only meaningful after a successful startup. * * @param newState the data source state diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java index 81e9ba234..a3142ab5d 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java @@ -140,7 +140,7 @@ public void dataSourceStatusProviderReturnsLatestStatus() throws Exception { Instant timeBeforeStarting = Instant.now(); try (LDClient client = new LDClient(SDK_KEY, config)) { DataSourceStatusProvider.Status initialStatus = client.getDataSourceStatusProvider().getStatus(); - assertThat(initialStatus.getState(), equalTo(DataSourceStatusProvider.State.STARTING)); + assertThat(initialStatus.getState(), equalTo(DataSourceStatusProvider.State.INITIALIZING)); assertThat(initialStatus.getStateSince(), greaterThanOrEqualTo(timeBeforeStarting)); assertThat(initialStatus.getLastError(), nullValue()); diff --git a/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java index eeee7131f..459c75273 100644 --- a/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java @@ -114,7 +114,7 @@ public void testConnectionProblem() throws Exception { assertFalse(pollingProcessor.isInitialized()); assertFalse(store.isInitialized()); - DataSourceStatusProvider.Status status = requireDataSourceStatus(statuses, DataSourceStatusProvider.State.STARTING); + DataSourceStatusProvider.Status status = requireDataSourceStatus(statuses, DataSourceStatusProvider.State.INITIALIZING); assertNotNull(status.getLastError()); assertEquals(DataSourceStatusProvider.ErrorKind.NETWORK_ERROR, status.getLastError().getKind()); } @@ -188,7 +188,7 @@ private void testRecoverableHttpError(int statusCode) throws Exception { assertFalse(initFuture.isDone()); assertFalse(pollingProcessor.isInitialized()); - DataSourceStatusProvider.Status status1 = requireDataSourceStatus(statuses, DataSourceStatusProvider.State.STARTING); + DataSourceStatusProvider.Status status1 = requireDataSourceStatus(statuses, DataSourceStatusProvider.State.INITIALIZING); assertNotNull(status1.getLastError()); assertEquals(DataSourceStatusProvider.ErrorKind.ERROR_RESPONSE, status1.getLastError().getKind()); assertEquals(statusCode, status1.getLastError().getStatusCode()); @@ -203,7 +203,7 @@ private void testRecoverableHttpError(int statusCode) throws Exception { // status should now be VALID (although there might have been more failed polls before that) DataSourceStatusProvider.Status status2 = requireDataSourceStatusEventually(statuses, - DataSourceStatusProvider.State.VALID, DataSourceStatusProvider.State.STARTING); + DataSourceStatusProvider.State.VALID, DataSourceStatusProvider.State.INITIALIZING); assertNotNull(status2.getLastError()); assertEquals(DataSourceStatusProvider.ErrorKind.ERROR_RESPONSE, status2.getLastError().getKind()); assertEquals(statusCode, status2.getLastError().getStatusCode()); diff --git a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java index b067024d6..996587473 100644 --- a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java @@ -11,7 +11,9 @@ import com.launchdarkly.sdk.server.TestComponents.MockEventSourceCreator; import com.launchdarkly.sdk.server.integrations.StreamingDataSourceBuilder; import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; -import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorKind; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.State; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.Status; import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; @@ -685,16 +687,16 @@ private void verifyEventBehavior(String eventName, String eventData) throws Exce } private void verifyInvalidDataEvent(String eventName, String eventData) throws Exception { - BlockingQueue statuses = new LinkedBlockingQueue<>(); + BlockingQueue statuses = new LinkedBlockingQueue<>(); dataSourceUpdates.statusBroadcaster.register(statuses::add); verifyEventCausesStreamRestartWithInMemoryStore(eventName, eventData); // We did not allow the stream to successfully process an event before causing the error, so the - // state will still be STARTING, but we should be able to see that an error happened. - DataSourceStatusProvider.Status status = requireDataSourceStatus(statuses, DataSourceStatusProvider.State.STARTING); + // state will still be INITIALIZING, but we should be able to see that an error happened. + Status status = requireDataSourceStatus(statuses, State.INITIALIZING); assertNotNull(status.getLastError()); - assertEquals(DataSourceStatusProvider.ErrorKind.INVALID_DATA, status.getLastError().getKind()); + assertEquals(ErrorKind.INVALID_DATA, status.getLastError().getKind()); } private void expectNoStreamRestart() throws Exception { @@ -796,7 +798,7 @@ private void testUnrecoverableHttpError(int statusCode) throws Exception { StreamProcessor sp = createStreamProcessor(STREAM_URI); Future initFuture = sp.start(); - BlockingQueue statuses = new LinkedBlockingQueue<>(); + BlockingQueue statuses = new LinkedBlockingQueue<>(); dataSourceUpdates.statusBroadcaster.register(statuses::add); ConnectionErrorHandler errorHandler = mockEventSourceCreator.getNextReceivedParams().errorHandler; @@ -808,8 +810,8 @@ private void testUnrecoverableHttpError(int statusCode) throws Exception { assertTrue(initFuture.isDone()); assertFalse(sp.isInitialized()); - DataSourceStatusProvider.Status newStatus = requireDataSourceStatus(statuses, DataSourceStatusProvider.State.OFF); - assertEquals(DataSourceStatusProvider.ErrorKind.ERROR_RESPONSE, newStatus.getLastError().getKind()); + Status newStatus = requireDataSourceStatus(statuses, State.OFF); + assertEquals(ErrorKind.ERROR_RESPONSE, newStatus.getLastError().getKind()); assertEquals(statusCode, newStatus.getLastError().getStatusCode()); } @@ -819,7 +821,7 @@ private void testRecoverableHttpError(int statusCode) throws Exception { StreamProcessor sp = createStreamProcessor(STREAM_URI); Future initFuture = sp.start(); - BlockingQueue statuses = new LinkedBlockingQueue<>(); + BlockingQueue statuses = new LinkedBlockingQueue<>(); dataSourceUpdates.statusBroadcaster.register(statuses::add); // simulate error @@ -833,8 +835,8 @@ private void testRecoverableHttpError(int statusCode) throws Exception { assertFalse(initFuture.isDone()); assertFalse(sp.isInitialized()); - DataSourceStatusProvider.Status failureStatus1 = requireDataSourceStatus(statuses, DataSourceStatusProvider.State.STARTING); - assertEquals(DataSourceStatusProvider.ErrorKind.ERROR_RESPONSE, failureStatus1.getLastError().getKind()); + Status failureStatus1 = requireDataSourceStatus(statuses, State.INITIALIZING); + assertEquals(ErrorKind.ERROR_RESPONSE, failureStatus1.getLastError().getKind()); assertEquals(statusCode, failureStatus1.getLastError().getStatusCode()); // simulate successful retry @@ -844,15 +846,15 @@ private void testRecoverableHttpError(int statusCode) throws Exception { assertTrue(initFuture.isDone()); assertTrue(sp.isInitialized()); - DataSourceStatusProvider.Status successStatus = requireDataSourceStatus(statuses, DataSourceStatusProvider.State.VALID); + Status successStatus = requireDataSourceStatus(statuses, State.VALID); assertSame(failureStatus1.getLastError(), successStatus.getLastError()); // simulate another error of the same kind - the difference is now the state will be INTERRUPTED action = errorHandler.onConnectionError(e); assertEquals(ConnectionErrorHandler.Action.PROCEED, action); - DataSourceStatusProvider.Status failureStatus2 = requireDataSourceStatus(statuses, DataSourceStatusProvider.State.INTERRUPTED); - assertEquals(DataSourceStatusProvider.ErrorKind.ERROR_RESPONSE, failureStatus2.getLastError().getKind()); + Status failureStatus2 = requireDataSourceStatus(statuses, State.INTERRUPTED); + assertEquals(ErrorKind.ERROR_RESPONSE, failureStatus2.getLastError().getKind()); assertEquals(statusCode, failureStatus2.getLastError().getStatusCode()); assertNotSame(failureStatus2.getLastError(), failureStatus1.getLastError()); // a new instance of the same kind of error } diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java index 58efd3a56..510ba3d69 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java @@ -135,7 +135,7 @@ public void statusIsStartingAfterUnsuccessfulLoad() throws Exception { fp.start(); assertThat(fp.isInitialized(), equalTo(false)); - DataSourceStatusProvider.Status status = requireDataSourceStatus(statuses, DataSourceStatusProvider.State.STARTING); + DataSourceStatusProvider.Status status = requireDataSourceStatus(statuses, DataSourceStatusProvider.State.INITIALIZING); assertNotNull(status.getLastError()); assertEquals(DataSourceStatusProvider.ErrorKind.INVALID_DATA, status.getLastError().getKind()); } @@ -200,9 +200,9 @@ public void ifFilesAreBadAtStartTimeAutoUpdateCanStillLoadGoodDataLater() throws setFileContents(file, getResourceContents("flag-only.json")); // this file has 1 flag repeatWithTimeout(Duration.ofSeconds(10), Duration.ofMillis(500), () -> { if (toItemsMap(store.getAll(FEATURES)).size() > 0) { - // success - status is now VALID, after having first been STARTING - can still see that an error occurred + // success - status is now VALID, after having first been INITIALIZING - can still see that an error occurred DataSourceStatusProvider.Status status = requireDataSourceStatusEventually(statuses, - DataSourceStatusProvider.State.VALID, DataSourceStatusProvider.State.STARTING); + DataSourceStatusProvider.State.VALID, DataSourceStatusProvider.State.INITIALIZING); assertNotNull(status.getLastError()); assertEquals(DataSourceStatusProvider.ErrorKind.INVALID_DATA, status.getLastError().getKind()); From ae5da18eec52e11308bba47190646116f3e009c3 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 8 May 2020 14:55:22 -0700 Subject: [PATCH 419/673] (5.0, but not dependent on other PRs) allow configuration of worker thread priority (#229) --- build.gradle | 2 +- .../sdk/server/ClientContextImpl.java | 19 ++++++++- .../launchdarkly/sdk/server/Components.java | 2 + .../sdk/server/DefaultEventProcessor.java | 5 ++- .../com/launchdarkly/sdk/server/LDClient.java | 6 +-- .../com/launchdarkly/sdk/server/LDConfig.java | 23 +++++++++++ .../sdk/server/StreamProcessor.java | 8 +++- .../sdk/server/interfaces/ClientContext.java | 27 ++++++++++--- .../sdk/server/LDClientListenersTest.java | 32 +++++++++++++++ .../sdk/server/StreamProcessorTest.java | 40 +++++++++++++++---- 10 files changed, 142 insertions(+), 22 deletions(-) diff --git a/build.gradle b/build.gradle index 8be73c5d1..ca071191e 100644 --- a/build.gradle +++ b/build.gradle @@ -72,7 +72,7 @@ ext.versions = [ "guava": "28.2-jre", "jackson": "2.10.0", "launchdarklyJavaSdkCommon": "1.0.0-rc1", - "okhttpEventsource": "2.1.0", + "okhttpEventsource": "2.2.0", "slf4j": "1.7.21", "snakeyaml": "1.19", "jedis": "2.9.0" diff --git a/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java b/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java index 4f45d2da0..9a82dde21 100644 --- a/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java @@ -23,6 +23,7 @@ final class ClientContextImpl implements ClientContext { private final String sdkKey; private final HttpConfiguration httpConfiguration; private final boolean offline; + private final int threadPriority; final ScheduledExecutorService sharedExecutor; final DiagnosticAccumulator diagnosticAccumulator; final DiagnosticEvent.Init diagnosticInitEvent; @@ -31,6 +32,7 @@ private ClientContextImpl( String sdkKey, HttpConfiguration httpConfiguration, boolean offline, + int threadPriority, ScheduledExecutorService sharedExecutor, DiagnosticAccumulator diagnosticAccumulator, DiagnosticEvent.Init diagnosticInitEvent @@ -38,6 +40,7 @@ private ClientContextImpl( this.sdkKey = sdkKey; this.httpConfiguration = httpConfiguration; this.offline = offline; + this.threadPriority = threadPriority; this.sharedExecutor = sharedExecutor; this.diagnosticAccumulator = diagnosticAccumulator; this.diagnosticInitEvent = diagnosticInitEvent; @@ -52,6 +55,7 @@ private ClientContextImpl( this.sdkKey = sdkKey; this.httpConfiguration = configuration.httpConfig; this.offline = configuration.offline; + this.threadPriority = configuration.threadPriority; this.sharedExecutor = sharedExecutor; if (!configuration.diagnosticOptOut && diagnosticAccumulator != null) { this.diagnosticAccumulator = diagnosticAccumulator; @@ -77,6 +81,11 @@ public HttpConfiguration getHttpConfiguration() { return httpConfiguration; } + @Override + public int getThreadPriority() { + return threadPriority; + } + /** * This mechanism is a convenience for internal components to access the package-private fields of the * context if it is a ClientContextImpl, and to receive null values for those fields if it is not. @@ -93,7 +102,13 @@ static ClientContextImpl get(ClientContext context) { fallbackSharedExecutor = Executors.newSingleThreadScheduledExecutor(); } } - return new ClientContextImpl(context.getSdkKey(), context.getHttpConfiguration(), context.isOffline(), - fallbackSharedExecutor, null, null); + return new ClientContextImpl( + context.getSdkKey(), + context.getHttpConfiguration(), + context.isOffline(), + context.getThreadPriority(), + fallbackSharedExecutor, + null, + null); } } diff --git a/src/main/java/com/launchdarkly/sdk/server/Components.java b/src/main/java/com/launchdarkly/sdk/server/Components.java index 460f54bec..89f90fe28 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Components.java +++ b/src/main/java/com/launchdarkly/sdk/server/Components.java @@ -407,6 +407,7 @@ public DataSource createDataSource(ClientContext context, DataSourceUpdates data requestor, dataSourceUpdates, null, + context.getThreadPriority(), ClientContextImpl.get(context).diagnosticAccumulator, streamUri, initialReconnectDelay @@ -496,6 +497,7 @@ public EventProcessor createEventProcessor(ClientContext context) { ), context.getHttpConfiguration(), ClientContextImpl.get(context).sharedExecutor, + context.getThreadPriority(), ClientContextImpl.get(context).diagnosticAccumulator, ClientContextImpl.get(context).diagnosticInitEvent ); diff --git a/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java index 984fca0eb..9cacbc546 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java @@ -63,6 +63,7 @@ final class DefaultEventProcessor implements EventProcessor { EventsConfiguration eventsConfig, HttpConfiguration httpConfig, ScheduledExecutorService sharedExecutor, + int threadPriority, DiagnosticAccumulator diagnosticAccumulator, DiagnosticEvent.Init diagnosticInitEvent ) { @@ -75,6 +76,7 @@ final class DefaultEventProcessor implements EventProcessor { eventsConfig, httpConfig, sharedExecutor, + threadPriority, inbox, closed, diagnosticAccumulator, @@ -232,6 +234,7 @@ private EventDispatcher( EventsConfiguration eventsConfig, HttpConfiguration httpConfig, ExecutorService sharedExecutor, + int threadPriority, final BlockingQueue inbox, final AtomicBoolean closed, DiagnosticAccumulator diagnosticAccumulator, @@ -245,7 +248,7 @@ private EventDispatcher( ThreadFactory threadFactory = new ThreadFactoryBuilder() .setDaemon(true) .setNameFormat("LaunchDarkly-event-delivery-%d") - .setPriority(Thread.MIN_PRIORITY) + .setPriority(threadPriority) .build(); OkHttpClient.Builder httpBuilder = new OkHttpClient.Builder(); diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClient.java b/src/main/java/com/launchdarkly/sdk/server/LDClient.java index 6ed3e4701..63457e1cf 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClient.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClient.java @@ -131,7 +131,7 @@ public LDClient(String sdkKey, LDConfig config) { this.sdkKey = checkNotNull(sdkKey, "sdkKey must not be null"); this.offline = config.offline; - this.sharedExecutor = createSharedExecutor(); + this.sharedExecutor = createSharedExecutor(config); final EventProcessorFactory epFactory = config.eventProcessorFactory == null ? Components.sendEvents() : config.eventProcessorFactory; @@ -510,11 +510,11 @@ public String version() { // to be executing frequently so that it is acceptable to use a single thread to execute them one at a // time rather than a thread pool, thus reducing the number of threads spawned by the SDK. This also // has the benefit of producing predictable delivery order for event listener notifications. - private ScheduledExecutorService createSharedExecutor() { + private ScheduledExecutorService createSharedExecutor(LDConfig config) { ThreadFactory threadFactory = new ThreadFactoryBuilder() .setDaemon(true) .setNameFormat("LaunchDarkly-tasks-%d") - .setPriority(Thread.MIN_PRIORITY) + .setPriority(config.threadPriority) .build(); return Executors.newSingleThreadScheduledExecutor(threadFactory); } diff --git a/src/main/java/com/launchdarkly/sdk/server/LDConfig.java b/src/main/java/com/launchdarkly/sdk/server/LDConfig.java index 235ce9196..774358dd9 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDConfig.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDConfig.java @@ -29,6 +29,7 @@ public final class LDConfig { final HttpConfiguration httpConfig; final boolean offline; final Duration startWait; + final int threadPriority; protected LDConfig(Builder builder) { this.dataStoreFactory = builder.dataStoreFactory; @@ -40,6 +41,7 @@ protected LDConfig(Builder builder) { builder.httpConfigFactory.createHttpConfiguration(); this.offline = builder.offline; this.startWait = builder.startWait; + this.threadPriority = builder.threadPriority; } LDConfig(LDConfig config) { @@ -50,6 +52,7 @@ protected LDConfig(Builder builder) { this.httpConfig = config.httpConfig; this.offline = config.offline; this.startWait = config.startWait; + this.threadPriority = config.threadPriority; } /** @@ -71,6 +74,7 @@ public static class Builder { private HttpConfigurationFactory httpConfigFactory = null; private boolean offline = false; private Duration startWait = DEFAULT_START_WAIT; + private int threadPriority = Thread.MIN_PRIORITY; /** * Creates a builder with all configuration parameters set to the default @@ -194,6 +198,25 @@ public Builder startWait(Duration startWait) { return this; } + /** + * Set the priority to use for all threads created by the SDK. + *

      + * By default, the SDK's worker threads use {@code Thread.MIN_PRIORITY} so that they will yield to + * application threads if the JVM is busy. You may increase this if you want the SDK to be prioritized + * over some other low-priority tasks. + *

      + * Values outside the range of [{@code Thread.MIN_PRIORITY}, {@code Thread.MAX_PRIORITY}] will be set + * to the minimum or maximum. + * + * @param threadPriority the priority for SDK threads + * @return the builder + * @since 5.0.0 + */ + public Builder threadPriority(int threadPriority) { + this.threadPriority = Math.max(Thread.MIN_PRIORITY, Math.min(Thread.MAX_PRIORITY, threadPriority)); + return this; + } + /** * Builds the configured {@link com.launchdarkly.sdk.server.LDConfig} object. * diff --git a/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java index 82642cbf1..0362d7711 100644 --- a/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java @@ -79,6 +79,7 @@ final class StreamProcessor implements DataSource { @VisibleForTesting final FeatureRequestor requestor; private final DiagnosticAccumulator diagnosticAccumulator; private final EventSourceCreator eventSourceCreator; + private final int threadPriority; private final DataStoreStatusProvider.StatusListener statusListener; private volatile EventSource es; private final AtomicBoolean initialized = new AtomicBoolean(false); @@ -117,6 +118,7 @@ static interface EventSourceCreator { FeatureRequestor requestor, DataSourceUpdates dataSourceUpdates, EventSourceCreator eventSourceCreator, + int threadPriority, DiagnosticAccumulator diagnosticAccumulator, URI streamUri, Duration initialReconnectDelay @@ -125,7 +127,8 @@ static interface EventSourceCreator { this.httpConfig = httpConfig; this.requestor = requestor; this.diagnosticAccumulator = diagnosticAccumulator; - this.eventSourceCreator = eventSourceCreator != null ? eventSourceCreator : StreamProcessor::defaultEventSourceCreator; + this.eventSourceCreator = eventSourceCreator != null ? eventSourceCreator : this::defaultEventSourceCreator; + this.threadPriority = threadPriority; this.streamUri = streamUri; this.initialReconnectDelay = initialReconnectDelay; @@ -388,8 +391,9 @@ public void onError(Throwable throwable) { } } - private static EventSource defaultEventSourceCreator(EventSourceParams params) { + private EventSource defaultEventSourceCreator(EventSourceParams params) { EventSource.Builder builder = new EventSource.Builder(params.handler, params.streamUri) + .threadPriority(threadPriority) .clientBuilderActions(new EventSource.Builder.ClientConfigurer() { public void configure(OkHttpClient.Builder builder) { configureHttpClientBuilder(params.httpConfig, builder); diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/ClientContext.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/ClientContext.java index 9d22b2dc0..93e2f828a 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/ClientContext.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/ClientContext.java @@ -3,30 +3,45 @@ /** * Context information provided by the {@link com.launchdarkly.sdk.server.LDClient} when creating components. *

      - * This is passed as a parameter to component factory methods such as - * {@link DataStoreFactory#createDataStore(ClientContext, DataStoreUpdates)}. The actual implementation - * class may contain other properties that are only relevant to the built-in SDK components and are - * therefore not part of the public interface; this allows the SDK to add its own context information as - * needed without disturbing the public API. + * This is passed as a parameter to {@link DataStoreFactory#createDataStore(ClientContext, DataStoreUpdates)}, + * etc. Component factories do not receive the entire {@link com.launchdarkly.sdk.server.LDConfig} because + * it could contain factory objects that have mutable state, and because components should not be able + * to access the configurations of unrelated components. + *

      + * The actual implementation class may contain other properties that are only relevant to the built-in + * SDK components and are therefore not part of the public interface; this allows the SDK to add its own + * context information as needed without disturbing the public API. * * @since 5.0.0 */ public interface ClientContext { /** * The current {@link com.launchdarkly.sdk.server.LDClient} instance's SDK key. + * * @return the SDK key */ public String getSdkKey(); /** - * True if {@link com.launchdarkly.sdk.server.LDConfig.Builder#offline(boolean)} was set to true. + * True if the SDK was configured to be completely offline. + * * @return the offline status + * @see com.launchdarkly.sdk.server.LDConfig.Builder#offline(boolean) */ public boolean isOffline(); /** * The configured networking properties that apply to all components. + * * @return the HTTP configuration */ public HttpConfiguration getHttpConfiguration(); + + /** + * The thread priority that should be used for any worker threads created by SDK components. + * + * @return the thread priority + * @see com.launchdarkly.sdk.server.LDConfig.Builder#threadPriority(int) + */ + public int getThreadPriority(); } diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java index 961a7baa6..e16de6f31 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java @@ -26,7 +26,9 @@ import static com.launchdarkly.sdk.server.TestComponents.specificDataStore; import static com.launchdarkly.sdk.server.TestComponents.specificPersistentDataStore; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; +import static org.junit.Assert.assertEquals; /** * This file contains tests for all of the event broadcaster/listener functionality in the client, plus @@ -187,4 +189,34 @@ public void dataStoreStatusProviderSendsStatusUpdates() throws Exception { assertThat(statuses.take(), equalTo(newStatus)); } } + + @Test + public void eventsAreDispatchedOnTaskThread() throws Exception { + int desiredPriority = Thread.MAX_PRIORITY - 1; + BlockingQueue capturedThreads = new LinkedBlockingQueue<>(); + + DataStore testDataStore = initedDataStore(); + DataBuilder initialData = new DataBuilder().addAny(DataModel.FEATURES, + flagBuilder("flagkey").version(1).build()); + DataSourceFactoryThatExposesUpdater updatableSource = new DataSourceFactoryThatExposesUpdater(initialData.build()); + LDConfig config = new LDConfig.Builder() + .dataStore(specificDataStore(testDataStore)) + .dataSource(updatableSource) + .events(Components.noEvents()) + .threadPriority(desiredPriority) + .build(); + + try (LDClient client = new LDClient(SDK_KEY, config)) { + client.registerFlagChangeListener(params -> { + capturedThreads.add(Thread.currentThread()); + }); + + updatableSource.updateFlag(flagBuilder("flagkey").version(2).build()); + + Thread handlerThread = capturedThreads.take(); + + assertEquals(desiredPriority, handlerThread.getPriority()); + assertThat(handlerThread.getName(), containsString("LaunchDarkly-tasks")); + } + } } diff --git a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java index 945b3cdf5..2ff5f9e9c 100644 --- a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java @@ -824,14 +824,31 @@ private StreamProcessor createStreamProcessor(LDConfig config, URI streamUri) { } private StreamProcessor createStreamProcessor(LDConfig config, URI streamUri, DiagnosticAccumulator diagnosticAccumulator) { - return new StreamProcessor(SDK_KEY, config.httpConfig, mockRequestor, dataSourceUpdates(dataStore), - mockEventSourceCreator, diagnosticAccumulator, - streamUri, DEFAULT_INITIAL_RECONNECT_DELAY); + return new StreamProcessor( + SDK_KEY, + config.httpConfig, + mockRequestor, + dataSourceUpdates(dataStore), + mockEventSourceCreator, + Thread.MIN_PRIORITY, + diagnosticAccumulator, + streamUri, + DEFAULT_INITIAL_RECONNECT_DELAY + ); } private StreamProcessor createStreamProcessorWithRealHttp(LDConfig config, URI streamUri) { - return new StreamProcessor(SDK_KEY, config.httpConfig, mockRequestor, dataSourceUpdates(dataStore), null, null, - streamUri, DEFAULT_INITIAL_RECONNECT_DELAY); + return new StreamProcessor( + SDK_KEY, + config.httpConfig, + mockRequestor, + dataSourceUpdates(dataStore), + null, + Thread.MIN_PRIORITY, + null, + streamUri, + DEFAULT_INITIAL_RECONNECT_DELAY + ); } private StreamProcessor createStreamProcessorWithStore(DataStore store) { @@ -839,8 +856,17 @@ private StreamProcessor createStreamProcessorWithStore(DataStore store) { } private StreamProcessor createStreamProcessorWithStoreUpdates(DataSourceUpdates storeUpdates) { - return new StreamProcessor(SDK_KEY, LDConfig.DEFAULT.httpConfig, mockRequestor, storeUpdates, - mockEventSourceCreator, null, STREAM_URI, DEFAULT_INITIAL_RECONNECT_DELAY); + return new StreamProcessor( + SDK_KEY, + LDConfig.DEFAULT.httpConfig, + mockRequestor, + storeUpdates, + mockEventSourceCreator, + Thread.MIN_PRIORITY, + null, + STREAM_URI, + DEFAULT_INITIAL_RECONNECT_DELAY + ); } private String featureJson(String key, int version) { From 5d56e8df89caa740dff0c3b2b2f92d3316b79a86 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 8 May 2020 15:31:07 -0700 Subject: [PATCH 420/673] 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 421/673] 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 422/673] 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 58bf66020b2ea79285b2a492020c0742f0a51f35 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 8 May 2020 16:05:05 -0700 Subject: [PATCH 423/673] adapt benchmarks to 5.0 API --- .../server}/LDClientEvaluationBenchmarks.java | 39 +++++++++++-------- .../{client => sdk/server}/TestValues.java | 28 ++++++------- 2 files changed, 37 insertions(+), 30 deletions(-) rename benchmarks/src/jmh/java/com/launchdarkly/{client => sdk/server}/LDClientEvaluationBenchmarks.java (74%) rename benchmarks/src/jmh/java/com/launchdarkly/{client => sdk/server}/TestValues.java (68%) 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 74% rename from benchmarks/src/jmh/java/com/launchdarkly/client/LDClientEvaluationBenchmarks.java rename to benchmarks/src/jmh/java/com/launchdarkly/sdk/server/LDClientEvaluationBenchmarks.java index e684a1fe7..0b9917cfd 100644 --- a/benchmarks/src/jmh/java/com/launchdarkly/client/LDClientEvaluationBenchmarks.java +++ b/benchmarks/src/jmh/java/com/launchdarkly/sdk/server/LDClientEvaluationBenchmarks.java @@ -1,6 +1,9 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.DataModel.FeatureFlag; +import com.launchdarkly.sdk.server.interfaces.DataStore; import org.openjdk.jmh.annotations.Benchmark; import org.openjdk.jmh.annotations.Scope; @@ -8,18 +11,20 @@ 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; +import static com.launchdarkly.sdk.server.TestComponents.initedDataStore; +import static com.launchdarkly.sdk.server.TestComponents.specificDataStore; +import static com.launchdarkly.sdk.server.TestUtil.upsertFlag; +import static com.launchdarkly.sdk.server.TestValues.BOOLEAN_FLAG_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_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 com.launchdarkly.sdk.server.TestValues.makeTestFlags; /** * These benchmarks cover just the evaluation logic itself (and, by necessity, the overhead of getting the @@ -34,13 +39,13 @@ public static class BenchmarkInputs { final Random random; public BenchmarkInputs() { - FeatureStore featureStore = TestUtil.initedFeatureStore(); + DataStore dataStore = initedDataStore(); for (FeatureFlag flag: makeTestFlags()) { - featureStore.upsert(FEATURES, flag); + upsertFlag(dataStore, flag); } LDConfig config = new LDConfig.Builder() - .dataStore(TestUtil.specificFeatureStore(featureStore)) + .dataStore(specificDataStore(dataStore)) .events(Components.noEvents()) .dataSource(Components.externalUpdatesOnly()) .build(); diff --git a/benchmarks/src/jmh/java/com/launchdarkly/client/TestValues.java b/benchmarks/src/jmh/java/com/launchdarkly/sdk/server/TestValues.java similarity index 68% rename from benchmarks/src/jmh/java/com/launchdarkly/client/TestValues.java rename to benchmarks/src/jmh/java/com/launchdarkly/sdk/server/TestValues.java index 0c2e12098..d71ef8e73 100644 --- a/benchmarks/src/jmh/java/com/launchdarkly/client/TestValues.java +++ b/benchmarks/src/jmh/java/com/launchdarkly/sdk/server/TestValues.java @@ -1,14 +1,16 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; -import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.DataModel.FeatureFlag; +import com.launchdarkly.sdk.server.DataModel.Target; 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; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.flagWithValue; +import static com.launchdarkly.sdk.server.ModelBuilders.prerequisite; public abstract class TestValues { private TestValues() {} @@ -42,26 +44,26 @@ public static List makeTestFlags() { 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) + FeatureFlag targetsFlag = flagBuilder(FLAG_WITH_TARGET_LIST_KEY) .on(true) - .targets(Arrays.asList(new Target(new HashSet(TARGETED_USER_KEYS), 1))) - .fallthrough(fallthroughVariation(0)) + .targets(new Target(new HashSet(TARGETED_USER_KEYS), 1)) + .fallthroughVariation(0) .offVariation(0) .variations(LDValue.of(false), LDValue.of(true)) .build(); flags.add(targetsFlag); - FeatureFlag prereqFlag = new FeatureFlagBuilder("prereq-flag") + FeatureFlag prereqFlag = flagBuilder("prereq-flag") .on(true) - .fallthrough(fallthroughVariation(1)) + .fallthroughVariation(1) .variations(LDValue.of(false), LDValue.of(true)) .build(); flags.add(prereqFlag); - FeatureFlag flagWithPrereq = new FeatureFlagBuilder(FLAG_WITH_PREREQ_KEY) + FeatureFlag flagWithPrereq = flagBuilder(FLAG_WITH_PREREQ_KEY) .on(true) - .prerequisites(Arrays.asList(new Prerequisite("prereq-flag", 1))) - .fallthrough(fallthroughVariation(1)) + .prerequisites(prerequisite("prereq-flag", 1)) + .fallthroughVariation(1) .offVariation(0) .variations(LDValue.of(false), LDValue.of(true)) .build(); From 94202d32f05c0b1761cfd1dcdead9f831f3f2c5e Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 8 May 2020 16:12:50 -0700 Subject: [PATCH 424/673] benchmarks need Gson in order to use shared test code --- benchmarks/build.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/benchmarks/build.gradle b/benchmarks/build.gradle index 3f54a72e4..fba1cd213 100644 --- a/benchmarks/build.gradle +++ b/benchmarks/build.gradle @@ -27,6 +27,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.code.gson:gson:2.7" compile "com.squareup.okhttp3:mockwebserver:3.12.10" // compile "org.hamcrest:hamcrest-all:1.3" compile "org.openjdk.jmh:jmh-core:1.21" From fecff19c8604b9459b29e23a46235dd9cbd3545b Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 8 May 2020 18:25:54 -0700 Subject: [PATCH 425/673] 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 426/673] 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 234929037c1cf96def6a0efb4167be65991f2462 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 11 May 2020 15:16:24 -0700 Subject: [PATCH 427/673] javadoc fixes --- .../sdk/server/integrations/PersistentDataStoreBuilder.java | 2 +- .../launchdarkly/sdk/server/interfaces/DataSourceFactory.java | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreBuilder.java b/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreBuilder.java index c33235afe..50608411f 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreBuilder.java @@ -30,7 +30,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. + * {@code cacheSeconds()} 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)}. diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceFactory.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceFactory.java index e615d5b6b..f29ef7846 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceFactory.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceFactory.java @@ -10,6 +10,9 @@ public interface DataSourceFactory { /** * Creates an implementation instance. + *

    + * The new {@code DataSource} should not attempt to make any connections until + * {@link DataSource#start()} is called. * * @param context allows access to the client configuration * @param dataSourceUpdates the component pushes data into the SDK via this interface From c9ece43209d5979a9b2c33da46ec575f61d0adbb Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 11 May 2020 16:59:53 -0700 Subject: [PATCH 428/673] minor PR feedback --- .../sdk/server/interfaces/DataSourceStatusProvider.java | 2 +- .../sdk/server/integrations/FileDataSourceTest.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProvider.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProvider.java index 47f9f29be..dd0f009d9 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProvider.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProvider.java @@ -82,7 +82,7 @@ public enum State { * Indicates that the data source has been permanently shut down. *

    * This could be because it encountered an unrecoverable error (for instance, the LaunchDarkly service - * rejected the SDK key; an invald SDK key will never become valid), or because the SDK client was + * rejected the SDK key; an invalid SDK key will never become valid), or because the SDK client was * explicitly shut down. */ OFF; diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java index 510ba3d69..249badc61 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java @@ -126,7 +126,7 @@ public void initializedIsFalseAfterUnsuccessfulLoad() throws Exception { } @Test - public void statusIsStartingAfterUnsuccessfulLoad() throws Exception { + public void statusIsInitializingAfterUnsuccessfulLoad() throws Exception { BlockingQueue statuses = new LinkedBlockingQueue<>(); dataSourceUpdates.register(statuses::add); From 96908426627251566e2930085d26c24de604d6d6 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 12 May 2020 12:39:13 -0700 Subject: [PATCH 429/673] make DataSourceUpdatesImpl responsible for detecting store failures; add new ErrorKind for this; improve comments and tests --- .../sdk/server/DataSourceUpdatesImpl.java | 55 ++++++++++---- .../sdk/server/PollingProcessor.java | 18 +++-- .../sdk/server/StreamProcessor.java | 51 +++++-------- .../interfaces/DataSourceStatusProvider.java | 11 ++- .../server/interfaces/DataSourceUpdates.java | 16 ++++- .../sdk/server/interfaces/DataStoreTypes.java | 20 ++++++ .../interfaces/PersistentDataStore.java | 10 ++- .../sdk/server/PollingProcessorTest.java | 71 +++++++++++++------ .../sdk/server/StreamProcessorTest.java | 33 +++++---- .../sdk/server/TestComponents.java | 37 ++++++++-- 10 files changed, 220 insertions(+), 102 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java b/src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java index be418c106..2485625a8 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java @@ -4,6 +4,9 @@ import com.google.common.collect.ImmutableSet; import com.launchdarkly.sdk.server.DataModelDependencies.KindAndKey; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorInfo; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorKind; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.State; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.Status; import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; import com.launchdarkly.sdk.server.interfaces.DataStore; @@ -41,6 +44,7 @@ final class DataSourceUpdatesImpl implements DataSourceUpdates { private final DataStoreStatusProvider dataStoreStatusProvider; private volatile DataSourceStatusProvider.Status currentStatus; + private volatile boolean lastStoreUpdateFailed = false; DataSourceUpdatesImpl( DataStore store, @@ -61,20 +65,25 @@ final class DataSourceUpdatesImpl implements DataSourceUpdates { } @Override - public void init(FullDataSet allData) { + public boolean init(FullDataSet allData) { Map> oldData = null; - - if (hasFlagChangeEventListeners()) { - // Query the existing data if any, so that after the update we can send events for whatever was changed - oldData = new HashMap<>(); - for (DataKind kind: ALL_DATA_KINDS) { - KeyedItems items = store.getAll(kind); - oldData.put(kind, ImmutableMap.copyOf(items.getItems())); + + try { + if (hasFlagChangeEventListeners()) { + // Query the existing data if any, so that after the update we can send events for whatever was changed + oldData = new HashMap<>(); + for (DataKind kind: ALL_DATA_KINDS) { + KeyedItems items = store.getAll(kind); + oldData.put(kind, ImmutableMap.copyOf(items.getItems())); + } } + store.init(DataModelDependencies.sortAllCollections(allData)); + lastStoreUpdateFailed = false; + } catch (RuntimeException e) { + reportStoreFailure(e); + return false; } - store.init(DataModelDependencies.sortAllCollections(allData)); - // We must always update the dependency graph even if we don't currently have any event listeners, because if // listeners are added later, we don't want to have to reread the whole data store to compute the graph updateDependencyTrackerFromFullDataSet(allData); @@ -84,11 +93,20 @@ public void init(FullDataSet allData) { if (oldData != null) { sendChangeEvents(computeChangedItemsForFullDataSet(oldData, fullDataSetToMap(allData))); } + + return true; } @Override - public void upsert(DataKind kind, String key, ItemDescriptor item) { - boolean successfullyUpdated = store.upsert(kind, key, item); + public boolean upsert(DataKind kind, String key, ItemDescriptor item) { + boolean successfullyUpdated; + try { + successfullyUpdated = store.upsert(kind, key, item); + lastStoreUpdateFailed = false; + } catch (RuntimeException e) { + reportStoreFailure(e); + return false; + } if (successfullyUpdated) { dependencyTracker.updateDependenciesFrom(kind, key, item); @@ -98,6 +116,8 @@ public void upsert(DataKind kind, String key, ItemDescriptor item) { sendChangeEvents(affectedItems); } } + + return true; } @Override @@ -193,6 +213,15 @@ private Set computeChangedItemsForFullDataSet(Map start() { } private void poll() { + FeatureRequestor.AllData allData = null; + try { - FeatureRequestor.AllData allData = requestor.getAllData(); - dataSourceUpdates.init(allData.toFullDataSet()); - if (!initialized.getAndSet(true)) { - logger.info("Initialized LaunchDarkly client."); - dataSourceUpdates.updateStatus(State.VALID, null); - initFuture.complete(null); - } + allData = requestor.getAllData(); } catch (HttpErrorException e) { ErrorInfo errorInfo = ErrorInfo.fromHttpError(e.getStatus()); logger.error(httpErrorMessage(e.getStatus(), "polling request", "will retry")); @@ -113,5 +109,13 @@ private void poll() { logger.debug(e.toString(), e); dataSourceUpdates.updateStatus(State.INTERRUPTED, ErrorInfo.fromException(ErrorKind.UNKNOWN, e)); } + + if (allData != null && dataSourceUpdates.init(allData.toFullDataSet())) { + if (!initialized.getAndSet(true)) { + logger.info("Initialized LaunchDarkly client."); + dataSourceUpdates.updateStatus(State.VALID, null); + initFuture.complete(null); + } + } } } diff --git a/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java index 07d09c54d..04fc8455f 100644 --- a/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java @@ -49,9 +49,11 @@ * 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. + * 1. If any event is malformed, we must assume the stream is broken and we may have missed updates. Set the + * data source state to INTERRUPTED, with an error kind of INVALID_DATA, and restart the stream. * 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. + * data store. We don't have to log this error because it is logged by DataSourceUpdatesImpl, which will also set + * our state to INTERRUPTED for us. * 2a. If the data store supports status notifications (which all persistent stores normally do), then we can * assume it has entered a failed state and will notify us once it is working again. If and when it recovers, then * it will tell us whether we need to restart the stream (to ensure that we haven't missed any updates), or @@ -59,8 +61,8 @@ * 2b. If the data store doesn't support status notifications (which is normally only true of the in-memory store) * then we don't know the significance of the error, but we must assume that updates have been lost, so we'll * restart the stream. - * 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. + * 3. If we receive an unrecoverable error like HTTP 401, we close the stream and don't retry, and set the state + * to OFF. Any other HTTP error or network error causes a retry with backoff, with a state of INTERRUPTED. * 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 @@ -299,11 +301,6 @@ public void onMessage(String name, MessageEvent event) throws Exception { es.restart(); } catch (StreamStoreException e) { // See item 2 in error handling comments at top of class - if (!lastStoreUpdateFailed) { - logger.error("Unexpected data store failure when storing updates from stream: {}", - e.getCause().toString()); - logger.debug(e.getCause().toString(), e.getCause()); - } if (statusListener == null) { if (!lastStoreUpdateFailed) { logger.warn("Restarting stream to ensure that we have the latest data"); @@ -322,10 +319,8 @@ private void handlePut(String eventData) throws StreamInputException, StreamStor esStarted = 0; PutData putData = parseStreamJson(PutData.class, eventData); FullDataSet allData = putData.data.toFullDataSet(); - try { - dataSourceUpdates.init(allData); - } catch (Exception e) { - throw new StreamStoreException(e); + if (!dataSourceUpdates.init(allData)) { + throw new StreamStoreException(); } if (!initialized.getAndSet(true)) { initFuture.complete(null); @@ -342,10 +337,8 @@ private void handlePatch(String eventData) throws StreamInputException, StreamSt DataKind kind = kindAndKey.getKey(); String key = kindAndKey.getValue(); VersionedData item = deserializeFromParsedJson(kind, data.data); - try { - dataSourceUpdates.upsert(kind, key, new ItemDescriptor(item.getVersion(), item)); - } catch (Exception e) { - throw new StreamStoreException(e); + if (!dataSourceUpdates.upsert(kind, key, new ItemDescriptor(item.getVersion(), item))) { + throw new StreamStoreException(); } } @@ -358,10 +351,8 @@ private void handleDelete(String eventData) throws StreamInputException, StreamS DataKind kind = kindAndKey.getKey(); String key = kindAndKey.getValue(); ItemDescriptor placeholder = new ItemDescriptor(data.version, null); - try { - dataSourceUpdates.upsert(kind, key, placeholder); - } catch (Exception e) { - throw new StreamStoreException(e); + if (!dataSourceUpdates.upsert(kind, key, placeholder)) { + throw new StreamStoreException(); } } @@ -373,10 +364,8 @@ private void handleIndirectPut() throws StreamInputException, StreamStoreExcepti throw new StreamInputException(e); } FullDataSet allData = putData.toFullDataSet(); - try { - dataSourceUpdates.init(allData); - } catch (Exception e) { - throw new StreamStoreException(e); + if (!dataSourceUpdates.init(allData)) { + throw new StreamStoreException(); } if (!initialized.getAndSet(true)) { initFuture.complete(null); @@ -397,10 +386,8 @@ private void handleIndirectPatch(String path) throws StreamInputException, Strea // could be that the request to the polling endpoint failed in some other way. But either way, we must // assume that we did not get valid data from LD so we have missed an update. } - try { - dataSourceUpdates.upsert(kind, key, new ItemDescriptor(item.getVersion(), item)); - } catch (Exception e) { - throw new StreamStoreException(e); + if (!dataSourceUpdates.upsert(kind, key, new ItemDescriptor(item.getVersion(), item))) { + throw new StreamStoreException(); } } @@ -481,11 +468,7 @@ public StreamInputException(Throwable 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 StreamStoreException extends Exception {} private static final class PutData { FeatureRequestor.AllData data; diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProvider.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProvider.java index dd0f009d9..8bb9cd241 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProvider.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProvider.java @@ -113,7 +113,16 @@ public static enum ErrorKind { /** * The SDK received malformed data from the LaunchDarkly service. */ - INVALID_DATA + INVALID_DATA, + + /** + * The data source itself is working, but when it tried to put an update into the data store, the data + * store failed (so the SDK may not have the latest data). + *

    + * Data source implementations do not need to report this kind of error; it will be automatically + * reported by the SDK whenever one of the update methods of {@link DataSourceUpdates} throws an exception. + */ + STORE_ERROR } /** diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceUpdates.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceUpdates.java index 20e192b1d..730c784cd 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceUpdates.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceUpdates.java @@ -15,10 +15,16 @@ public interface DataSourceUpdates { /** * Completely overwrites the current contents of the data store with a set of items for each collection. + *

    + * If the underlying data store throws an error during this operation, the SDK will catch it, log it, + * and set the data source state to {@link DataSourceStatusProvider.State#INTERRUPTED} with an error of + * {@link DataSourceStatusProvider.ErrorKind#STORE_ERROR}. It will not rethrow the error to the data + * source, but will simply return {@code false} to indicate that the operation failed. * * @param allData a list of {@link DataStoreTypes.DataKind} instances and their corresponding data sets + * @return true if the update succeeded, false if it failed */ - void init(FullDataSet allData); + boolean init(FullDataSet allData); /** * Updates or inserts an item in the specified collection. For updates, the object will only be @@ -27,12 +33,18 @@ public interface DataSourceUpdates { * To mark an item as deleted, pass an {@link ItemDescriptor} that contains a null, with a version * number (you may use {@link ItemDescriptor#deletedItem(int)}). Deletions must be versioned so that * they do not overwrite a later update in case updates are received out of order. + *

    + * If the underlying data store throws an error during this operation, the SDK will catch it, log it, + * and set the data source state to {@link DataSourceStatusProvider.State#INTERRUPTED} with an error of + * {@link DataSourceStatusProvider.ErrorKind#STORE_ERROR}. It will not rethrow the error to the data + * source, but will simply return {@code false} to indicate that the operation failed. * * @param kind specifies which collection to use * @param key the unique key for the item within that collection * @param item the item to insert or update + * @return true if the update succeeded, false if it failed */ - void upsert(DataKind kind, String key, ItemDescriptor item); + boolean upsert(DataKind kind, String key, ItemDescriptor item); /** * Returns an object that provides status tracking for the data store, if applicable. diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypes.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypes.java index c92a3048c..7c9f9c800 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypes.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypes.java @@ -267,6 +267,16 @@ public Iterable>> getData() { public FullDataSet(Iterable>> data) { this.data = data == null ? ImmutableList.of(): data; } + + @Override + public boolean equals(Object o) { + return o instanceof FullDataSet && data.equals(((FullDataSet)o).data); + } + + @Override + public int hashCode() { + return data.hashCode(); + } } /** @@ -295,5 +305,15 @@ public Iterable> getItems() { public KeyedItems(Iterable> items) { this.items = items == null ? ImmutableList.of() : items; } + + @Override + public boolean equals(Object o) { + return o instanceof KeyedItems && items.equals(((KeyedItems)o).items); + } + + @Override + public int hashCode() { + return items.hashCode(); + } } } diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/PersistentDataStore.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/PersistentDataStore.java index 410baa062..03dea02d7 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/PersistentDataStore.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/PersistentDataStore.java @@ -23,19 +23,25 @@ * a version number, and can represent either a serialized object or a placeholder (tombstone) * for a deleted item. There are two approaches a persistent store implementation can use for * persisting this data: - * + *

    * 1. Preferably, it should store the version number and the {@link SerializedItemDescriptor#isDeleted()} * state separately so that the object does not need to be fully deserialized to read them. In * this case, deleted item placeholders can ignore the value of {@link SerializedItemDescriptor#getSerializedItem()} * on writes and can set it to null on reads. The store should never call {@link DataKind#deserialize(String)} * or {@link DataKind#serialize(DataStoreTypes.ItemDescriptor)}. - * + *

    * 2. If that isn't possible, then the store should simply persist the exact string from * {@link SerializedItemDescriptor#getSerializedItem()} on writes, and return the persisted * string on reads (returning zero for the version and false for {@link SerializedItemDescriptor#isDeleted()}). * The string is guaranteed to provide the SDK with enough information to infer the version and * the deleted state. On updates, the store must call {@link DataKind#deserialize(String)} in * order to inspect the version number of the existing item if any. + *

    + * Error handling is defined as follows: if any data store operation encounters a database error, or + * is otherwise unable to complete its task, it should throw a {@code RuntimeException} to make the SDK + * aware of this. The SDK will log the exception and will assume that the data store is now in a + * non-operational state; the SDK will then start polling {@link #isStoreAvailable()} to determine + * when the store has started working again. * * @since 5.0.0 */ diff --git a/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java index 459c75273..c7418341e 100644 --- a/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java @@ -4,7 +4,11 @@ import com.launchdarkly.sdk.server.TestComponents.MockDataStoreStatusProvider; import com.launchdarkly.sdk.server.integrations.PollingDataSourceBuilder; import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; -import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorKind; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.State; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.Status; +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; import org.junit.Before; import org.junit.Test; @@ -20,6 +24,7 @@ import java.util.concurrent.TimeoutException; import static com.launchdarkly.sdk.server.TestComponents.clientContext; +import static com.launchdarkly.sdk.server.TestComponents.dataStoreThatThrowsException; import static com.launchdarkly.sdk.server.TestComponents.sharedExecutor; import static com.launchdarkly.sdk.server.TestUtil.requireDataSourceStatus; import static com.launchdarkly.sdk.server.TestUtil.requireDataSourceStatusEventually; @@ -39,13 +44,12 @@ public class PollingProcessorTest { private static final String SDK_KEY = "sdk-key"; private static final Duration LENGTHY_INTERVAL = Duration.ofSeconds(60); - private InMemoryDataStore store; private MockDataSourceUpdates dataSourceUpdates; private MockFeatureRequestor requestor; @Before public void setup() { - store = new InMemoryDataStore(); + DataStore store = new InMemoryDataStore(); dataSourceUpdates = TestComponents.dataSourceUpdates(store, new MockDataStoreStatusProvider()); requestor = new MockFeatureRequestor(); } @@ -83,16 +87,17 @@ public void builderCanSpecifyConfiguration() throws Exception { public void testConnectionOk() throws Exception { requestor.allData = new FeatureRequestor.AllData(new HashMap<>(), new HashMap<>()); - BlockingQueue statuses = new LinkedBlockingQueue<>(); + BlockingQueue statuses = new LinkedBlockingQueue<>(); dataSourceUpdates.statusBroadcaster.register(statuses::add); try (PollingProcessor pollingProcessor = makeProcessor()) { Future initFuture = pollingProcessor.start(); initFuture.get(1000, TimeUnit.MILLISECONDS); + assertTrue(pollingProcessor.isInitialized()); - assertTrue(store.isInitialized()); + assertEquals(requestor.allData.toFullDataSet(), dataSourceUpdates.awaitInit()); - requireDataSourceStatus(statuses, DataSourceStatusProvider.State.VALID); + requireDataSourceStatus(statuses, State.VALID); } } @@ -100,7 +105,7 @@ public void testConnectionOk() throws Exception { public void testConnectionProblem() throws Exception { requestor.ioException = new IOException("This exception is part of a test and yes you should be seeing it."); - BlockingQueue statuses = new LinkedBlockingQueue<>(); + BlockingQueue statuses = new LinkedBlockingQueue<>(); dataSourceUpdates.statusBroadcaster.register(statuses::add); try (PollingProcessor pollingProcessor = makeProcessor()) { @@ -112,14 +117,38 @@ public void testConnectionProblem() throws Exception { } assertFalse(initFuture.isDone()); assertFalse(pollingProcessor.isInitialized()); - assertFalse(store.isInitialized()); + assertEquals(0, dataSourceUpdates.receivedInits.size()); - DataSourceStatusProvider.Status status = requireDataSourceStatus(statuses, DataSourceStatusProvider.State.INITIALIZING); + Status status = requireDataSourceStatus(statuses, State.INITIALIZING); assertNotNull(status.getLastError()); - assertEquals(DataSourceStatusProvider.ErrorKind.NETWORK_ERROR, status.getLastError().getKind()); + assertEquals(ErrorKind.NETWORK_ERROR, status.getLastError().getKind()); } } + @Test + public void testDataStoreFailure() throws Exception { + DataStore badStore = dataStoreThatThrowsException(new RuntimeException("sorry")); + DataStoreStatusProvider badStoreStatusProvider = new MockDataStoreStatusProvider(false); + dataSourceUpdates = TestComponents.dataSourceUpdates(badStore, badStoreStatusProvider); + + requestor.allData = new FeatureRequestor.AllData(new HashMap<>(), new HashMap<>()); + + BlockingQueue statuses = new LinkedBlockingQueue<>(); + dataSourceUpdates.statusBroadcaster.register(statuses::add); + + try (PollingProcessor pollingProcessor = makeProcessor()) { + pollingProcessor.start(); + + assertEquals(requestor.allData.toFullDataSet(), dataSourceUpdates.awaitInit()); + + assertFalse(pollingProcessor.isInitialized()); + + Status status = requireDataSourceStatus(statuses, State.INITIALIZING); + assertNotNull(status.getLastError()); + assertEquals(ErrorKind.STORE_ERROR, status.getLastError().getKind()); + } + } + @Test public void http400ErrorIsRecoverable() throws Exception { testRecoverableHttpError(400); @@ -153,7 +182,7 @@ public void http500ErrorIsRecoverable() throws Exception { private void testUnrecoverableHttpError(int statusCode) throws Exception { requestor.httpException = new HttpErrorException(statusCode); - BlockingQueue statuses = new LinkedBlockingQueue<>(); + BlockingQueue statuses = new LinkedBlockingQueue<>(); dataSourceUpdates.statusBroadcaster.register(statuses::add); try (PollingProcessor pollingProcessor = makeProcessor()) { @@ -165,9 +194,9 @@ private void testUnrecoverableHttpError(int statusCode) throws Exception { assertTrue(initFuture.isDone()); assertFalse(pollingProcessor.isInitialized()); - DataSourceStatusProvider.Status status = requireDataSourceStatus(statuses, DataSourceStatusProvider.State.OFF); + Status status = requireDataSourceStatus(statuses, State.OFF); assertNotNull(status.getLastError()); - assertEquals(DataSourceStatusProvider.ErrorKind.ERROR_RESPONSE, status.getLastError().getKind()); + assertEquals(ErrorKind.ERROR_RESPONSE, status.getLastError().getKind()); assertEquals(statusCode, status.getLastError().getStatusCode()); } } @@ -177,7 +206,7 @@ private void testRecoverableHttpError(int statusCode) throws Exception { Duration shortInterval = Duration.ofMillis(20); requestor.httpException = httpError; - BlockingQueue statuses = new LinkedBlockingQueue<>(); + BlockingQueue statuses = new LinkedBlockingQueue<>(); dataSourceUpdates.statusBroadcaster.register(statuses::add); try (PollingProcessor pollingProcessor = makeProcessor(shortInterval)) { @@ -188,9 +217,9 @@ private void testRecoverableHttpError(int statusCode) throws Exception { assertFalse(initFuture.isDone()); assertFalse(pollingProcessor.isInitialized()); - DataSourceStatusProvider.Status status1 = requireDataSourceStatus(statuses, DataSourceStatusProvider.State.INITIALIZING); + Status status1 = requireDataSourceStatus(statuses, State.INITIALIZING); assertNotNull(status1.getLastError()); - assertEquals(DataSourceStatusProvider.ErrorKind.ERROR_RESPONSE, status1.getLastError().getKind()); + assertEquals(ErrorKind.ERROR_RESPONSE, status1.getLastError().getKind()); assertEquals(statusCode, status1.getLastError().getStatusCode()); // now make it so the requestor will succeed @@ -202,19 +231,17 @@ private void testRecoverableHttpError(int statusCode) throws Exception { assertTrue(pollingProcessor.isInitialized()); // status should now be VALID (although there might have been more failed polls before that) - DataSourceStatusProvider.Status status2 = requireDataSourceStatusEventually(statuses, - DataSourceStatusProvider.State.VALID, DataSourceStatusProvider.State.INITIALIZING); + Status status2 = requireDataSourceStatusEventually(statuses, State.VALID, State.INITIALIZING); assertNotNull(status2.getLastError()); - assertEquals(DataSourceStatusProvider.ErrorKind.ERROR_RESPONSE, status2.getLastError().getKind()); + assertEquals(ErrorKind.ERROR_RESPONSE, status2.getLastError().getKind()); assertEquals(statusCode, status2.getLastError().getStatusCode()); // simulate another error of the same kind - the difference is now the state will be INTERRUPTED requestor.httpException = httpError; - DataSourceStatusProvider.Status status3 = requireDataSourceStatusEventually(statuses, - DataSourceStatusProvider.State.INTERRUPTED, DataSourceStatusProvider.State.VALID); + Status status3 = requireDataSourceStatusEventually(statuses, State.INTERRUPTED, State.VALID); assertNotNull(status3.getLastError()); - assertEquals(DataSourceStatusProvider.ErrorKind.ERROR_RESPONSE, status3.getLastError().getKind()); + assertEquals(ErrorKind.ERROR_RESPONSE, status3.getLastError().getKind()); assertEquals(statusCode, status3.getLastError().getStatusCode()); assertNotSame(status1.getLastError(), status3.getLastError()); // it's a new error object of the same kind } diff --git a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java index e93d1459b..4392aaf2d 100644 --- a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java @@ -594,25 +594,28 @@ public void doesNotRestartStreamIfStoreHadOutageButDoesNotNeedRefresh() throws E @Test public void storeFailureOnPutCausesStreamRestart() throws Exception { - DataStore badStore = dataStoreThatThrowsException(new RuntimeException("sorry")); + MockDataSourceUpdates badUpdates = dataSourceUpdatesThatMakesUpdatesFailAndDoesNotSupportStatusMonitoring(); expectStreamRestart(); replayAll(); - try (StreamProcessor sp = createStreamProcessorWithStore(badStore)) { + try (StreamProcessor sp = createStreamProcessorWithStoreUpdates(badUpdates)) { sp.start(); EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; handler.onMessage("put", emptyPutEvent()); + + assertNotNull(badUpdates.getLastStatus().getLastError()); + assertEquals(ErrorKind.STORE_ERROR, badUpdates.getLastStatus().getLastError().getKind()); } verifyAll(); } @Test public void storeFailureOnPatchCausesStreamRestart() throws Exception { - DataStore badStore = dataStoreThatThrowsException(new RuntimeException("sorry")); + MockDataSourceUpdates badUpdates = dataSourceUpdatesThatMakesUpdatesFailAndDoesNotSupportStatusMonitoring(); expectStreamRestart(); replayAll(); - try (StreamProcessor sp = createStreamProcessorWithStore(badStore)) { + try (StreamProcessor sp = createStreamProcessorWithStoreUpdates(badUpdates)) { sp.start(); EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; handler.onMessage("patch", @@ -623,11 +626,11 @@ public void storeFailureOnPatchCausesStreamRestart() throws Exception { @Test public void storeFailureOnDeleteCausesStreamRestart() throws Exception { - DataStore badStore = dataStoreThatThrowsException(new RuntimeException("sorry")); + MockDataSourceUpdates badUpdates = dataSourceUpdatesThatMakesUpdatesFailAndDoesNotSupportStatusMonitoring(); expectStreamRestart(); replayAll(); - try (StreamProcessor sp = createStreamProcessorWithStore(badStore)) { + try (StreamProcessor sp = createStreamProcessorWithStoreUpdates(badUpdates)) { sp.start(); EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; handler.onMessage("delete", @@ -638,12 +641,12 @@ public void storeFailureOnDeleteCausesStreamRestart() throws Exception { @Test public void storeFailureOnIndirectPutCausesStreamRestart() throws Exception { - DataStore badStore = dataStoreThatThrowsException(new RuntimeException("sorry")); + MockDataSourceUpdates badUpdates = dataSourceUpdatesThatMakesUpdatesFailAndDoesNotSupportStatusMonitoring(); setupRequestorToReturnAllDataWithFlag(FEATURE); expectStreamRestart(); replayAll(); - try (StreamProcessor sp = createStreamProcessorWithStore(badStore)) { + try (StreamProcessor sp = createStreamProcessorWithStoreUpdates(badUpdates)) { sp.start(); EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; handler.onMessage("indirect/put", new MessageEvent("")); @@ -653,13 +656,13 @@ public void storeFailureOnIndirectPutCausesStreamRestart() throws Exception { @Test public void storeFailureOnIndirectPatchCausesStreamRestart() throws Exception { - DataStore badStore = dataStoreThatThrowsException(new RuntimeException("sorry")); + MockDataSourceUpdates badUpdates = dataSourceUpdatesThatMakesUpdatesFailAndDoesNotSupportStatusMonitoring(); setupRequestorToReturnAllDataWithFlag(FEATURE); expectStreamRestart(); replayAll(); - try (StreamProcessor sp = createStreamProcessorWithStore(badStore)) { + try (StreamProcessor sp = createStreamProcessorWithStoreUpdates(badUpdates)) { sp.start(); EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; handler.onMessage("indirect/put", new MessageEvent("")); @@ -667,6 +670,12 @@ public void storeFailureOnIndirectPatchCausesStreamRestart() throws Exception { verifyAll(); } + private MockDataSourceUpdates dataSourceUpdatesThatMakesUpdatesFailAndDoesNotSupportStatusMonitoring() { + DataStore badStore = dataStoreThatThrowsException(new RuntimeException("sorry")); + DataStoreStatusProvider badStoreStatusProvider = new MockDataStoreStatusProvider(false); + return TestComponents.dataSourceUpdates(badStore, badStoreStatusProvider); + } + private void verifyEventCausesNoStreamRestart(String eventName, String eventData) throws Exception { expectNoStreamRestart(); verifyEventBehavior(eventName, eventData); @@ -896,10 +905,6 @@ private StreamProcessor createStreamProcessorWithRealHttp(LDConfig config, URI s ); } - private StreamProcessor createStreamProcessorWithStore(DataStore store) { - return createStreamProcessorWithStoreUpdates(dataSourceUpdates(store)); - } - private StreamProcessor createStreamProcessorWithStoreUpdates(DataSourceUpdates storeUpdates) { return new StreamProcessor( SDK_KEY, diff --git a/src/test/java/com/launchdarkly/sdk/server/TestComponents.java b/src/test/java/com/launchdarkly/sdk/server/TestComponents.java index 088ad5dd7..341ddd741 100644 --- a/src/test/java/com/launchdarkly/sdk/server/TestComponents.java +++ b/src/test/java/com/launchdarkly/sdk/server/TestComponents.java @@ -38,6 +38,7 @@ import java.util.concurrent.Future; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import static com.launchdarkly.sdk.server.DataModel.FEATURES; @@ -185,11 +186,12 @@ public void close() throws IOException { } public static class MockDataSourceUpdates implements DataSourceUpdates { - private final DataSourceUpdates wrappedInstance; + private final DataSourceUpdatesImpl wrappedInstance; private final DataStoreStatusProvider dataStoreStatusProvider; public final EventBroadcasterImpl flagChangeEventBroadcaster; public final EventBroadcasterImpl statusBroadcaster; + public final BlockingQueue> receivedInits = new LinkedBlockingQueue<>(); public MockDataSourceUpdates(DataStore store, DataStoreStatusProvider dataStoreStatusProvider) { this.dataStoreStatusProvider = dataStoreStatusProvider; @@ -204,13 +206,14 @@ public MockDataSourceUpdates(DataStore store, DataStoreStatusProvider dataStoreS } @Override - public void init(FullDataSet allData) { - wrappedInstance.init(allData); + public boolean init(FullDataSet allData) { + receivedInits.add(allData); + return wrappedInstance.init(allData); } @Override - public void upsert(DataKind kind, String key, ItemDescriptor item) { - wrappedInstance.upsert(kind, key, item); + public boolean upsert(DataKind kind, String key, ItemDescriptor item) { + return wrappedInstance.upsert(kind, key, item); } @Override @@ -223,10 +226,24 @@ public void updateStatus(State newState, ErrorInfo newError) { wrappedInstance.updateStatus(newState, newError); } + public DataSourceStatusProvider.Status getLastStatus() { + return wrappedInstance.getLastStatus(); + } + // this method is surfaced for use by tests in other packages that can't see the EventBroadcasterImpl class public void register(DataSourceStatusProvider.StatusListener listener) { statusBroadcaster.register(listener); } + + public FullDataSet awaitInit() { + try { + FullDataSet value = receivedInits.poll(5, TimeUnit.SECONDS); + if (value != null) { + return value; + } + } catch (InterruptedException e) {} + throw new RuntimeException("did not receive expected init call"); + } } public static class DataStoreFactoryThatExposesUpdater implements DataStoreFactory { @@ -285,10 +302,16 @@ public CacheStats getCacheStats() { public static class MockDataStoreStatusProvider implements DataStoreStatusProvider { public final EventBroadcasterImpl statusBroadcaster; private final AtomicReference lastStatus; - + private final boolean statusMonitoringEnabled; + public MockDataStoreStatusProvider() { + this(true); + } + + public MockDataStoreStatusProvider(boolean statusMonitoringEnabled) { this.statusBroadcaster = EventBroadcasterImpl.forDataStoreStatus(sharedExecutor); this.lastStatus = new AtomicReference<>(new DataStoreStatusProvider.Status(true, false)); + this.statusMonitoringEnabled = statusMonitoringEnabled; } // visible for tests @@ -318,7 +341,7 @@ public void removeStatusListener(StatusListener listener) { @Override public boolean isStatusMonitoringEnabled() { - return true; + return statusMonitoringEnabled; } @Override From d6c90d1cf1eab8eca9e58bca1628e14d1d94757f Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 12 May 2020 14:10:51 -0700 Subject: [PATCH 430/673] (#7) implement time-dependent escalation of data source error logging (#228) --- .../sdk/server/ClientContextImpl.java | 14 +- .../launchdarkly/sdk/server/Components.java | 33 ++++- .../sdk/server/DataSourceUpdatesImpl.java | 136 +++++++++++++++--- .../sdk/server/DefaultEventProcessor.java | 22 +-- .../com/launchdarkly/sdk/server/LDClient.java | 8 +- .../com/launchdarkly/sdk/server/LDConfig.java | 22 +++ .../sdk/server/LoggingConfigurationImpl.java | 18 +++ .../sdk/server/PollingProcessor.java | 14 +- .../sdk/server/StreamProcessor.java | 26 ++-- .../com/launchdarkly/sdk/server/Util.java | 57 ++++++-- .../LoggingConfigurationBuilder.java | 57 ++++++++ .../sdk/server/interfaces/ClientContext.java | 6 + .../interfaces/DataSourceStatusProvider.java | 23 ++- .../interfaces/DataStoreStatusProvider.java | 4 - .../interfaces/LoggingConfiguration.java | 23 +++ .../LoggingConfigurationFactory.java | 16 +++ .../sdk/server/DataSourceUpdatesImplTest.java | 27 ++-- .../sdk/server/TestComponents.java | 4 +- .../com/launchdarkly/sdk/server/UtilTest.java | 11 ++ .../LoggingConfigurationBuilderTest.java | 34 +++++ src/test/resources/logback.xml | 2 +- 21 files changed, 473 insertions(+), 84 deletions(-) create mode 100644 src/main/java/com/launchdarkly/sdk/server/LoggingConfigurationImpl.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/integrations/LoggingConfigurationBuilder.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/interfaces/LoggingConfiguration.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/interfaces/LoggingConfigurationFactory.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/integrations/LoggingConfigurationBuilderTest.java diff --git a/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java b/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java index 9a82dde21..dac5b6d34 100644 --- a/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java @@ -2,6 +2,7 @@ import com.launchdarkly.sdk.server.interfaces.ClientContext; import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; +import com.launchdarkly.sdk.server.interfaces.LoggingConfiguration; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; @@ -22,6 +23,7 @@ final class ClientContextImpl implements ClientContext { private final String sdkKey; private final HttpConfiguration httpConfiguration; + private final LoggingConfiguration loggingConfiguration; private final boolean offline; private final int threadPriority; final ScheduledExecutorService sharedExecutor; @@ -31,6 +33,7 @@ final class ClientContextImpl implements ClientContext { private ClientContextImpl( String sdkKey, HttpConfiguration httpConfiguration, + LoggingConfiguration loggingConfiguration, boolean offline, int threadPriority, ScheduledExecutorService sharedExecutor, @@ -39,6 +42,7 @@ private ClientContextImpl( ) { this.sdkKey = sdkKey; this.httpConfiguration = httpConfiguration; + this.loggingConfiguration = loggingConfiguration; this.offline = offline; this.threadPriority = threadPriority; this.sharedExecutor = sharedExecutor; @@ -54,6 +58,7 @@ private ClientContextImpl( ) { this.sdkKey = sdkKey; this.httpConfiguration = configuration.httpConfig; + this.loggingConfiguration = configuration.loggingConfig; this.offline = configuration.offline; this.threadPriority = configuration.threadPriority; this.sharedExecutor = sharedExecutor; @@ -80,6 +85,11 @@ public boolean isOffline() { public HttpConfiguration getHttpConfiguration() { return httpConfiguration; } + + @Override + public LoggingConfiguration getLoggingConfiguration() { + return loggingConfiguration; + } @Override public int getThreadPriority() { @@ -105,10 +115,12 @@ static ClientContextImpl get(ClientContext context) { return new ClientContextImpl( context.getSdkKey(), context.getHttpConfiguration(), + context.getLoggingConfiguration(), context.isOffline(), context.getThreadPriority(), fallbackSharedExecutor, null, - null); + null + ); } } diff --git a/src/main/java/com/launchdarkly/sdk/server/Components.java b/src/main/java/com/launchdarkly/sdk/server/Components.java index 7e4542976..1b3d63f6f 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Components.java +++ b/src/main/java/com/launchdarkly/sdk/server/Components.java @@ -5,6 +5,7 @@ import com.launchdarkly.sdk.server.DiagnosticEvent.ConfigProperty; import com.launchdarkly.sdk.server.integrations.EventProcessorBuilder; import com.launchdarkly.sdk.server.integrations.HttpConfigurationBuilder; +import com.launchdarkly.sdk.server.integrations.LoggingConfigurationBuilder; import com.launchdarkly.sdk.server.integrations.PersistentDataStoreBuilder; import com.launchdarkly.sdk.server.integrations.PollingDataSourceBuilder; import com.launchdarkly.sdk.server.integrations.StreamingDataSourceBuilder; @@ -24,6 +25,7 @@ import com.launchdarkly.sdk.server.interfaces.FlagValueChangeListener; import com.launchdarkly.sdk.server.interfaces.HttpAuthentication; import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; +import com.launchdarkly.sdk.server.interfaces.LoggingConfiguration; import com.launchdarkly.sdk.server.interfaces.PersistentDataStore; import com.launchdarkly.sdk.server.interfaces.PersistentDataStoreFactory; @@ -222,7 +224,7 @@ public static DataSourceFactory externalUpdatesOnly() { } /** - * Returns a configurable factory for the SDK's networking configuration. + * Returns a configuration builder for the SDK's networking configuration. *

    * Passing this to {@link LDConfig.Builder#http(com.launchdarkly.sdk.server.interfaces.HttpConfigurationFactory)} * applies this configuration to all HTTP/HTTPS requests made by the SDK. @@ -265,6 +267,28 @@ public static HttpConfigurationBuilder httpConfiguration() { public static HttpAuthentication httpBasicAuthentication(String username, String password) { return new HttpBasicAuthentication(username, password); } + + /** + * Returns a configuration builder for the SDK's logging configuration. + *

    + * Passing this to {@link LDConfig.Builder#logging(com.launchdarkly.sdk.server.interfaces.LoggingConfigurationFactory)}, + * after setting any desired properties on the builder, applies this configuration to the SDK. + *

    
    +   *     LDConfig config = new LDConfig.Builder()
    +   *         .logging(
    +   *              Components.logging()
    +   *                  .logDataSourceOutageAsErrorAfter(Duration.ofSeconds(120))
    +   *         )
    +   *         .build();
    +   * 
    + * + * @return a factory object + * @since 5.0.0 + * @see LDConfig.Builder#logging(com.launchdarkly.sdk.server.interfaces.LoggingConfigurationFactory) + */ + public static LoggingConfigurationBuilder logging() { + return new LoggingConfigurationBuilderImpl(); + } /** * Convenience method for creating a {@link FlagChangeListener} that tracks a flag's value for a specific user. @@ -580,4 +604,11 @@ public DataStore createDataStore(ClientContext context, DataStoreUpdates dataSto ); } } + + private static final class LoggingConfigurationBuilderImpl extends LoggingConfigurationBuilder { + @Override + public LoggingConfiguration createLoggingConfiguration() { + return new LoggingConfigurationImpl(logDataSourceOutageAsErrorAfter); + } + } } diff --git a/src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java b/src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java index 2485625a8..70ef123c1 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java @@ -1,13 +1,14 @@ package com.launchdarkly.sdk.server; +import com.google.common.base.Joiner; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import com.launchdarkly.sdk.server.DataModelDependencies.KindAndKey; -import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorInfo; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorKind; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.State; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.Status; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.StatusListener; import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; @@ -18,13 +19,18 @@ import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; +import java.time.Duration; import java.time.Instant; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Set; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; import static com.google.common.collect.Iterables.concat; +import static com.google.common.collect.Iterables.transform; import static com.launchdarkly.sdk.server.DataModel.ALL_DATA_KINDS; import static com.launchdarkly.sdk.server.DataModel.FEATURES; import static java.util.Collections.emptyMap; @@ -33,35 +39,38 @@ * The data source will push updates into this component. We then apply any necessary * transformations before putting them into the data store; currently that just means sorting * the data set for init(). We also generate flag change events for any updates or deletions. + *

    + * This component is also responsible for receiving updates to the data source status, broadcasting + * them to any status listeners, and tracking the length of any period of sustained failure. * * @since 4.11.0 */ final class DataSourceUpdatesImpl implements DataSourceUpdates { private final DataStore store; private final EventBroadcasterImpl flagChangeEventNotifier; - private final EventBroadcasterImpl dataSourceStatusNotifier; + private final EventBroadcasterImpl dataSourceStatusNotifier; private final DataModelDependencies.DependencyTracker dependencyTracker = new DataModelDependencies.DependencyTracker(); private final DataStoreStatusProvider dataStoreStatusProvider; + private final OutageTracker outageTracker; - private volatile DataSourceStatusProvider.Status currentStatus; + private volatile Status currentStatus; private volatile boolean lastStoreUpdateFailed = false; DataSourceUpdatesImpl( DataStore store, DataStoreStatusProvider dataStoreStatusProvider, EventBroadcasterImpl flagChangeEventNotifier, - EventBroadcasterImpl dataSourceStatusNotifier + EventBroadcasterImpl dataSourceStatusNotifier, + ScheduledExecutorService sharedExecutor, + Duration outageLoggingTimeout ) { this.store = store; this.flagChangeEventNotifier = flagChangeEventNotifier; this.dataSourceStatusNotifier = dataSourceStatusNotifier; this.dataStoreStatusProvider = dataStoreStatusProvider; + this.outageTracker = new OutageTracker(sharedExecutor, outageLoggingTimeout); - currentStatus = new DataSourceStatusProvider.Status( - DataSourceStatusProvider.State.INITIALIZING, - Instant.now(), - null - ); + currentStatus = new Status(State.INITIALIZING, Instant.now(), null); } @Override @@ -126,26 +135,35 @@ public DataStoreStatusProvider getDataStoreStatusProvider() { } @Override - public void updateStatus(DataSourceStatusProvider.State newState, DataSourceStatusProvider.ErrorInfo newError) { + public void updateStatus(State newState, ErrorInfo newError) { if (newState == null) { return; } - DataSourceStatusProvider.Status newStatus; + + Status statusToBroadcast = null; + synchronized (this) { - if (newState == DataSourceStatusProvider.State.INTERRUPTED && currentStatus.getState() == DataSourceStatusProvider.State.INITIALIZING) { - newState = DataSourceStatusProvider.State.INITIALIZING; // see comment on updateStatus in the DataSourceUpdates interface + Status oldStatus = currentStatus; + + if (newState == State.INTERRUPTED && oldStatus.getState() == State.INITIALIZING) { + newState = State.INITIALIZING; // see comment on updateStatus in the DataSourceUpdates interface } - if (newState == currentStatus.getState() && newError == null) { - return; + + if (newState != oldStatus.getState() || newError != null) { + currentStatus = new Status( + newState, + newState == currentStatus.getState() ? currentStatus.getStateSince() : Instant.now(), + newError == null ? currentStatus.getLastError() : newError + ); + statusToBroadcast = currentStatus; } - currentStatus = new DataSourceStatusProvider.Status( - newState, - newState == currentStatus.getState() ? currentStatus.getStateSince() : Instant.now(), - newError == null ? currentStatus.getLastError() : newError - ); - newStatus = currentStatus; + + outageTracker.trackDataSourceState(newState, newError); + } + + if (statusToBroadcast != null) { + dataSourceStatusNotifier.broadcast(statusToBroadcast); } - dataSourceStatusNotifier.broadcast(newStatus); } Status getLastStatus() { @@ -224,4 +242,78 @@ private void reportStoreFailure(RuntimeException e) { LDClient.logger.debug(e.toString(), e); updateStatus(State.INTERRUPTED, ErrorInfo.fromException(ErrorKind.STORE_ERROR, e)); } + + // Encapsulates our logic for keeping track of the length and cause of data source outages. + private static final class OutageTracker { + private final boolean enabled; + private final ScheduledExecutorService sharedExecutor; + private final Duration loggingTimeout; + private final HashMap errorCounts = new HashMap<>(); + + private volatile boolean inOutage; + private volatile ScheduledFuture timeoutFuture; + + OutageTracker(ScheduledExecutorService sharedExecutor, Duration loggingTimeout) { + this.sharedExecutor = sharedExecutor; + this.loggingTimeout = loggingTimeout; + this.enabled = loggingTimeout != null; + } + + void trackDataSourceState(State newState, ErrorInfo newError) { + if (!enabled) { + return; + } + + synchronized (this) { + if (newState == State.INTERRUPTED || newError != null || (newState == State.INITIALIZING && inOutage)) { + // We are in a potentially recoverable outage. If that wasn't the case already, and if we've been configured + // with a timeout for logging the outage at a higher level, schedule that timeout. + if (inOutage) { + // We were already in one - just record this latest error for logging later. + recordError(newError); + } else { + // We weren't already in one, so set the timeout and start recording errors. + inOutage = true; + errorCounts.clear(); + recordError(newError); + timeoutFuture = sharedExecutor.schedule(this::onTimeout, loggingTimeout.toMillis(), TimeUnit.MILLISECONDS); + } + } else { + if (timeoutFuture != null) { + timeoutFuture.cancel(false); + timeoutFuture = null; + } + inOutage = false; + } + } + } + + private void recordError(ErrorInfo newError) { + // Accumulate how many times each kind of error has occurred during the outage - use just the basic + // properties as the key so the map won't expand indefinitely + ErrorInfo basicErrorInfo = new ErrorInfo(newError.getKind(), newError.getStatusCode(), null, null); + LDClient.logger.warn("recordError(" + basicErrorInfo + ")"); + errorCounts.compute(basicErrorInfo, (key, oldValue) -> oldValue == null ? 1 : oldValue.intValue() + 1); + } + + private void onTimeout() { + String errorsDesc; + synchronized (this) { + if (timeoutFuture == null || !inOutage) { + return; + } + timeoutFuture = null; + errorsDesc = Joiner.on(", ").join(transform(errorCounts.entrySet(), OutageTracker::describeErrorCount)); + } + LDClient.logger.error( + "LaunchDarkly data source outage - updates have been unavailable for at least {} with the following errors: {}", + Util.describeDuration(loggingTimeout), + errorsDesc + ); + } + + private static String describeErrorCount(Map.Entry entry) { + return entry.getKey() + " (" + entry.getValue() + (entry.getValue() == 1 ? " time" : " times") + ")"; + } + } } diff --git a/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java index 9cacbc546..e5c2b8ada 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java @@ -31,9 +31,10 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; +import static com.launchdarkly.sdk.server.Util.checkIfErrorIsRecoverableAndLog; import static com.launchdarkly.sdk.server.Util.configureHttpClientBuilder; import static com.launchdarkly.sdk.server.Util.getHeadersBuilderFor; -import static com.launchdarkly.sdk.server.Util.httpErrorMessage; +import static com.launchdarkly.sdk.server.Util.httpErrorDescription; import static com.launchdarkly.sdk.server.Util.isHttpErrorRecoverable; import static com.launchdarkly.sdk.server.Util.shutdownHttpClient; @@ -50,6 +51,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"; private static final MediaType JSON_CONTENT_TYPE = MediaType.parse("application/json; charset=utf-8"); + private static final String WILL_RETRY_MESSAGE = "will retry"; @VisibleForTesting final EventDispatcher dispatcher; private final BlockingQueue inbox; @@ -483,12 +485,16 @@ 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")); + boolean recoverable = isHttpErrorRecoverable(response.code()); + String errorDesc = httpErrorDescription(response.code()); + if (recoverable) { + logger.warn("Error in posting events (some events were dropped): {}", errorDesc); // 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. + } else { + disabled.set(true); + logger.error("Error in posting events (giving up permanently): {}", errorDesc); } } } @@ -517,8 +523,9 @@ private static void postJson(OkHttpClient httpClient, Headers headers, String js 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())) { + boolean recoverable = checkIfErrorIsRecoverableAndLog(logger, httpErrorDescription(response.code()), + "posting " + descriptor, response.code(), WILL_RETRY_MESSAGE); + if (recoverable) { continue; } } @@ -538,8 +545,7 @@ 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.toString()); - logger.debug(e.toString(), e); + checkIfErrorIsRecoverableAndLog(logger, e.toString(), "posting " + descriptor, 0, WILL_RETRY_MESSAGE); continue; } } diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClient.java b/src/main/java/com/launchdarkly/sdk/server/LDClient.java index 512d3bb9c..f5c170627 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClient.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClient.java @@ -9,6 +9,7 @@ import com.launchdarkly.sdk.server.interfaces.DataSource; import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; @@ -64,6 +65,7 @@ public final class LDClient implements LDClientInterface { final EventProcessor eventProcessor; final DataSource dataSource; final DataStore dataStore; + private final DataSourceUpdates dataSourceUpdates; private final DataStoreStatusProviderImpl dataStoreStatusProvider; private final DataSourceStatusProviderImpl dataSourceStatusProvider; private final EventBroadcasterImpl flagChangeEventNotifier; @@ -186,8 +188,11 @@ public DataModel.Segment getSegment(String key) { dataStore, dataStoreStatusProvider, flagChangeEventNotifier, - dataSourceStatusNotifier + dataSourceStatusNotifier, + sharedExecutor, + config.loggingConfig.getLogDataSourceOutageAsErrorAfter() ); + this.dataSourceUpdates = dataSourceUpdates; this.dataSource = dataSourceFactory.createDataSource(context, dataSourceUpdates); this.dataSourceStatusProvider = new DataSourceStatusProviderImpl(dataSourceStatusNotifier, dataSourceUpdates::getLastStatus); @@ -477,6 +482,7 @@ public void close() throws IOException { this.dataStore.close(); this.eventProcessor.close(); this.dataSource.close(); + this.dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.OFF, null); this.sharedExecutor.shutdownNow(); } diff --git a/src/main/java/com/launchdarkly/sdk/server/LDConfig.java b/src/main/java/com/launchdarkly/sdk/server/LDConfig.java index 774358dd9..b9369aaca 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDConfig.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDConfig.java @@ -6,6 +6,8 @@ import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; import com.launchdarkly.sdk.server.interfaces.HttpConfigurationFactory; +import com.launchdarkly.sdk.server.interfaces.LoggingConfiguration; +import com.launchdarkly.sdk.server.interfaces.LoggingConfigurationFactory; import java.net.URI; import java.time.Duration; @@ -27,6 +29,7 @@ public final class LDConfig { final boolean diagnosticOptOut; final EventProcessorFactory eventProcessorFactory; final HttpConfiguration httpConfig; + final LoggingConfiguration loggingConfig; final boolean offline; final Duration startWait; final int threadPriority; @@ -39,6 +42,8 @@ protected LDConfig(Builder builder) { this.httpConfig = builder.httpConfigFactory == null ? Components.httpConfiguration().createHttpConfiguration() : builder.httpConfigFactory.createHttpConfiguration(); + this.loggingConfig = (builder.loggingConfigFactory == null ? Components.logging() : builder.loggingConfigFactory). + createLoggingConfiguration(); this.offline = builder.offline; this.startWait = builder.startWait; this.threadPriority = builder.threadPriority; @@ -50,6 +55,7 @@ protected LDConfig(Builder builder) { this.diagnosticOptOut = config.diagnosticOptOut; this.eventProcessorFactory = config.eventProcessorFactory; this.httpConfig = config.httpConfig; + this.loggingConfig = config.loggingConfig; this.offline = config.offline; this.startWait = config.startWait; this.threadPriority = config.threadPriority; @@ -72,6 +78,7 @@ public static class Builder { private boolean diagnosticOptOut = false; private EventProcessorFactory eventProcessorFactory = null; private HttpConfigurationFactory httpConfigFactory = null; + private LoggingConfigurationFactory loggingConfigFactory = null; private boolean offline = false; private Duration startWait = DEFAULT_START_WAIT; private int threadPriority = Thread.MIN_PRIORITY; @@ -165,6 +172,21 @@ public Builder http(HttpConfigurationFactory factory) { this.httpConfigFactory = factory; return this; } + + /** + * Sets the SDK's logging configuration, using a factory object. This object is normally a + * configuration builder obtained from {@link Components#logging()}, which has methods + * for setting individual logging-related properties. + * + * @param factory the factory object + * @return the builder + * @since 5.0.0 + * @see Components#logging() + */ + public Builder logging(LoggingConfigurationFactory factory) { + this.loggingConfigFactory = factory; + return this; + } /** * Set whether this client is offline. diff --git a/src/main/java/com/launchdarkly/sdk/server/LoggingConfigurationImpl.java b/src/main/java/com/launchdarkly/sdk/server/LoggingConfigurationImpl.java new file mode 100644 index 000000000..f3ce2c872 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/LoggingConfigurationImpl.java @@ -0,0 +1,18 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.interfaces.LoggingConfiguration; + +import java.time.Duration; + +final class LoggingConfigurationImpl implements LoggingConfiguration { + private final Duration logDataSourceOutageAsErrorAfter; + + LoggingConfigurationImpl(Duration logDataSourceOutageAsErrorAfter) { + this.logDataSourceOutageAsErrorAfter = logDataSourceOutageAsErrorAfter; + } + + @Override + public Duration getLogDataSourceOutageAsErrorAfter() { + return logDataSourceOutageAsErrorAfter; + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java b/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java index 07883cd01..76fccaa00 100644 --- a/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java @@ -20,11 +20,13 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; -import static com.launchdarkly.sdk.server.Util.httpErrorMessage; -import static com.launchdarkly.sdk.server.Util.isHttpErrorRecoverable; +import static com.launchdarkly.sdk.server.Util.checkIfErrorIsRecoverableAndLog; +import static com.launchdarkly.sdk.server.Util.httpErrorDescription; final class PollingProcessor implements DataSource { private static final Logger logger = LoggerFactory.getLogger(PollingProcessor.class); + private static final String ERROR_CONTEXT_MESSAGE = "on polling request"; + private static final String WILL_RETRY_MESSAGE = "will retry at next scheduled poll interval"; @VisibleForTesting final FeatureRequestor requestor; private final DataSourceUpdates dataSourceUpdates; @@ -90,16 +92,16 @@ private void poll() { allData = requestor.getAllData(); } catch (HttpErrorException e) { ErrorInfo errorInfo = ErrorInfo.fromHttpError(e.getStatus()); - logger.error(httpErrorMessage(e.getStatus(), "polling request", "will retry")); - if (isHttpErrorRecoverable(e.getStatus())) { + boolean recoverable = checkIfErrorIsRecoverableAndLog(logger, httpErrorDescription(e.getStatus()), + ERROR_CONTEXT_MESSAGE, e.getStatus(), WILL_RETRY_MESSAGE); + if (recoverable) { dataSourceUpdates.updateStatus(State.INTERRUPTED, errorInfo); } else { dataSourceUpdates.updateStatus(State.OFF, errorInfo); initFuture.complete(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.toString()); - logger.debug(e.toString(), e); + checkIfErrorIsRecoverableAndLog(logger, e.toString(), ERROR_CONTEXT_MESSAGE, 0, WILL_RETRY_MESSAGE); dataSourceUpdates.updateStatus(State.INTERRUPTED, ErrorInfo.fromException(ErrorKind.NETWORK_ERROR, e)); } catch (SerializationException e) { logger.error("Polling request received malformed data: {}", e.toString()); diff --git a/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java index 04fc8455f..ea9d80a12 100644 --- a/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java @@ -36,10 +36,10 @@ import static com.launchdarkly.sdk.server.DataModel.ALL_DATA_KINDS; import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; +import static com.launchdarkly.sdk.server.Util.checkIfErrorIsRecoverableAndLog; import static com.launchdarkly.sdk.server.Util.configureHttpClientBuilder; import static com.launchdarkly.sdk.server.Util.getHeadersBuilderFor; -import static com.launchdarkly.sdk.server.Util.httpErrorMessage; -import static com.launchdarkly.sdk.server.Util.isHttpErrorRecoverable; +import static com.launchdarkly.sdk.server.Util.httpErrorDescription; import okhttp3.Headers; import okhttp3.OkHttpClient; @@ -76,6 +76,8 @@ final class StreamProcessor implements DataSource { private static final String INDIRECT_PATCH = "indirect/patch"; private static final Logger logger = LoggerFactory.getLogger(StreamProcessor.class); private static final Duration DEAD_CONNECTION_INTERVAL = Duration.ofSeconds(300); + private static final String ERROR_CONTEXT_MESSAGE = "in stream connection"; + private static final String WILL_RETRY_MESSAGE = "will retry"; private final DataSourceUpdates dataSourceUpdates; private final HttpConfiguration httpConfig; @@ -171,21 +173,21 @@ private ConnectionErrorHandler createDefaultConnectionErrorHandler() { if (t instanceof UnsuccessfulResponseException) { int status = ((UnsuccessfulResponseException)t).getCode(); - - logger.error(httpErrorMessage(status, "streaming connection", "will retry")); - ErrorInfo errorInfo = ErrorInfo.fromHttpError(status); - - if (!isHttpErrorRecoverable(status)) { + + boolean recoverable = checkIfErrorIsRecoverableAndLog(logger, httpErrorDescription(status), + ERROR_CONTEXT_MESSAGE, status, WILL_RETRY_MESSAGE); + if (recoverable) { + dataSourceUpdates.updateStatus(State.INTERRUPTED, errorInfo); + esStarted = System.currentTimeMillis(); + return Action.PROCEED; + } else { dataSourceUpdates.updateStatus(State.OFF, errorInfo); - return Action.SHUTDOWN; + return Action.SHUTDOWN; } - - dataSourceUpdates.updateStatus(State.INTERRUPTED, errorInfo); - esStarted = System.currentTimeMillis(); - return Action.PROCEED; } + checkIfErrorIsRecoverableAndLog(logger, t.toString(), ERROR_CONTEXT_MESSAGE, 0, WILL_RETRY_MESSAGE); ErrorInfo errorInfo = ErrorInfo.fromException(t instanceof IOException ? ErrorKind.NETWORK_ERROR : ErrorKind.UNKNOWN, t); dataSourceUpdates.updateStatus(State.INTERRUPTED, errorInfo); return Action.PROCEED; diff --git a/src/main/java/com/launchdarkly/sdk/server/Util.java b/src/main/java/com/launchdarkly/sdk/server/Util.java index b77811c61..881e07eac 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Util.java +++ b/src/main/java/com/launchdarkly/sdk/server/Util.java @@ -3,7 +3,10 @@ import com.launchdarkly.sdk.server.interfaces.HttpAuthentication; import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; +import org.slf4j.Logger; + import java.io.IOException; +import java.time.Duration; import java.util.concurrent.TimeUnit; import static com.google.common.collect.Iterables.transform; @@ -106,22 +109,46 @@ static boolean isHttpErrorRecoverable(int statusCode) { } /** - * Builds an appropriate log message for an HTTP error status. - * @param statusCode the HTTP status - * @param context description of what we were trying to do - * @param recoverableMessage description of our behavior if the error is recoverable; typically "will retry" - * @return a message string + * Logs an HTTP error or network error at the appropriate level and determines whether it is recoverable + * (as defined by {@link #isHttpErrorRecoverable(int)}). + * + * @param logger the logger to log to + * @param errorDesc description of the error + * @param errorContext a phrase like "when doing such-and-such" + * @param statusCode HTTP status code, or 0 for a network error + * @param recoverableMessage a phrase like "will retry" to use if the error is recoverable + * @return true if the error is recoverable */ - static String httpErrorMessage(int statusCode, String context, String recoverableMessage) { - StringBuilder sb = new StringBuilder(); - sb.append("Received HTTP error ").append(statusCode); - switch (statusCode) { - case 401: - case 403: - sb.append(" (invalid SDK key)"); + static boolean checkIfErrorIsRecoverableAndLog( + Logger logger, + String errorDesc, + String errorContext, + int statusCode, + String recoverableMessage + ) { + if (statusCode > 0 && !isHttpErrorRecoverable(statusCode)) { + logger.error("Error {} (giving up permanently): {}", errorContext, errorDesc); + return false; + } else { + logger.warn("Error {} ({}): {}", errorContext, recoverableMessage, errorDesc); + return true; + } + } + + static String httpErrorDescription(int statusCode) { + return "HTTP error " + statusCode + + (statusCode == 401 || statusCode == 403 ? " (invalid SDK key)" : ""); + } + + static String describeDuration(Duration d) { + if (d.toMillis() % 1000 == 0) { + if (d.toMillis() % 60000 == 0) { + return d.toMinutes() + (d.toMinutes() == 1 ? " minute" : " minutes"); + } else { + long sec = d.toMillis() / 1000; + return sec + (sec == 1 ? " second" : " seconds"); + } } - sb.append(" for ").append(context).append(" - "); - sb.append(isHttpErrorRecoverable(statusCode) ? recoverableMessage : "giving up permanently"); - return sb.toString(); + return d.toMillis() + " milliseconds"; } } diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/LoggingConfigurationBuilder.java b/src/main/java/com/launchdarkly/sdk/server/integrations/LoggingConfigurationBuilder.java new file mode 100644 index 000000000..9816f8962 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/LoggingConfigurationBuilder.java @@ -0,0 +1,57 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.interfaces.LoggingConfigurationFactory; + +import java.time.Duration; + +/** + * Contains methods for configuring the SDK's logging behavior. + *

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

    
    + *     LDConfig config = new LDConfig.Builder()
    + *         .logging(
    + *           Components.logging()
    + *             .logDataSourceOutageAsErrorAfter(Duration.ofSeconds(120))
    + *          )
    + *         .build();
    + * 
    + *

    + * Note that this class is abstract; the actual implementation is created by calling {@link Components#logging()}. + * + * @since 5.0.0 + */ +public abstract class LoggingConfigurationBuilder implements LoggingConfigurationFactory { + /** + * The default value for {@link #logDataSourceOutageAsErrorAfter(Duration)}: one minute. + */ + public static final Duration DEFAULT_LOG_DATA_SOURCE_OUTAGE_AS_ERROR_AFTER = Duration.ofMinutes(1); + + protected Duration logDataSourceOutageAsErrorAfter = DEFAULT_LOG_DATA_SOURCE_OUTAGE_AS_ERROR_AFTER; + + /** + * Sets the time threshold, if any, after which the SDK will log a data source outage at {@code ERROR} + * level instead of {@code WARN} level. + *

    + * A data source outage means that an error condition, such as a network interruption or an error from + * the LaunchDarkly service, is preventing the SDK from receiving feature flag updates. Many outages are + * brief and the SDK can recover from them quickly; in that case it may be undesirable to log an + * {@code ERROR} line, which might trigger an unwanted automated alert depending on your monitoring + * tools. So, by default, the SDK logs such errors at {@code WARN} level. However, if the amount of time + * specified by this method elapses before the data source starts working again, the SDK will log an + * additional message at {@code ERROR} level to indicate that this is a sustained problem. + *

    + * The default is {@link #DEFAULT_LOG_DATA_SOURCE_OUTAGE_AS_ERROR_AFTER}. Setting it to {@code null} + * will disable this feature, so you will only get {@code WARN} messages. + * + * @param logDataSourceOutageAsErrorAfter the error logging threshold, or null + * @return the builder + */ + public LoggingConfigurationBuilder logDataSourceOutageAsErrorAfter(Duration logDataSourceOutageAsErrorAfter) { + this.logDataSourceOutageAsErrorAfter = logDataSourceOutageAsErrorAfter; + return this; + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/ClientContext.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/ClientContext.java index 93e2f828a..5320a9eb5 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/ClientContext.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/ClientContext.java @@ -37,6 +37,12 @@ public interface ClientContext { */ public HttpConfiguration getHttpConfiguration(); + /** + * The configured logging properties that apply to all components. + * @return the logging configuration + */ + public LoggingConfiguration getLoggingConfiguration(); + /** * The thread priority that should be used for any worker threads created by SDK components. * diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProvider.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProvider.java index 8bb9cd241..e103a44e2 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProvider.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProvider.java @@ -1,5 +1,7 @@ package com.launchdarkly.sdk.server.interfaces; +import com.google.common.base.Strings; + import java.time.Instant; import java.util.Objects; @@ -226,7 +228,26 @@ public int hashCode() { @Override public String toString() { - return "ErrorInfo(" + kind + "," + statusCode + "," + message + "," + time + ")"; + StringBuilder s = new StringBuilder(); + s.append(kind.toString()); + if (statusCode > 0 || !Strings.isNullOrEmpty(message)) { + s.append("("); + if (statusCode > 0) { + s.append(statusCode); + } + if (!Strings.isNullOrEmpty(message)) { + if (statusCode > 0) { + s.append(","); + } + s.append(message); + } + s.append(")"); + } + if (time != null) { + s.append("@"); + s.append(time.toString()); + } + return s.toString(); } } diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProvider.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProvider.java index b117510f4..35d602e52 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProvider.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProvider.java @@ -8,10 +8,6 @@ * An interface for querying the status of a persistent data store. *

    * An implementation of this interface is returned by {@link com.launchdarkly.sdk.server.LDClientInterface#getDataStoreStatusProvider}. - * If the data store is a persistent data store, then these methods are implemented by the SDK; if it is a custom - * class that implements this interface, then these methods delegate to the corresponding methods of the class; - * if it is the default in-memory data store, then these methods do nothing and return null values. - *

    * Application code should not implement this interface. * * @since 5.0.0 diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/LoggingConfiguration.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/LoggingConfiguration.java new file mode 100644 index 000000000..695c02f7d --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/LoggingConfiguration.java @@ -0,0 +1,23 @@ +package com.launchdarkly.sdk.server.interfaces; + +import com.launchdarkly.sdk.server.integrations.LoggingConfigurationBuilder; + +import java.time.Duration; + +/** + * Encapsulates the SDK's general logging configuration. + *

    + * Use {@link LoggingConfigurationFactory} to construct an instance. + * + * @since 5.0.0 + */ +public interface LoggingConfiguration { + /** + * The time threshold, if any, after which the SDK will log a data source outage at {@code ERROR} + * level instead of {@code WARN} level. + * + * @return the error logging threshold, or null + * @see LoggingConfigurationBuilder#logDataSourceOutageAsErrorAfter(java.time.Duration) + */ + Duration getLogDataSourceOutageAsErrorAfter(); +} diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/LoggingConfigurationFactory.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/LoggingConfigurationFactory.java new file mode 100644 index 000000000..54f70bfc0 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/LoggingConfigurationFactory.java @@ -0,0 +1,16 @@ +package com.launchdarkly.sdk.server.interfaces; + +/** + * Interface for a factory that creates an {@link LoggingConfiguration}. + * + * @see com.launchdarkly.sdk.server.Components#logging() + * @see com.launchdarkly.sdk.server.LDConfig.Builder#logging(LoggingConfigurationFactory) + * @since 5.0.0 + */ +public interface LoggingConfigurationFactory { + /** + * Creates the configuration object. + * @return a {@link LoggingConfiguration} + */ + public LoggingConfiguration createLoggingConfiguration(); +} diff --git a/src/test/java/com/launchdarkly/sdk/server/DataSourceUpdatesImplTest.java b/src/test/java/com/launchdarkly/sdk/server/DataSourceUpdatesImplTest.java index 323b38211..cc6602dad 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataSourceUpdatesImplTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataSourceUpdatesImplTest.java @@ -31,6 +31,7 @@ import static com.launchdarkly.sdk.server.ModelBuilders.ruleBuilder; import static com.launchdarkly.sdk.server.ModelBuilders.segmentBuilder; import static com.launchdarkly.sdk.server.TestComponents.inMemoryDataStore; +import static com.launchdarkly.sdk.server.TestComponents.sharedExecutor; import static org.easymock.EasyMock.replay; import static org.junit.Assert.assertEquals; import static org.junit.Assert.fail; @@ -43,6 +44,10 @@ public class DataSourceUpdatesImplTest extends EasyMockSupport { private EventBroadcasterImpl flagChangeBroadcaster = EventBroadcasterImpl.forFlagChangeEvents(TestComponents.sharedExecutor); + private DataSourceUpdatesImpl makeInstance(DataStore store) { + return new DataSourceUpdatesImpl(store, null, flagChangeBroadcaster, null, sharedExecutor, null); + } + @Test public void sendsEventsOnInitForNewlyAddedFlags() throws Exception { DataStore store = inMemoryDataStore(); @@ -52,7 +57,7 @@ public void sendsEventsOnInitForNewlyAddedFlags() throws Exception { .addAny(SEGMENTS, segmentBuilder("segment1").version(1).build()); - DataSourceUpdatesImpl storeUpdates = new DataSourceUpdatesImpl(store, null, flagChangeBroadcaster, null); + DataSourceUpdatesImpl storeUpdates = makeInstance(store); storeUpdates.init(builder.build()); @@ -77,7 +82,7 @@ public void sendsEventOnUpdateForNewlyAddedFlag() throws Exception { .addAny(SEGMENTS, segmentBuilder("segment1").version(1).build()); - DataSourceUpdatesImpl storeUpdates = new DataSourceUpdatesImpl(store, null, flagChangeBroadcaster, null); + DataSourceUpdatesImpl storeUpdates = makeInstance(store); storeUpdates.init(builder.build()); @@ -100,7 +105,7 @@ public void sendsEventsOnInitForUpdatedFlags() throws Exception { segmentBuilder("segment1").version(1).build(), segmentBuilder("segment2").version(1).build()); - DataSourceUpdatesImpl storeUpdates = new DataSourceUpdatesImpl(store, null, flagChangeBroadcaster, null); + DataSourceUpdatesImpl storeUpdates = makeInstance(store); storeUpdates.init(builder.build()); @@ -124,7 +129,7 @@ public void sendsEventOnUpdateForUpdatedFlag() throws Exception { .addAny(SEGMENTS, segmentBuilder("segment1").version(1).build()); - DataSourceUpdatesImpl storeUpdates = new DataSourceUpdatesImpl(store, null, flagChangeBroadcaster, null); + DataSourceUpdatesImpl storeUpdates = makeInstance(store); storeUpdates.init(builder.build()); @@ -146,7 +151,7 @@ public void sendsEventsOnInitForDeletedFlags() throws Exception { .addAny(SEGMENTS, segmentBuilder("segment1").version(1).build()); - DataSourceUpdatesImpl storeUpdates = new DataSourceUpdatesImpl(store, null, flagChangeBroadcaster, null); + DataSourceUpdatesImpl storeUpdates = makeInstance(store); storeUpdates.init(builder.build()); @@ -172,7 +177,7 @@ public void sendsEventOnUpdateForDeletedFlag() throws Exception { .addAny(SEGMENTS, segmentBuilder("segment1").version(1).build()); - DataSourceUpdatesImpl storeUpdates = new DataSourceUpdatesImpl(store, null, flagChangeBroadcaster, null); + DataSourceUpdatesImpl storeUpdates = makeInstance(store); storeUpdates.init(builder.build()); @@ -196,7 +201,7 @@ public void sendsEventsOnInitForFlagsWhosePrerequisitesChanged() throws Exceptio flagBuilder("flag5").version(1).prerequisites(prerequisite("flag4", 0)).build(), flagBuilder("flag6").version(1).build()); - DataSourceUpdatesImpl storeUpdates = new DataSourceUpdatesImpl(store, null, flagChangeBroadcaster, null); + DataSourceUpdatesImpl storeUpdates = makeInstance(store); storeUpdates.init(builder.build()); @@ -221,7 +226,7 @@ public void sendsEventsOnUpdateForFlagsWhosePrerequisitesChanged() throws Except flagBuilder("flag5").version(1).prerequisites(prerequisite("flag4", 0)).build(), flagBuilder("flag6").version(1).build()); - DataSourceUpdatesImpl storeUpdates = new DataSourceUpdatesImpl(store, null, flagChangeBroadcaster, null); + DataSourceUpdatesImpl storeUpdates = makeInstance(store); storeUpdates.init(builder.build()); @@ -250,7 +255,7 @@ public void sendsEventsOnInitForFlagsWhoseSegmentsChanged() throws Exception { segmentBuilder("segment1").version(1).build(), segmentBuilder("segment2").version(1).build()); - DataSourceUpdatesImpl storeUpdates = new DataSourceUpdatesImpl(store, null, flagChangeBroadcaster, null); + DataSourceUpdatesImpl storeUpdates = makeInstance(store); storeUpdates.init(builder.build()); @@ -279,7 +284,7 @@ public void sendsEventsOnUpdateForFlagsWhoseSegmentsChanged() throws Exception { segmentBuilder("segment1").version(1).build(), segmentBuilder("segment2").version(1).build()); - DataSourceUpdatesImpl storeUpdates = new DataSourceUpdatesImpl(store, null, flagChangeBroadcaster, null); + DataSourceUpdatesImpl storeUpdates = makeInstance(store); storeUpdates.init(builder.build()); @@ -303,7 +308,7 @@ public void dataSetIsPassedToDataStoreInCorrectOrder() throws Exception { store.init(EasyMock.capture(captureData)); replay(store); - DataSourceUpdatesImpl storeUpdates = new DataSourceUpdatesImpl(store, null, flagChangeBroadcaster, null); + DataSourceUpdatesImpl storeUpdates = makeInstance(store); storeUpdates.init(DEPENDENCY_ORDERING_TEST_DATA); Map> dataMap = toDataMap(captureData.getValue()); diff --git a/src/test/java/com/launchdarkly/sdk/server/TestComponents.java b/src/test/java/com/launchdarkly/sdk/server/TestComponents.java index 341ddd741..aa791132b 100644 --- a/src/test/java/com/launchdarkly/sdk/server/TestComponents.java +++ b/src/test/java/com/launchdarkly/sdk/server/TestComponents.java @@ -201,7 +201,9 @@ public MockDataSourceUpdates(DataStore store, DataStoreStatusProvider dataStoreS store, dataStoreStatusProvider, flagChangeEventBroadcaster, - statusBroadcaster + statusBroadcaster, + sharedExecutor, + null ); } diff --git a/src/test/java/com/launchdarkly/sdk/server/UtilTest.java b/src/test/java/com/launchdarkly/sdk/server/UtilTest.java index ef7ffed7f..93c097c23 100644 --- a/src/test/java/com/launchdarkly/sdk/server/UtilTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/UtilTest.java @@ -37,4 +37,15 @@ public void testSocketTimeout() { shutdownHttpClient(httpClient); } } + + @Test + public void describeDuration() { + assertEquals("15 milliseconds", Util.describeDuration(Duration.ofMillis(15))); + assertEquals("1500 milliseconds", Util.describeDuration(Duration.ofMillis(1500))); + assertEquals("1 second", Util.describeDuration(Duration.ofMillis(1000))); + assertEquals("2 seconds", Util.describeDuration(Duration.ofMillis(2000))); + assertEquals("70 seconds", Util.describeDuration(Duration.ofMillis(70000))); + assertEquals("1 minute", Util.describeDuration(Duration.ofMillis(60000))); + assertEquals("2 minutes", Util.describeDuration(Duration.ofMillis(120000))); + } } diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/LoggingConfigurationBuilderTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/LoggingConfigurationBuilderTest.java new file mode 100644 index 000000000..eef9e5dae --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/LoggingConfigurationBuilderTest.java @@ -0,0 +1,34 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.interfaces.LoggingConfiguration; + +import org.junit.Test; + +import java.time.Duration; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +@SuppressWarnings("javadoc") +public class LoggingConfigurationBuilderTest { + @Test + public void testDefaults() { + LoggingConfiguration c = Components.logging().createLoggingConfiguration(); + assertEquals(LoggingConfigurationBuilder.DEFAULT_LOG_DATA_SOURCE_OUTAGE_AS_ERROR_AFTER, + c.getLogDataSourceOutageAsErrorAfter()); + } + + @Test + public void logDataSourceOutageAsErrorAfter() { + LoggingConfiguration c1 = Components.logging() + .logDataSourceOutageAsErrorAfter(Duration.ofMinutes(9)) + .createLoggingConfiguration(); + assertEquals(Duration.ofMinutes(9), c1.getLogDataSourceOutageAsErrorAfter()); + + LoggingConfiguration c2 = Components.logging() + .logDataSourceOutageAsErrorAfter(null) + .createLoggingConfiguration(); + assertNull(c2.getLogDataSourceOutageAsErrorAfter()); + } +} diff --git a/src/test/resources/logback.xml b/src/test/resources/logback.xml index 757bb2429..6be0de84e 100644 --- a/src/test/resources/logback.xml +++ b/src/test/resources/logback.xml @@ -7,7 +7,7 @@ - + From 40d45fab1167f7ed9beafb91350847fd6b274af8 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 12 May 2020 15:54:40 -0700 Subject: [PATCH 431/673] 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 432/673] 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 433/673] 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 434/673] 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 6718e4cdb4f0a894b2b0a2b135e2e5e649e201df Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 13 May 2020 18:00:52 -0700 Subject: [PATCH 435/673] prepare 5.0.0-rc2 release (#194) --- CHANGELOG.md | 21 + build.gradle | 11 +- gradle.properties | 2 +- packaging-test/test-app/build.gradle | 7 + .../sdk/server/ClientContextImpl.java | 102 ++- .../launchdarkly/sdk/server/Components.java | 91 +- .../server/DataSourceStatusProviderImpl.java | 31 + .../sdk/server/DataSourceUpdatesImpl.java | 319 +++++++ .../server/DataStoreStatusProviderImpl.java | 30 +- .../sdk/server/DataStoreUpdatesImpl.java | 154 +--- .../sdk/server/DefaultEventProcessor.java | 240 ++--- .../sdk/server/DefaultEventSender.java | 165 ++++ .../sdk/server/EventBroadcasterImpl.java | 99 ++ .../sdk/server/EventsConfiguration.java | 20 +- .../sdk/server/FlagChangeEventPublisher.java | 58 -- .../sdk/server/InMemoryDataStore.java | 13 +- .../com/launchdarkly/sdk/server/LDClient.java | 125 ++- .../sdk/server/LDClientInterface.java | 14 + .../com/launchdarkly/sdk/server/LDConfig.java | 45 + .../sdk/server/LoggingConfigurationImpl.java | 18 + .../PersistentDataStoreStatusManager.java | 74 +- .../PersistentDataStoreWrapper.java | 57 +- .../sdk/server/PollingProcessor.java | 119 ++- .../sdk/server/StreamProcessor.java | 118 +-- .../com/launchdarkly/sdk/server/Util.java | 57 +- .../integrations/EventProcessorBuilder.java | 18 + .../integrations/FileDataSourceBuilder.java | 6 +- .../integrations/FileDataSourceImpl.java | 21 +- .../LoggingConfigurationBuilder.java | 57 ++ .../PersistentDataStoreBuilder.java | 22 +- .../sdk/server/interfaces/ClientContext.java | 30 +- .../sdk/server/interfaces/DataSource.java | 13 + .../server/interfaces/DataSourceFactory.java | 7 +- .../interfaces/DataSourceStatusProvider.java | 351 ++++++++ .../server/interfaces/DataSourceUpdates.java | 81 ++ .../sdk/server/interfaces/DataStore.java | 25 + .../server/interfaces/DataStoreFactory.java | 4 +- .../interfaces/DataStoreStatusProvider.java | 45 +- .../sdk/server/interfaces/DataStoreTypes.java | 23 + .../server/interfaces/DataStoreUpdates.java | 49 +- .../sdk/server/interfaces/EventSender.java | 94 ++ .../server/interfaces/EventSenderFactory.java | 18 + .../interfaces/LoggingConfiguration.java | 23 + .../LoggingConfigurationFactory.java | 16 + .../interfaces/PersistentDataStore.java | 10 +- .../sdk/server/interfaces/package-info.java | 9 +- ...st.java => DataSourceUpdatesImplTest.java} | 230 +++-- .../sdk/server/DefaultEventProcessorTest.java | 852 +++++++----------- .../sdk/server/DefaultEventSenderTest.java | 339 +++++++ .../sdk/server/DiagnosticEventTest.java | 3 +- .../sdk/server/EventBroadcasterImplTest.java | 82 ++ .../sdk/server/LDClientEndToEndTest.java | 46 +- .../LDClientExternalUpdatesOnlyTest.java | 3 + .../sdk/server/LDClientListenersTest.java | 277 ++++++ .../sdk/server/LDClientOfflineTest.java | 8 +- .../launchdarkly/sdk/server/LDClientTest.java | 111 +-- .../PersistentDataStoreWrapperTest.java | 62 +- .../sdk/server/PollingProcessorTest.java | 174 +++- .../sdk/server/StreamProcessorTest.java | 196 ++-- .../sdk/server/TestComponents.java | 236 +++-- .../com/launchdarkly/sdk/server/TestUtil.java | 70 ++ .../com/launchdarkly/sdk/server/UtilTest.java | 11 + .../integrations/FileDataSourceTest.java | 85 +- .../LoggingConfigurationBuilderTest.java | 34 + .../integrations/MockPersistentDataStore.java | 16 +- src/test/resources/logback.xml | 2 +- 66 files changed, 4073 insertions(+), 1676 deletions(-) create mode 100644 src/main/java/com/launchdarkly/sdk/server/DataSourceStatusProviderImpl.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/DefaultEventSender.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/EventBroadcasterImpl.java delete mode 100644 src/main/java/com/launchdarkly/sdk/server/FlagChangeEventPublisher.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/LoggingConfigurationImpl.java rename src/main/java/com/launchdarkly/sdk/server/{integrations => }/PersistentDataStoreStatusManager.java (53%) rename src/main/java/com/launchdarkly/sdk/server/{integrations => }/PersistentDataStoreWrapper.java (91%) create mode 100644 src/main/java/com/launchdarkly/sdk/server/integrations/LoggingConfigurationBuilder.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProvider.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceUpdates.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/interfaces/EventSender.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/interfaces/EventSenderFactory.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/interfaces/LoggingConfiguration.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/interfaces/LoggingConfigurationFactory.java rename src/test/java/com/launchdarkly/sdk/server/{DataStoreUpdatesImplTest.java => DataSourceUpdatesImplTest.java} (62%) create mode 100644 src/test/java/com/launchdarkly/sdk/server/DefaultEventSenderTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/EventBroadcasterImplTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java rename src/test/java/com/launchdarkly/sdk/server/{integrations => }/PersistentDataStoreWrapperTest.java (92%) create mode 100644 src/test/java/com/launchdarkly/sdk/server/integrations/LoggingConfigurationBuilderTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 546d9decf..d31660fe9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ All notable changes to the LaunchDarkly Java SDK will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org). +## [5.0.0-rc2] - 2020-05-13 +The primary purpose of this second beta release is to introduce the new `DataSourceStatusProvider` API, which is the server-side equivalent to the "connection status" API that exists in LaunchDarkly's mobile SDKs. Other additions and changes since the previous beta release (5.0.0-rc1) are described below. See the [5.0.0-rc1 release notes](https://github.com/launchdarkly/java-server-sdk/releases/tag/5.0.0-rc1) for other changes since 4.13.0. + +### Added: +- `LDClient.getDataSourceStatusProvider()` is a status monitoring mechanism similar to `getDataStoreStatusProvider()`, but for the data source (streaming, polling, or file data). You can not only check the current connection status, but also choose to receive notifications when the status changes. +- `LDConfig.Builder.logging()` is a new configuration category for options related to logging. Currently the only such option is `escalateDataSourceOutageLoggingAfter`, which controls the new connection failure logging behavior described below. +- `LDConfig.Builder.threadPriority()` allows you to set the priority for worker threads created by the SDK. + +### Changed: +- Network failures and server errors for streaming or polling requests were previously logged at `ERROR` level in most cases but sometimes at `WARN` level. They are now all at `WARN` level, but with a new behavior: if connection failures continue without a successful retry for a certain amount of time, the SDK will log a special `ERROR`-level message to warn you that this is not just a brief outage. The amount of time is one minute by default, but can be changed with the new `logDataSourceOutageAsErrorAfter` option in `LoggingConfigurationBuilder`. +- The number of worker threads maintained by the SDK has been reduced so that most intermittent background tasks, such as listener notifications, event flush timers, and polling requests, are now dispatched on a single thread. The delivery of analytics events to LaunchDarkly still has its own thread pool because it is a heavier-weight task with greater need for concurrency. +- In polling mode, the poll requests previously ran on a dedicated worker thread that inherited its priority from the application thread that created the SDK. They are now on the SDK's main worker thread, which has `Thread.MIN_PRIORITY` by default (as all the other SDK threads already did) but the priority can be changed as described above. +- Only relevant for implementing custom components: The `DataStore` and `DataSource` interfaces, and their factories, have been changed to provide a more general mechanism for status reporting. This does not affect the part of a persistent data store implementation that is database-specific, so new beta releases of the Consul/DynamoDB/Redis libraries were not necessary. + ## [5.0.0-rc1] - 2020-04-29 This beta release is being made available for testing and user feedback, due to the large number of changes from Java SDK 4.x. Features are still subject to change in the final 5.0.0 release. Until the final release, the beta source code will be on the [5.x branch](https://github.com/launchdarkly/java-server-sdk/tree/5.x). Javadocs can be found on [javadoc.io](https://javadoc.io/doc/com.launchdarkly/launchdarkly-server-sdk/5.0.0-rc1/index.html). @@ -34,6 +48,13 @@ This is a major rewrite that introduces a cleaner API design, adds new features, If you want to test this release and you are using Consul, DynamoDB, or Redis as a persistent data store, you will also need to update to version 2.0.0-rc1 of the [Consul integration](https://github.com/launchdarkly/java-server-sdk-consul/tree/2.x), 3.0.0-rc1 of the [DynamoDB integration](https://github.com/launchdarkly/java-server-sdk-dynamodb/tree/3.x), or 1.0.0-rc1 of the [Redis integration](http://github.com/launchdarkly/java-server-sdk-redis) (previously the Redis integration was built in; now it is a separate module). +## [4.14.0] - 2020-05-13 +### Added: +- `EventSender` interface and `EventsConfigurationBuilder.eventSender()` allow you to specify a custom implementation of how event data is sent. This is mainly to facilitate testing, but could also be used to store and forward event data. + +### Fixed: +- Changed the Javadoc comments for the `LDClient` constructors to provide a better explanation of the client's initialization behavior. + ## [4.13.0] - 2020-04-21 ### Added: - The new methods `Components.httpConfiguration()` and `LDConfig.Builder.http()`, and the new class `HttpConfigurationBuilder`, provide a subcomponent configuration model that groups together HTTP-related options such as `connectTimeoutMillis` and `proxyHost` - similar to how `Components.streamingDataSource()` works for streaming-related options or `Components.sendEvents()` for event-related options. The individual `LDConfig.Builder` methods for those options will still work, but are deprecated and will be removed in version 5.0. diff --git a/build.gradle b/build.gradle index 863ec9204..ca071191e 100644 --- a/build.gradle +++ b/build.gradle @@ -72,7 +72,7 @@ ext.versions = [ "guava": "28.2-jre", "jackson": "2.10.0", "launchdarklyJavaSdkCommon": "1.0.0-rc1", - "okhttpEventsource": "2.1.0", + "okhttpEventsource": "2.2.0", "slf4j": "1.7.21", "snakeyaml": "1.19", "jedis": "2.9.0" @@ -326,11 +326,14 @@ def getFileFromClasspath(config, filePath) { } 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.sdk", - "Bundle-Version": version, + "Bundle-Version": implementationVersion, "Bundle-Name": "LaunchDarkly SDK", "Bundle-ManifestVersion": "2", "Bundle-Vendor": "LaunchDarkly" @@ -351,7 +354,7 @@ def addOsgiManifest(jarTask, List importConfigs, List bundleExport(p, a.moduleVersion.id.version) }) diff --git a/gradle.properties b/gradle.properties index e4d9755c4..3a580cfdb 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -version=5.0.0-rc1 +version=5.0.0-rc2 # 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/test-app/build.gradle b/packaging-test/test-app/build.gradle index 9673b74eb..b00af000f 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', 'Import-Package': 'com.launchdarkly.sdk,com.launchdarkly.sdk.json' + ',com.launchdarkly.sdk.server,org.slf4j' + diff --git a/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java b/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java index eae023c78..dac5b6d34 100644 --- a/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java @@ -2,18 +2,66 @@ import com.launchdarkly.sdk.server.interfaces.ClientContext; import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; +import com.launchdarkly.sdk.server.interfaces.LoggingConfiguration; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; + +/** + * This is the package-private implementation of {@link ClientContext} that contains additional non-public + * SDK objects that may be used by our internal components. + *

    + * All component factories, whether they are built-in ones or custom ones from the application, receive a + * {@link ClientContext} and can access its public properties. But only our built-in ones can see the + * package-private properties, which they can do by calling {@code ClientContextImpl.get(ClientContext)} + * to make sure that what they have is really a {@code ClientContextImpl} (as opposed to some other + * implementation of {@link ClientContext}, which might have been created for instance in application + * test code). + */ final class ClientContextImpl implements ClientContext { + private static volatile ScheduledExecutorService fallbackSharedExecutor = null; + private final String sdkKey; private final HttpConfiguration httpConfiguration; + private final LoggingConfiguration loggingConfiguration; private final boolean offline; - private final DiagnosticAccumulator diagnosticAccumulator; - private final DiagnosticEvent.Init diagnosticInitEvent; - - ClientContextImpl(String sdkKey, LDConfig configuration, DiagnosticAccumulator diagnosticAccumulator) { + private final int threadPriority; + final ScheduledExecutorService sharedExecutor; + final DiagnosticAccumulator diagnosticAccumulator; + final DiagnosticEvent.Init diagnosticInitEvent; + + private ClientContextImpl( + String sdkKey, + HttpConfiguration httpConfiguration, + LoggingConfiguration loggingConfiguration, + boolean offline, + int threadPriority, + ScheduledExecutorService sharedExecutor, + DiagnosticAccumulator diagnosticAccumulator, + DiagnosticEvent.Init diagnosticInitEvent + ) { + this.sdkKey = sdkKey; + this.httpConfiguration = httpConfiguration; + this.loggingConfiguration = loggingConfiguration; + this.offline = offline; + this.threadPriority = threadPriority; + this.sharedExecutor = sharedExecutor; + this.diagnosticAccumulator = diagnosticAccumulator; + this.diagnosticInitEvent = diagnosticInitEvent; + } + + ClientContextImpl( + String sdkKey, + LDConfig configuration, + ScheduledExecutorService sharedExecutor, + DiagnosticAccumulator diagnosticAccumulator + ) { this.sdkKey = sdkKey; this.httpConfiguration = configuration.httpConfig; + this.loggingConfiguration = configuration.loggingConfig; this.offline = configuration.offline; + this.threadPriority = configuration.threadPriority; + this.sharedExecutor = sharedExecutor; if (!configuration.diagnosticOptOut && diagnosticAccumulator != null) { this.diagnosticAccumulator = diagnosticAccumulator; this.diagnosticInitEvent = new DiagnosticEvent.Init(diagnosticAccumulator.dataSinceDate, diagnosticAccumulator.diagnosticId, configuration); @@ -37,28 +85,42 @@ public boolean isOffline() { public HttpConfiguration getHttpConfiguration() { return httpConfiguration; } - - // Note that the following two properties are package-private - they are only used by SDK internal components, - // not any custom components implemented by an application. - DiagnosticAccumulator getDiagnosticAccumulator() { - return diagnosticAccumulator; + + @Override + public LoggingConfiguration getLoggingConfiguration() { + return loggingConfiguration; } - DiagnosticEvent.Init getDiagnosticInitEvent() { - return diagnosticInitEvent; + @Override + public int getThreadPriority() { + return threadPriority; } - static DiagnosticAccumulator getDiagnosticAccumulator(ClientContext context) { + /** + * This mechanism is a convenience for internal components to access the package-private fields of the + * context if it is a ClientContextImpl, and to receive null values for those fields if it is not. + * The latter case should only happen in application test code where the application developer has no + * way to create our package-private ClientContextImpl. In that case, we also generate a temporary + * sharedExecutor so components can work correctly in tests. + */ + static ClientContextImpl get(ClientContext context) { if (context instanceof ClientContextImpl) { - return ((ClientContextImpl)context).getDiagnosticAccumulator(); + return (ClientContextImpl)context; } - return null; - } - - static DiagnosticEvent.Init getDiagnosticInitEvent(ClientContext context) { - if (context instanceof ClientContextImpl) { - return ((ClientContextImpl)context).getDiagnosticInitEvent(); + synchronized (ClientContextImpl.class) { + if (fallbackSharedExecutor == null) { + fallbackSharedExecutor = Executors.newSingleThreadScheduledExecutor(); + } } - return null; + return new ClientContextImpl( + context.getSdkKey(), + context.getHttpConfiguration(), + context.getLoggingConfiguration(), + context.isOffline(), + context.getThreadPriority(), + fallbackSharedExecutor, + null, + null + ); } } diff --git a/src/main/java/com/launchdarkly/sdk/server/Components.java b/src/main/java/com/launchdarkly/sdk/server/Components.java index 6ffdd37c7..43ca745f0 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Components.java +++ b/src/main/java/com/launchdarkly/sdk/server/Components.java @@ -5,12 +5,15 @@ import com.launchdarkly.sdk.server.DiagnosticEvent.ConfigProperty; import com.launchdarkly.sdk.server.integrations.EventProcessorBuilder; import com.launchdarkly.sdk.server.integrations.HttpConfigurationBuilder; +import com.launchdarkly.sdk.server.integrations.LoggingConfigurationBuilder; import com.launchdarkly.sdk.server.integrations.PersistentDataStoreBuilder; import com.launchdarkly.sdk.server.integrations.PollingDataSourceBuilder; import com.launchdarkly.sdk.server.integrations.StreamingDataSourceBuilder; import com.launchdarkly.sdk.server.interfaces.ClientContext; import com.launchdarkly.sdk.server.interfaces.DataSource; import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; @@ -18,10 +21,13 @@ import com.launchdarkly.sdk.server.interfaces.Event; import com.launchdarkly.sdk.server.interfaces.EventProcessor; import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; +import com.launchdarkly.sdk.server.interfaces.EventSender; import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; import com.launchdarkly.sdk.server.interfaces.FlagValueChangeListener; import com.launchdarkly.sdk.server.interfaces.HttpAuthentication; import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; +import com.launchdarkly.sdk.server.interfaces.LoggingConfiguration; +import com.launchdarkly.sdk.server.interfaces.PersistentDataStore; import com.launchdarkly.sdk.server.interfaces.PersistentDataStoreFactory; import java.io.IOException; @@ -219,7 +225,7 @@ public static DataSourceFactory externalUpdatesOnly() { } /** - * Returns a configurable factory for the SDK's networking configuration. + * Returns a configuration builder for the SDK's networking configuration. *

    * Passing this to {@link LDConfig.Builder#http(com.launchdarkly.sdk.server.interfaces.HttpConfigurationFactory)} * applies this configuration to all HTTP/HTTPS requests made by the SDK. @@ -262,6 +268,28 @@ public static HttpConfigurationBuilder httpConfiguration() { public static HttpAuthentication httpBasicAuthentication(String username, String password) { return new HttpBasicAuthentication(username, password); } + + /** + * Returns a configuration builder for the SDK's logging configuration. + *

    + * Passing this to {@link LDConfig.Builder#logging(com.launchdarkly.sdk.server.interfaces.LoggingConfigurationFactory)}, + * after setting any desired properties on the builder, applies this configuration to the SDK. + *

    
    +   *     LDConfig config = new LDConfig.Builder()
    +   *         .logging(
    +   *              Components.logging()
    +   *                  .logDataSourceOutageAsErrorAfter(Duration.ofSeconds(120))
    +   *         )
    +   *         .build();
    +   * 
    + * + * @return a factory object + * @since 5.0.0 + * @see LDConfig.Builder#logging(com.launchdarkly.sdk.server.interfaces.LoggingConfigurationFactory) + */ + public static LoggingConfigurationBuilder logging() { + return new LoggingConfigurationBuilderImpl(); + } /** * Convenience method for creating a {@link FlagChangeListener} that tracks a flag's value for a specific user. @@ -291,7 +319,7 @@ public static FlagChangeListener flagValueMonitoringListener(LDClientInterface c private static final class InMemoryDataStoreFactory implements DataStoreFactory, DiagnosticDescription { static final DataStoreFactory INSTANCE = new InMemoryDataStoreFactory(); @Override - public DataStore createDataStore(ClientContext context) { + public DataStore createDataStore(ClientContext context, DataStoreUpdates dataStoreUpdates) { return new InMemoryDataStore(); } @@ -328,7 +356,7 @@ private static final class NullDataSourceFactory implements DataSourceFactory, D static final NullDataSourceFactory INSTANCE = new NullDataSourceFactory(); @Override - public DataSource createDataSource(ClientContext context, DataStoreUpdates dataStoreUpdates) { + public DataSource createDataSource(ClientContext context, DataSourceUpdates dataSourceUpdates) { if (context.isOffline()) { // If they have explicitly called offline(true) to disable everything, we'll log this slightly // more specific message. @@ -336,6 +364,7 @@ public DataSource createDataSource(ClientContext context, DataStoreUpdates dataS } else { LDClient.logger.info("LaunchDarkly client will not connect to Launchdarkly for feature flag data"); } + dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.VALID, null); return NullDataSource.INSTANCE; } @@ -373,11 +402,11 @@ public void close() throws IOException {} private static final class StreamingDataSourceBuilderImpl extends StreamingDataSourceBuilder implements DiagnosticDescription { @Override - public DataSource createDataSource(ClientContext context, DataStoreUpdates dataStoreUpdates) { + public DataSource createDataSource(ClientContext context, DataSourceUpdates dataSourceUpdates) { // Note, we log startup messages under the LDClient class to keep logs more readable if (context.isOffline()) { - return Components.externalUpdatesOnly().createDataSource(context, dataStoreUpdates); + return Components.externalUpdatesOnly().createDataSource(context, dataSourceUpdates); } LDClient.logger.info("Enabling streaming API"); @@ -403,9 +432,10 @@ public DataSource createDataSource(ClientContext context, DataStoreUpdates dataS context.getSdkKey(), context.getHttpConfiguration(), requestor, - dataStoreUpdates, + dataSourceUpdates, null, - ClientContextImpl.getDiagnosticAccumulator(context), + context.getThreadPriority(), + ClientContextImpl.get(context).diagnosticAccumulator, streamUri, initialReconnectDelay ); @@ -431,11 +461,11 @@ public LDValue describeConfiguration(LDConfig config) { private static final class PollingDataSourceBuilderImpl extends PollingDataSourceBuilder implements DiagnosticDescription { @Override - public DataSource createDataSource(ClientContext context, DataStoreUpdates dataStoreUpdates) { + public DataSource createDataSource(ClientContext context, DataSourceUpdates dataSourceUpdates) { // Note, we log startup messages under the LDClient class to keep logs more readable if (context.isOffline()) { - return Components.externalUpdatesOnly().createDataSource(context, dataStoreUpdates); + return Components.externalUpdatesOnly().createDataSource(context, dataSourceUpdates); } LDClient.logger.info("Disabling streaming API"); @@ -447,7 +477,12 @@ public DataSource createDataSource(ClientContext context, DataStoreUpdates dataS baseURI == null ? LDConfig.DEFAULT_BASE_URI : baseURI, true ); - return new PollingProcessor(requestor, dataStoreUpdates, pollInterval); + return new PollingProcessor( + requestor, + dataSourceUpdates, + ClientContextImpl.get(context).sharedExecutor, + pollInterval + ); } @Override @@ -473,23 +508,26 @@ public EventProcessor createEventProcessor(ClientContext context) { if (context.isOffline()) { return new NullEventProcessor(); } + EventSender eventSender = + (eventSenderFactory == null ? new DefaultEventSender.Factory() : eventSenderFactory) + .createEventSender(context.getSdkKey(), context.getHttpConfiguration()); return new DefaultEventProcessor( - context.getSdkKey(), new EventsConfiguration( allAttributesPrivate, capacity, + eventSender, baseURI == null ? LDConfig.DEFAULT_EVENTS_URI : baseURI, flushInterval, inlineUsersInEvents, privateAttributes, - 0, // deprecated samplingInterval isn't supported in new builder userKeysCapacity, userKeysFlushInterval, diagnosticRecordingInterval ), - context.getHttpConfiguration(), - ClientContextImpl.getDiagnosticAccumulator(context), - ClientContextImpl.getDiagnosticInitEvent(context) + ClientContextImpl.get(context).sharedExecutor, + context.getThreadPriority(), + ClientContextImpl.get(context).diagnosticAccumulator, + ClientContextImpl.get(context).diagnosticInitEvent ); } @@ -551,5 +589,28 @@ public LDValue describeConfiguration(LDConfig config) { } return LDValue.of("custom"); } + + /** + * Called by the SDK to create the data store instance. + */ + @Override + public DataStore createDataStore(ClientContext context, DataStoreUpdates dataStoreUpdates) { + PersistentDataStore core = persistentDataStoreFactory.createPersistentDataStore(context); + return new PersistentDataStoreWrapper( + core, + cacheTime, + staleValuesPolicy, + recordCacheStats, + dataStoreUpdates, + ClientContextImpl.get(context).sharedExecutor + ); + } + } + + private static final class LoggingConfigurationBuilderImpl extends LoggingConfigurationBuilder { + @Override + public LoggingConfiguration createLoggingConfiguration() { + return new LoggingConfigurationImpl(logDataSourceOutageAsErrorAfter); + } } } diff --git a/src/main/java/com/launchdarkly/sdk/server/DataSourceStatusProviderImpl.java b/src/main/java/com/launchdarkly/sdk/server/DataSourceStatusProviderImpl.java new file mode 100644 index 000000000..cadb09ca5 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/DataSourceStatusProviderImpl.java @@ -0,0 +1,31 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; + +import java.util.function.Supplier; + +final class DataSourceStatusProviderImpl implements DataSourceStatusProvider { + private final EventBroadcasterImpl dataSourceStatusNotifier; + private final Supplier statusSupplier; + + DataSourceStatusProviderImpl(EventBroadcasterImpl dataSourceStatusNotifier, + Supplier statusSupplier) { + this.dataSourceStatusNotifier = dataSourceStatusNotifier; + this.statusSupplier = statusSupplier; + } + + @Override + public Status getStatus() { + return statusSupplier.get(); + } + + @Override + public void addStatusListener(StatusListener listener) { + dataSourceStatusNotifier.register(listener); + } + + @Override + public void removeStatusListener(StatusListener listener) { + dataSourceStatusNotifier.unregister(listener); + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java b/src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java new file mode 100644 index 000000000..70ef123c1 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java @@ -0,0 +1,319 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.base.Joiner; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.launchdarkly.sdk.server.DataModelDependencies.KindAndKey; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorInfo; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorKind; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.State; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.Status; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.StatusListener; +import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; +import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; +import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; + +import java.time.Duration; +import java.time.Instant; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +import static com.google.common.collect.Iterables.concat; +import static com.google.common.collect.Iterables.transform; +import static com.launchdarkly.sdk.server.DataModel.ALL_DATA_KINDS; +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static java.util.Collections.emptyMap; + +/** + * The data source will push updates into this component. We then apply any necessary + * transformations before putting them into the data store; currently that just means sorting + * the data set for init(). We also generate flag change events for any updates or deletions. + *

    + * This component is also responsible for receiving updates to the data source status, broadcasting + * them to any status listeners, and tracking the length of any period of sustained failure. + * + * @since 4.11.0 + */ +final class DataSourceUpdatesImpl implements DataSourceUpdates { + private final DataStore store; + private final EventBroadcasterImpl flagChangeEventNotifier; + private final EventBroadcasterImpl dataSourceStatusNotifier; + private final DataModelDependencies.DependencyTracker dependencyTracker = new DataModelDependencies.DependencyTracker(); + private final DataStoreStatusProvider dataStoreStatusProvider; + private final OutageTracker outageTracker; + + private volatile Status currentStatus; + private volatile boolean lastStoreUpdateFailed = false; + + DataSourceUpdatesImpl( + DataStore store, + DataStoreStatusProvider dataStoreStatusProvider, + EventBroadcasterImpl flagChangeEventNotifier, + EventBroadcasterImpl dataSourceStatusNotifier, + ScheduledExecutorService sharedExecutor, + Duration outageLoggingTimeout + ) { + this.store = store; + this.flagChangeEventNotifier = flagChangeEventNotifier; + this.dataSourceStatusNotifier = dataSourceStatusNotifier; + this.dataStoreStatusProvider = dataStoreStatusProvider; + this.outageTracker = new OutageTracker(sharedExecutor, outageLoggingTimeout); + + currentStatus = new Status(State.INITIALIZING, Instant.now(), null); + } + + @Override + public boolean init(FullDataSet allData) { + Map> oldData = null; + + try { + if (hasFlagChangeEventListeners()) { + // Query the existing data if any, so that after the update we can send events for whatever was changed + oldData = new HashMap<>(); + for (DataKind kind: ALL_DATA_KINDS) { + KeyedItems items = store.getAll(kind); + oldData.put(kind, ImmutableMap.copyOf(items.getItems())); + } + } + store.init(DataModelDependencies.sortAllCollections(allData)); + lastStoreUpdateFailed = false; + } catch (RuntimeException e) { + reportStoreFailure(e); + return false; + } + + // We must always update the dependency graph even if we don't currently have any event listeners, because if + // listeners are added later, we don't want to have to reread the whole data store to compute the graph + updateDependencyTrackerFromFullDataSet(allData); + + // Now, if we previously queried the old data because someone is listening for flag change events, compare + // the versions of all items and generate events for those (and any other items that depend on them) + if (oldData != null) { + sendChangeEvents(computeChangedItemsForFullDataSet(oldData, fullDataSetToMap(allData))); + } + + return true; + } + + @Override + public boolean upsert(DataKind kind, String key, ItemDescriptor item) { + boolean successfullyUpdated; + try { + successfullyUpdated = store.upsert(kind, key, item); + lastStoreUpdateFailed = false; + } catch (RuntimeException e) { + reportStoreFailure(e); + return false; + } + + if (successfullyUpdated) { + dependencyTracker.updateDependenciesFrom(kind, key, item); + if (hasFlagChangeEventListeners()) { + Set affectedItems = new HashSet<>(); + dependencyTracker.addAffectedItems(affectedItems, new KindAndKey(kind, key)); + sendChangeEvents(affectedItems); + } + } + + return true; + } + + @Override + public DataStoreStatusProvider getDataStoreStatusProvider() { + return dataStoreStatusProvider; + } + + @Override + public void updateStatus(State newState, ErrorInfo newError) { + if (newState == null) { + return; + } + + Status statusToBroadcast = null; + + synchronized (this) { + Status oldStatus = currentStatus; + + if (newState == State.INTERRUPTED && oldStatus.getState() == State.INITIALIZING) { + newState = State.INITIALIZING; // see comment on updateStatus in the DataSourceUpdates interface + } + + if (newState != oldStatus.getState() || newError != null) { + currentStatus = new Status( + newState, + newState == currentStatus.getState() ? currentStatus.getStateSince() : Instant.now(), + newError == null ? currentStatus.getLastError() : newError + ); + statusToBroadcast = currentStatus; + } + + outageTracker.trackDataSourceState(newState, newError); + } + + if (statusToBroadcast != null) { + dataSourceStatusNotifier.broadcast(statusToBroadcast); + } + } + + Status getLastStatus() { + synchronized (this) { + return currentStatus; + } + } + + private boolean hasFlagChangeEventListeners() { + return flagChangeEventNotifier.hasListeners(); + } + + private void sendChangeEvents(Iterable affectedItems) { + for (KindAndKey item: affectedItems) { + if (item.kind == FEATURES) { + flagChangeEventNotifier.broadcast(new FlagChangeEvent(item.key)); + } + } + } + + private void updateDependencyTrackerFromFullDataSet(FullDataSet allData) { + dependencyTracker.reset(); + for (Map.Entry> e0: allData.getData()) { + DataKind kind = e0.getKey(); + for (Map.Entry e1: e0.getValue().getItems()) { + String key = e1.getKey(); + dependencyTracker.updateDependenciesFrom(kind, key, e1.getValue()); + } + } + } + + private Map> fullDataSetToMap(FullDataSet allData) { + Map> ret = new HashMap<>(); + for (Map.Entry> e: allData.getData()) { + ret.put(e.getKey(), ImmutableMap.copyOf(e.getValue().getItems())); + } + return ret; + } + + private Set computeChangedItemsForFullDataSet(Map> oldDataMap, + Map> newDataMap) { + Set affectedItems = new HashSet<>(); + for (DataKind kind: ALL_DATA_KINDS) { + Map oldItems = oldDataMap.get(kind); + Map newItems = newDataMap.get(kind); + if (oldItems == null) { + oldItems = emptyMap(); + } + if (newItems == null) { + newItems = emptyMap(); + } + Set allKeys = ImmutableSet.copyOf(concat(oldItems.keySet(), newItems.keySet())); + for (String key: allKeys) { + ItemDescriptor oldItem = oldItems.get(key); + ItemDescriptor newItem = newItems.get(key); + if (oldItem == null && newItem == null) { // shouldn't be possible due to how we computed allKeys + continue; + } + if (oldItem == null || newItem == null || oldItem.getVersion() < newItem.getVersion()) { + dependencyTracker.addAffectedItems(affectedItems, new KindAndKey(kind, key)); + } + // Note that comparing the version numbers is sufficient; we don't have to compare every detail of the + // flag or segment configuration, because it's a basic underlying assumption of the entire LD data model + // that if an entity's version number hasn't changed, then the entity hasn't changed (and that if two + // version numbers are different, the higher one is the more recent version). + } + } + return affectedItems; + } + + private void reportStoreFailure(RuntimeException e) { + if (!lastStoreUpdateFailed) { + LDClient.logger.warn("Unexpected data store error when trying to store an update received from the data source: {}", e.toString()); + lastStoreUpdateFailed = true; + } + LDClient.logger.debug(e.toString(), e); + updateStatus(State.INTERRUPTED, ErrorInfo.fromException(ErrorKind.STORE_ERROR, e)); + } + + // Encapsulates our logic for keeping track of the length and cause of data source outages. + private static final class OutageTracker { + private final boolean enabled; + private final ScheduledExecutorService sharedExecutor; + private final Duration loggingTimeout; + private final HashMap errorCounts = new HashMap<>(); + + private volatile boolean inOutage; + private volatile ScheduledFuture timeoutFuture; + + OutageTracker(ScheduledExecutorService sharedExecutor, Duration loggingTimeout) { + this.sharedExecutor = sharedExecutor; + this.loggingTimeout = loggingTimeout; + this.enabled = loggingTimeout != null; + } + + void trackDataSourceState(State newState, ErrorInfo newError) { + if (!enabled) { + return; + } + + synchronized (this) { + if (newState == State.INTERRUPTED || newError != null || (newState == State.INITIALIZING && inOutage)) { + // We are in a potentially recoverable outage. If that wasn't the case already, and if we've been configured + // with a timeout for logging the outage at a higher level, schedule that timeout. + if (inOutage) { + // We were already in one - just record this latest error for logging later. + recordError(newError); + } else { + // We weren't already in one, so set the timeout and start recording errors. + inOutage = true; + errorCounts.clear(); + recordError(newError); + timeoutFuture = sharedExecutor.schedule(this::onTimeout, loggingTimeout.toMillis(), TimeUnit.MILLISECONDS); + } + } else { + if (timeoutFuture != null) { + timeoutFuture.cancel(false); + timeoutFuture = null; + } + inOutage = false; + } + } + } + + private void recordError(ErrorInfo newError) { + // Accumulate how many times each kind of error has occurred during the outage - use just the basic + // properties as the key so the map won't expand indefinitely + ErrorInfo basicErrorInfo = new ErrorInfo(newError.getKind(), newError.getStatusCode(), null, null); + LDClient.logger.warn("recordError(" + basicErrorInfo + ")"); + errorCounts.compute(basicErrorInfo, (key, oldValue) -> oldValue == null ? 1 : oldValue.intValue() + 1); + } + + private void onTimeout() { + String errorsDesc; + synchronized (this) { + if (timeoutFuture == null || !inOutage) { + return; + } + timeoutFuture = null; + errorsDesc = Joiner.on(", ").join(transform(errorCounts.entrySet(), OutageTracker::describeErrorCount)); + } + LDClient.logger.error( + "LaunchDarkly data source outage - updates have been unavailable for at least {} with the following errors: {}", + Util.describeDuration(loggingTimeout), + errorsDesc + ); + } + + private static String describeErrorCount(Map.Entry entry) { + return entry.getKey() + " (" + entry.getValue() + (entry.getValue() == 1 ? " time" : " times") + ")"; + } + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImpl.java b/src/main/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImpl.java index db63225ee..26b67e426 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImpl.java @@ -3,34 +3,40 @@ import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; -// Simple delegator to ensure that LDClient.getDataStoreStatusProvider() never returns null and that -// the application isn't given direct access to the store. final class DataStoreStatusProviderImpl implements DataStoreStatusProvider { - private final DataStoreStatusProvider delegateTo; + private final DataStore store; + private final DataStoreUpdatesImpl dataStoreUpdates; - DataStoreStatusProviderImpl(DataStore store) { - delegateTo = store instanceof DataStoreStatusProvider ? (DataStoreStatusProvider)store : null; + DataStoreStatusProviderImpl( + DataStore store, + DataStoreUpdatesImpl dataStoreUpdates + ) { + this.store = store; + this.dataStoreUpdates = dataStoreUpdates; } @Override public Status getStoreStatus() { - return delegateTo == null ? null : delegateTo.getStoreStatus(); + return dataStoreUpdates.lastStatus.get(); } @Override - public boolean addStatusListener(StatusListener listener) { - return delegateTo != null && delegateTo.addStatusListener(listener); + public void addStatusListener(StatusListener listener) { + dataStoreUpdates.statusBroadcaster.register(listener); } @Override public void removeStatusListener(StatusListener listener) { - if (delegateTo != null) { - delegateTo.removeStatusListener(listener); - } + dataStoreUpdates.statusBroadcaster.unregister(listener); + } + + @Override + public boolean isStatusMonitoringEnabled() { + return store.isStatusMonitoringEnabled(); } @Override public CacheStats getCacheStats() { - return delegateTo == null ? null : delegateTo.getCacheStats(); + return store.getCacheStats(); } } diff --git a/src/main/java/com/launchdarkly/sdk/server/DataStoreUpdatesImpl.java b/src/main/java/com/launchdarkly/sdk/server/DataStoreUpdatesImpl.java index 7edc3ef14..93b01eb38 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DataStoreUpdatesImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/DataStoreUpdatesImpl.java @@ -1,153 +1,29 @@ package com.launchdarkly.sdk.server; -import com.google.common.collect.ImmutableMap; -import com.google.common.collect.ImmutableSet; -import com.launchdarkly.sdk.server.DataModelDependencies.KindAndKey; -import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; -import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; -import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; -import static com.google.common.collect.Iterables.concat; -import static com.launchdarkly.sdk.server.DataModel.ALL_DATA_KINDS; -import static com.launchdarkly.sdk.server.DataModel.FEATURES; -import static java.util.Collections.emptyMap; +class DataStoreUpdatesImpl implements DataStoreUpdates { + // package-private because it's convenient to use these from DataStoreStatusProviderImpl + final EventBroadcasterImpl statusBroadcaster; + final AtomicReference lastStatus; -/** - * The data source will push updates into this component. We then apply any necessary - * transformations before putting them into the data store; currently that just means sorting - * the data set for init(). We also generate flag change events for any updates or deletions. - * - * @since 4.11.0 - */ -final class DataStoreUpdatesImpl implements DataStoreUpdates { - private final DataStore store; - private final FlagChangeEventPublisher flagChangeEventPublisher; - private final DataModelDependencies.DependencyTracker dependencyTracker = new DataModelDependencies.DependencyTracker(); - private final DataStoreStatusProvider dataStoreStatusProvider; - - DataStoreUpdatesImpl(DataStore store, FlagChangeEventPublisher flagChangeEventPublisher) { - this.store = store; - this.flagChangeEventPublisher = flagChangeEventPublisher; - this.dataStoreStatusProvider = new DataStoreStatusProviderImpl(store); - } - - @Override - public void init(FullDataSet allData) { - Map> oldData = null; - - if (hasFlagChangeEventListeners()) { - // Query the existing data if any, so that after the update we can send events for whatever was changed - oldData = new HashMap<>(); - for (DataKind kind: ALL_DATA_KINDS) { - KeyedItems items = store.getAll(kind); - oldData.put(kind, ImmutableMap.copyOf(items.getItems())); - } - } - - store.init(DataModelDependencies.sortAllCollections(allData)); - - // We must always update the dependency graph even if we don't currently have any event listeners, because if - // listeners are added later, we don't want to have to reread the whole data store to compute the graph - updateDependencyTrackerFromFullDataSet(allData); - - // Now, if we previously queried the old data because someone is listening for flag change events, compare - // the versions of all items and generate events for those (and any other items that depend on them) - if (oldData != null) { - sendChangeEvents(computeChangedItemsForFullDataSet(oldData, fullDataSetToMap(allData))); - } - } - - @Override - public void upsert(DataKind kind, String key, ItemDescriptor item) { - boolean successfullyUpdated = store.upsert(kind, key, item); - - if (successfullyUpdated) { - dependencyTracker.updateDependenciesFrom(kind, key, item); - if (hasFlagChangeEventListeners()) { - Set affectedItems = new HashSet<>(); - dependencyTracker.addAffectedItems(affectedItems, new KindAndKey(kind, key)); - sendChangeEvents(affectedItems); - } - } + DataStoreUpdatesImpl( + EventBroadcasterImpl statusBroadcaster + ) { + this.statusBroadcaster = statusBroadcaster; + this.lastStatus = new AtomicReference<>(new DataStoreStatusProvider.Status(true, false)); // initially "available" } @Override - public DataStoreStatusProvider getStatusProvider() { - return dataStoreStatusProvider; - } - - private boolean hasFlagChangeEventListeners() { - return flagChangeEventPublisher != null && flagChangeEventPublisher.hasListeners(); - } - - private void sendChangeEvents(Iterable affectedItems) { - if (flagChangeEventPublisher == null) { - return; - } - for (KindAndKey item: affectedItems) { - if (item.kind == FEATURES) { - flagChangeEventPublisher.publishEvent(new FlagChangeEvent(item.key)); - } - } - } - - private void updateDependencyTrackerFromFullDataSet(FullDataSet allData) { - dependencyTracker.reset(); - for (Map.Entry> e0: allData.getData()) { - DataKind kind = e0.getKey(); - for (Map.Entry e1: e0.getValue().getItems()) { - String key = e1.getKey(); - dependencyTracker.updateDependenciesFrom(kind, key, e1.getValue()); - } - } - } - - private Map> fullDataSetToMap(FullDataSet allData) { - Map> ret = new HashMap<>(); - for (Map.Entry> e: allData.getData()) { - ret.put(e.getKey(), ImmutableMap.copyOf(e.getValue().getItems())); - } - return ret; - } - - private Set computeChangedItemsForFullDataSet(Map> oldDataMap, - Map> newDataMap) { - Set affectedItems = new HashSet<>(); - for (DataKind kind: ALL_DATA_KINDS) { - Map oldItems = oldDataMap.get(kind); - Map newItems = newDataMap.get(kind); - if (oldItems == null) { - oldItems = emptyMap(); - } - if (newItems == null) { - newItems = emptyMap(); - } - Set allKeys = ImmutableSet.copyOf(concat(oldItems.keySet(), newItems.keySet())); - for (String key: allKeys) { - ItemDescriptor oldItem = oldItems.get(key); - ItemDescriptor newItem = newItems.get(key); - if (oldItem == null && newItem == null) { // shouldn't be possible due to how we computed allKeys - continue; - } - if (oldItem == null || newItem == null || oldItem.getVersion() < newItem.getVersion()) { - dependencyTracker.addAffectedItems(affectedItems, new KindAndKey(kind, key)); - } - // Note that comparing the version numbers is sufficient; we don't have to compare every detail of the - // flag or segment configuration, because it's a basic underlying assumption of the entire LD data model - // that if an entity's version number hasn't changed, then the entity hasn't changed (and that if two - // version numbers are different, the higher one is the more recent version). + public void updateStatus(DataStoreStatusProvider.Status newStatus) { + if (newStatus != null) { + DataStoreStatusProvider.Status oldStatus = lastStatus.getAndSet(newStatus); + if (!newStatus.equals(oldStatus)) { + statusBroadcaster.broadcast(newStatus); } } - return affectedItems; } } diff --git a/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java index a814f81ea..acaa2571f 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java @@ -6,24 +6,21 @@ import com.launchdarkly.sdk.server.EventSummarizer.EventSummary; import com.launchdarkly.sdk.server.interfaces.Event; import com.launchdarkly.sdk.server.interfaces.EventProcessor; -import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; +import com.launchdarkly.sdk.server.interfaces.EventSender; +import com.launchdarkly.sdk.server.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.UUID; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; import java.util.concurrent.Semaphore; import java.util.concurrent.ThreadFactory; import java.util.concurrent.TimeUnit; @@ -31,61 +28,53 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; -import static com.launchdarkly.sdk.server.Util.configureHttpClientBuilder; -import static com.launchdarkly.sdk.server.Util.getHeadersBuilderFor; -import static com.launchdarkly.sdk.server.Util.httpErrorMessage; -import static com.launchdarkly.sdk.server.Util.isHttpErrorRecoverable; -import static com.launchdarkly.sdk.server.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"; - private static final MediaType JSON_CONTENT_TYPE = MediaType.parse("application/json; charset=utf-8"); @VisibleForTesting final EventDispatcher dispatcher; private final BlockingQueue inbox; private final ScheduledExecutorService scheduler; private final AtomicBoolean closed = new AtomicBoolean(false); + private final List> scheduledTasks = new ArrayList<>(); private volatile boolean inputCapacityExceeded = false; - DefaultEventProcessor(String sdkKey, EventsConfiguration eventsConfig, HttpConfiguration httpConfig, - DiagnosticAccumulator diagnosticAccumulator, DiagnosticEvent.Init diagnosticInitEvent) { + DefaultEventProcessor( + EventsConfiguration eventsConfig, + ScheduledExecutorService sharedExecutor, + int threadPriority, + DiagnosticAccumulator diagnosticAccumulator, + DiagnosticEvent.Init diagnosticInitEvent + ) { inbox = new ArrayBlockingQueue<>(eventsConfig.capacity); - ThreadFactory threadFactory = new ThreadFactoryBuilder() - .setDaemon(true) - .setNameFormat("LaunchDarkly-EventProcessor-%d") - .setPriority(Thread.MIN_PRIORITY) - .build(); - scheduler = Executors.newSingleThreadScheduledExecutor(threadFactory); - - dispatcher = new EventDispatcher(sdkKey, eventsConfig, httpConfig, inbox, threadFactory, closed, diagnosticAccumulator, diagnosticInitEvent); + scheduler = sharedExecutor; + + dispatcher = new EventDispatcher( + eventsConfig, + sharedExecutor, + threadPriority, + inbox, + closed, + diagnosticAccumulator, + diagnosticInitEvent + ); Runnable flusher = () -> { postMessageAsync(MessageType.FLUSH, null); }; - this.scheduler.scheduleAtFixedRate(flusher, eventsConfig.flushInterval.toMillis(), - eventsConfig.flushInterval.toMillis(), TimeUnit.MILLISECONDS); + scheduledTasks.add(this.scheduler.scheduleAtFixedRate(flusher, eventsConfig.flushInterval.toMillis(), + eventsConfig.flushInterval.toMillis(), TimeUnit.MILLISECONDS)); Runnable userKeysFlusher = () -> { postMessageAsync(MessageType.FLUSH_USERS, null); }; - this.scheduler.scheduleAtFixedRate(userKeysFlusher, eventsConfig.userKeysFlushInterval.toMillis(), - eventsConfig.userKeysFlushInterval.toMillis(), TimeUnit.MILLISECONDS); + scheduledTasks.add(this.scheduler.scheduleAtFixedRate(userKeysFlusher, eventsConfig.userKeysFlushInterval.toMillis(), + eventsConfig.userKeysFlushInterval.toMillis(), TimeUnit.MILLISECONDS)); if (diagnosticAccumulator != null) { Runnable diagnosticsTrigger = () -> { postMessageAsync(MessageType.DIAGNOSTIC, null); }; - this.scheduler.scheduleAtFixedRate(diagnosticsTrigger, eventsConfig.diagnosticRecordingInterval.toMillis(), - eventsConfig.diagnosticRecordingInterval.toMillis(), TimeUnit.MILLISECONDS); + scheduledTasks.add(this.scheduler.scheduleAtFixedRate(diagnosticsTrigger, eventsConfig.diagnosticRecordingInterval.toMillis(), + eventsConfig.diagnosticRecordingInterval.toMillis(), TimeUnit.MILLISECONDS)); } } @@ -106,7 +95,7 @@ public void flush() { @Override public void close() throws IOException { if (closed.compareAndSet(false, true)) { - scheduler.shutdown(); + scheduledTasks.forEach(task -> task.cancel(false)); postMessageAsync(MessageType.FLUSH, null); postMessageAndWait(MessageType.SHUTDOWN, null); } @@ -205,30 +194,35 @@ 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 AtomicLong lastKnownPastTime = new AtomicLong(0); private final AtomicBoolean disabled = new AtomicBoolean(false); @VisibleForTesting final DiagnosticAccumulator diagnosticAccumulator; - private final ExecutorService diagnosticExecutor; + private final ExecutorService sharedExecutor; private final SendDiagnosticTaskFactory sendDiagnosticTaskFactory; private long deduplicatedUsers = 0; - private EventDispatcher(String sdkKey, EventsConfiguration eventsConfig, HttpConfiguration httpConfig, - final BlockingQueue inbox, - ThreadFactory threadFactory, - final AtomicBoolean closed, - DiagnosticAccumulator diagnosticAccumulator, - DiagnosticEvent.Init diagnosticInitEvent) { + private EventDispatcher( + EventsConfiguration eventsConfig, + ExecutorService sharedExecutor, + int threadPriority, + final BlockingQueue inbox, + final AtomicBoolean closed, + DiagnosticAccumulator diagnosticAccumulator, + DiagnosticEvent.Init diagnosticInitEvent + ) { this.eventsConfig = eventsConfig; + this.sharedExecutor = sharedExecutor; this.diagnosticAccumulator = diagnosticAccumulator; this.busyFlushWorkersCount = new AtomicInteger(0); - OkHttpClient.Builder httpBuilder = new OkHttpClient.Builder(); - configureHttpClientBuilder(httpConfig, httpBuilder); - httpClient = httpBuilder.build(); + ThreadFactory threadFactory = new ThreadFactoryBuilder() + .setDaemon(true) + .setNameFormat("LaunchDarkly-event-delivery-%d") + .setPriority(threadPriority) + .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 @@ -266,18 +260,21 @@ public void uncaughtException(Thread t, Throwable e) { flushWorkers = new ArrayList<>(); EventResponseListener listener = this::handleResponse; 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); - diagnosticExecutor = Executors.newSingleThreadExecutor(threadFactory); - diagnosticExecutor.submit(sendDiagnosticTaskFactory.createSendDiagnosticTask(diagnosticInitEvent)); + this.sendDiagnosticTaskFactory = new SendDiagnosticTaskFactory(eventsConfig); + sharedExecutor.submit(sendDiagnosticTaskFactory.createSendDiagnosticTask(diagnosticInitEvent)); } else { - diagnosticExecutor = null; sendDiagnosticTaskFactory = null; } } @@ -333,7 +330,7 @@ private void sendAndResetDiagnostics(EventBuffer outbox) { // 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)); + sharedExecutor.submit(sendDiagnosticTaskFactory.createSendDiagnosticTask(diagnosticEvent)); } private void doShutdown() { @@ -342,10 +339,12 @@ private void doShutdown() { for (SendEventsTask task: flushWorkers) { task.stop(); } - if (diagnosticExecutor != null) { - diagnosticExecutor.shutdown(); + try { + eventsConfig.eventSender.close(); + } catch (IOException e) { + logger.error("Unexpected error when closing event sender: {}", e.toString()); + logger.debug(e.toString(), e); } - shutdownHttpClient(httpClient); } private void waitUntilAllFlushWorkersInactive() { @@ -459,68 +458,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(json, JSON_CONTENT_TYPE)) - .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; } } } @@ -586,36 +529,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(); @@ -633,7 +571,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()); @@ -650,25 +594,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; - - 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(); + private final EventsConfiguration eventsConfig; + + SendDiagnosticTaskFactory(EventsConfiguration eventsConfig) { + this.eventsConfig = eventsConfig; } Runnable createSendDiagnosticTask(final DiagnosticEvent diagnosticEvent) { @@ -676,7 +608,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/sdk/server/DefaultEventSender.java b/src/main/java/com/launchdarkly/sdk/server/DefaultEventSender.java new file mode 100644 index 000000000..8ccba212e --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/DefaultEventSender.java @@ -0,0 +1,165 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.interfaces.EventSender; +import com.launchdarkly.sdk.server.interfaces.EventSenderFactory; +import com.launchdarkly.sdk.server.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.time.Duration; +import java.util.Date; +import java.util.UUID; + +import static com.launchdarkly.sdk.server.Util.checkIfErrorIsRecoverableAndLog; +import static com.launchdarkly.sdk.server.Util.configureHttpClientBuilder; +import static com.launchdarkly.sdk.server.Util.describeDuration; +import static com.launchdarkly.sdk.server.Util.getHeadersBuilderFor; +import static com.launchdarkly.sdk.server.Util.httpErrorDescription; +import static com.launchdarkly.sdk.server.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); + + static final Duration DEFAULT_RETRY_DELAY = Duration.ofSeconds(1); + 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 MediaType JSON_CONTENT_TYPE = MediaType.parse("application/json; charset=utf-8"); + 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; + private final Duration retryDelay; + + DefaultEventSender( + String sdkKey, + HttpConfiguration httpConfiguration, + Duration retryDelay + ) { + OkHttpClient.Builder httpBuilder = new OkHttpClient.Builder(); + configureHttpClientBuilder(httpConfiguration, httpBuilder); + this.httpClient = httpBuilder.build(); + + this.baseHeaders = getHeadersBuilderFor(sdkKey, httpConfiguration) + .add("Content-Type", "application/json") + .build(); + + this.retryDelay = retryDelay == null ? DEFAULT_RETRY_DELAY : retryDelay; + } + + @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(); + String path; + String description; + + switch (kind) { + case ANALYTICS: + 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: + 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(data, JSON_CONTENT_TYPE); + 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 {}", description, describeDuration(retryDelay)); + try { + Thread.sleep(retryDelay.toMillis()); + } 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"; + String errorContext = "posting " + description; + + 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 errorDesc = httpErrorDescription(response.code()); + boolean recoverable = checkIfErrorIsRecoverableAndLog( + logger, + errorDesc, + errorContext, + response.code(), + nextActionMessage + ); + if (!recoverable) { + mustShutDown = true; + break; + } + } catch (IOException e) { + checkIfErrorIsRecoverableAndLog(logger, e.toString(), errorContext, 0, nextActionMessage); + } + } + + 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_LOCK) { + 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, DefaultEventSender.DEFAULT_RETRY_DELAY); + } + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/EventBroadcasterImpl.java b/src/main/java/com/launchdarkly/sdk/server/EventBroadcasterImpl.java new file mode 100644 index 000000000..e280184d1 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/EventBroadcasterImpl.java @@ -0,0 +1,99 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; +import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; +import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; + +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ExecutorService; +import java.util.function.BiConsumer; + +/** + * A generic mechanism for registering event listeners and broadcasting events to them. The SDK maintains an + * instance of this for each available type of listener (flag change, data store status, etc.). They are all + * intended to share a single executor service; notifications are submitted individually to this service for + * each listener. + * + * @param the listener interface class + * @param the event class + */ +final class EventBroadcasterImpl { + private final CopyOnWriteArrayList listeners = new CopyOnWriteArrayList<>(); + private final BiConsumer broadcastAction; + private final ExecutorService executor; + + /** + * Creates an instance. + * + * @param broadcastAction a lambda that calls the appropriate listener method for an event + * @param executor the executor to use for running notification tasks on a worker thread; if this + * is null (which should only be the case in test code) then broadcasting an event will be a no-op + */ + EventBroadcasterImpl(BiConsumer broadcastAction, ExecutorService executor) { + this.broadcastAction = broadcastAction; + this.executor = executor; + } + + static EventBroadcasterImpl forFlagChangeEvents(ExecutorService executor) { + return new EventBroadcasterImpl<>(FlagChangeListener::onFlagChange, executor); + } + + static EventBroadcasterImpl + forDataSourceStatus(ExecutorService executor) { + return new EventBroadcasterImpl<>(DataSourceStatusProvider.StatusListener::dataSourceStatusChanged, executor); + } + + static EventBroadcasterImpl + forDataStoreStatus(ExecutorService executor) { + return new EventBroadcasterImpl<>(DataStoreStatusProvider.StatusListener::dataStoreStatusChanged, executor); + } + + /** + * Registers a listener for this type of event. This method is thread-safe. + * + * @param listener the listener to register + */ + void register(ListenerT listener) { + listeners.add(listener); + } + + /** + * Unregisters a listener. This method is thread-safe. + * + * @param listener the listener to unregister + */ + void unregister(ListenerT listener) { + listeners.remove(listener); + } + + /** + * Returns true if any listeners are currently registered. This method is thread-safe. + * + * @return true if there are listeners + */ + boolean hasListeners() { + return !listeners.isEmpty(); + } + + /** + * Broadcasts an event to all available listeners. + * + * @param event the event to broadcast + */ + void broadcast(EventT event) { + if (executor == null) { + return; + } + for (ListenerT l: listeners) { + executor.execute(() -> { + try { + broadcastAction.accept(l, event); + } catch (Exception e) { + LDClient.logger.warn("Unexpected error from listener ({0}): {1}", l.getClass(), e.toString()); + LDClient.logger.debug(e.toString(), e); + } + }); + } + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/EventsConfiguration.java b/src/main/java/com/launchdarkly/sdk/server/EventsConfiguration.java index 92acfb4b0..c03dc3350 100644 --- a/src/main/java/com/launchdarkly/sdk/server/EventsConfiguration.java +++ b/src/main/java/com/launchdarkly/sdk/server/EventsConfiguration.java @@ -2,6 +2,7 @@ import com.google.common.collect.ImmutableSet; import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.server.interfaces.EventSender; import java.net.URI; import java.time.Duration; @@ -11,26 +12,35 @@ final class EventsConfiguration { final boolean allAttributesPrivate; final int capacity; + final EventSender eventSender; final URI eventsUri; final Duration flushInterval; final boolean inlineUsersInEvents; final ImmutableSet privateAttributes; - final int samplingInterval; final int userKeysCapacity; final Duration userKeysFlushInterval; final Duration diagnosticRecordingInterval; - EventsConfiguration(boolean allAttributesPrivate, int capacity, URI eventsUri, Duration flushInterval, - boolean inlineUsersInEvents, Set privateAttributes, int samplingInterval, - int userKeysCapacity, Duration userKeysFlushInterval, Duration diagnosticRecordingInterval) { + EventsConfiguration( + boolean allAttributesPrivate, + int capacity, + EventSender eventSender, + URI eventsUri, + Duration flushInterval, + boolean inlineUsersInEvents, + Set privateAttributes, + int userKeysCapacity, + Duration userKeysFlushInterval, + Duration diagnosticRecordingInterval + ) { super(); this.allAttributesPrivate = allAttributesPrivate; this.capacity = capacity; + this.eventSender = eventSender; this.eventsUri = eventsUri == null ? LDConfig.DEFAULT_EVENTS_URI : eventsUri; this.flushInterval = flushInterval; this.inlineUsersInEvents = inlineUsersInEvents; this.privateAttributes = privateAttributes == null ? ImmutableSet.of() : ImmutableSet.copyOf(privateAttributes); - this.samplingInterval = samplingInterval; this.userKeysCapacity = userKeysCapacity; this.userKeysFlushInterval = userKeysFlushInterval; this.diagnosticRecordingInterval = diagnosticRecordingInterval; diff --git a/src/main/java/com/launchdarkly/sdk/server/FlagChangeEventPublisher.java b/src/main/java/com/launchdarkly/sdk/server/FlagChangeEventPublisher.java deleted file mode 100644 index 217174b32..000000000 --- a/src/main/java/com/launchdarkly/sdk/server/FlagChangeEventPublisher.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.launchdarkly.sdk.server; - -import com.google.common.util.concurrent.ThreadFactoryBuilder; -import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; -import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; - -import java.io.Closeable; -import java.io.IOException; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.ThreadFactory; - -final class FlagChangeEventPublisher implements Closeable { - private final CopyOnWriteArrayList listeners = new CopyOnWriteArrayList<>(); - private volatile ExecutorService executor = null; - - public void register(FlagChangeListener listener) { - listeners.add(listener); - synchronized (this) { - if (executor == null) { - executor = createExecutorService(); - } - } - } - - public void unregister(FlagChangeListener listener) { - listeners.remove(listener); - } - - public boolean hasListeners() { - return !listeners.isEmpty(); - } - - public void publishEvent(FlagChangeEvent event) { - for (FlagChangeListener l: listeners) { - executor.execute(() -> { - l.onFlagChange(event); - }); - } - } - - @Override - public void close() throws IOException { - if (executor != null) { - executor.shutdown(); - } - } - - private ExecutorService createExecutorService() { - ThreadFactory threadFactory = new ThreadFactoryBuilder() - .setDaemon(true) - .setNameFormat("LaunchDarkly-FlagChangeEventPublisher-%d") - .setPriority(Thread.MIN_PRIORITY) - .build(); - return Executors.newCachedThreadPool(threadFactory); - } -} diff --git a/src/main/java/com/launchdarkly/sdk/server/InMemoryDataStore.java b/src/main/java/com/launchdarkly/sdk/server/InMemoryDataStore.java index aea263cbc..b8378a32a 100644 --- a/src/main/java/com/launchdarkly/sdk/server/InMemoryDataStore.java +++ b/src/main/java/com/launchdarkly/sdk/server/InMemoryDataStore.java @@ -4,6 +4,7 @@ import com.google.common.collect.ImmutableMap; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider.CacheStats; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; @@ -99,7 +100,17 @@ public boolean upsert(DataKind kind, String key, ItemDescriptor item) { public boolean isInitialized() { return initialized; } - + + @Override + public boolean isStatusMonitoringEnabled() { + return false; + } + + @Override + public CacheStats getCacheStats() { + return null; + } + /** * Does nothing; this class does not have any resources to release * diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClient.java b/src/main/java/com/launchdarkly/sdk/server/LDClient.java index dc1b53b18..f5c170627 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClient.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClient.java @@ -1,5 +1,6 @@ package com.launchdarkly.sdk.server; +import com.google.common.util.concurrent.ThreadFactoryBuilder; import com.launchdarkly.sdk.EvaluationDetail; import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDUser; @@ -7,15 +8,17 @@ import com.launchdarkly.sdk.server.integrations.EventProcessorBuilder; import com.launchdarkly.sdk.server.interfaces.DataSource; import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; -import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; import com.launchdarkly.sdk.server.interfaces.Event; import com.launchdarkly.sdk.server.interfaces.EventProcessor; import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; +import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; import org.apache.commons.codec.binary.Hex; @@ -28,7 +31,10 @@ import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.util.Map; +import java.util.concurrent.Executors; import java.util.concurrent.Future; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadFactory; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.jar.Attributes; @@ -54,19 +60,37 @@ public final class LDClient implements LDClientInterface { static final String CLIENT_VERSION = getClientVersion(); private final String sdkKey; + private final boolean offline; private final Evaluator evaluator; - private final FlagChangeEventPublisher flagChangeEventPublisher; final EventProcessor eventProcessor; final DataSource dataSource; final DataStore dataStore; - private final DataStoreStatusProvider dataStoreStatusProvider; - private final boolean offline; + private final DataSourceUpdates dataSourceUpdates; + private final DataStoreStatusProviderImpl dataStoreStatusProvider; + private final DataSourceStatusProviderImpl dataSourceStatusProvider; + private final EventBroadcasterImpl flagChangeEventNotifier; + private final ScheduledExecutorService sharedExecutor; /** - * 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 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 + * 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); @@ -83,17 +107,35 @@ private static final DataModel.Segment getSegment(DataStore store, String key) { } /** - * 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 per request or per thread. + *

    + * Unless it is configured to be offline with {@link LDConfig.Builder#offline(boolean)} or + * {@link Components#externalUpdatesOnly()}, 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#startWait(java.time.Duration)} (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) { checkNotNull(config, "config must not be null"); this.sdkKey = checkNotNull(sdkKey, "sdkKey must not be null"); this.offline = config.offline; + this.sharedExecutor = createSharedExecutor(config); + final EventProcessorFactory epFactory = config.eventProcessorFactory == null ? Components.sendEvents() : config.eventProcessorFactory; @@ -108,16 +150,22 @@ public LDClient(String sdkKey, LDConfig config) { // Do not create diagnostic accumulator if config has specified is opted out, or if we're not using the // standard event processor final boolean useDiagnostics = !config.diagnosticOptOut && epFactory instanceof EventProcessorBuilder; - final ClientContextImpl context = new ClientContextImpl(sdkKey, config, - useDiagnostics ? new DiagnosticAccumulator(new DiagnosticId(sdkKey)) : null); + final ClientContextImpl context = new ClientContextImpl( + sdkKey, + config, + sharedExecutor, + useDiagnostics ? new DiagnosticAccumulator(new DiagnosticId(sdkKey)) : null + ); this.eventProcessor = epFactory.createEventProcessor(context); DataStoreFactory factory = config.dataStoreFactory == null ? Components.inMemoryDataStore() : config.dataStoreFactory; - this.dataStore = factory.createDataStore(context); - this.dataStoreStatusProvider = new DataStoreStatusProviderImpl(this.dataStore); - + EventBroadcasterImpl dataStoreStatusNotifier = + EventBroadcasterImpl.forDataStoreStatus(sharedExecutor); + DataStoreUpdatesImpl dataStoreUpdates = new DataStoreUpdatesImpl(dataStoreStatusNotifier); + this.dataStore = factory.createDataStore(context, dataStoreUpdates); + this.evaluator = new Evaluator(new Evaluator.Getters() { public DataModel.FeatureFlag getFlag(String key) { return LDClient.getFlag(LDClient.this.dataStore, key); @@ -127,14 +175,27 @@ public DataModel.Segment getSegment(String key) { return LDClient.getSegment(LDClient.this.dataStore, key); } }); - - this.flagChangeEventPublisher = new FlagChangeEventPublisher(); - + + this.flagChangeEventNotifier = EventBroadcasterImpl.forFlagChangeEvents(sharedExecutor); + + this.dataStoreStatusProvider = new DataStoreStatusProviderImpl(this.dataStore, dataStoreUpdates); + DataSourceFactory dataSourceFactory = config.dataSourceFactory == null ? Components.streamingDataSource() : config.dataSourceFactory; - DataStoreUpdates dataStoreUpdates = new DataStoreUpdatesImpl(dataStore, flagChangeEventPublisher); - this.dataSource = dataSourceFactory.createDataSource(context, dataStoreUpdates); - + EventBroadcasterImpl dataSourceStatusNotifier = + EventBroadcasterImpl.forDataSourceStatus(sharedExecutor); + DataSourceUpdatesImpl dataSourceUpdates = new DataSourceUpdatesImpl( + dataStore, + dataStoreStatusProvider, + flagChangeEventNotifier, + dataSourceStatusNotifier, + sharedExecutor, + config.loggingConfig.getLogDataSourceOutageAsErrorAfter() + ); + this.dataSourceUpdates = dataSourceUpdates; + this.dataSource = dataSourceFactory.createDataSource(context, dataSourceUpdates); + this.dataSourceStatusProvider = new DataSourceStatusProviderImpl(dataSourceStatusNotifier, dataSourceUpdates::getLastStatus); + Future startFuture = dataSource.start(); if (!config.startWait.isZero() && !config.startWait.isNegative()) { if (!(dataSource instanceof Components.NullDataSource)) { @@ -397,18 +458,23 @@ private Evaluator.EvalResult evaluateInternal(String featureKey, LDUser user, LD @Override public void registerFlagChangeListener(FlagChangeListener listener) { - flagChangeEventPublisher.register(listener); + flagChangeEventNotifier.register(listener); } @Override public void unregisterFlagChangeListener(FlagChangeListener listener) { - flagChangeEventPublisher.unregister(listener); + flagChangeEventNotifier.unregister(listener); } @Override public DataStoreStatusProvider getDataStoreStatusProvider() { return dataStoreStatusProvider; } + + @Override + public DataSourceStatusProvider getDataSourceStatusProvider() { + return dataSourceStatusProvider; + } @Override public void close() throws IOException { @@ -416,7 +482,8 @@ public void close() throws IOException { this.dataStore.close(); this.eventProcessor.close(); this.dataSource.close(); - this.flagChangeEventPublisher.close(); + this.dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.OFF, null); + this.sharedExecutor.shutdownNow(); } @Override @@ -454,6 +521,20 @@ public String version() { return CLIENT_VERSION; } + // This executor is used for a variety of SDK tasks such as flag change events, checking the data store + // status after an outage, and the poll task in polling mode. These are all tasks that we do not expect + // to be executing frequently so that it is acceptable to use a single thread to execute them one at a + // time rather than a thread pool, thus reducing the number of threads spawned by the SDK. This also + // has the benefit of producing predictable delivery order for event listener notifications. + private ScheduledExecutorService createSharedExecutor(LDConfig config) { + ThreadFactory threadFactory = new ThreadFactoryBuilder() + .setDaemon(true) + .setNameFormat("LaunchDarkly-tasks-%d") + .setPriority(config.threadPriority) + .build(); + return Executors.newSingleThreadScheduledExecutor(threadFactory); + } + private static String getClientVersion() { Class clazz = LDConfig.class; String className = clazz.getSimpleName() + ".class"; diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClientInterface.java b/src/main/java/com/launchdarkly/sdk/server/LDClientInterface.java index 20220e432..440ef3571 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClientInterface.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClientInterface.java @@ -3,6 +3,7 @@ import com.launchdarkly.sdk.EvaluationDetail; import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; @@ -263,6 +264,19 @@ public interface LDClientInterface extends Closeable { * @since 5.0.0 */ void unregisterFlagChangeListener(FlagChangeListener listener); + + /** + * Returns an interface for tracking the status of the data source. + *

    + * The data source is the mechanism that the SDK uses to get feature flag configurations, such as a + * streaming connection (the default) or poll requests. The {@link DataSourceStatusProvider} has methods + * for checking whether the data source is (as far as the SDK knows) currently operational and tracking + * changes in this status. + * + * @return a {@link DataSourceStatusProvider} + * @since 5.0.0 + */ + DataSourceStatusProvider getDataSourceStatusProvider(); /** * Returns an interface for tracking the status of a persistent data store. diff --git a/src/main/java/com/launchdarkly/sdk/server/LDConfig.java b/src/main/java/com/launchdarkly/sdk/server/LDConfig.java index 235ce9196..b9369aaca 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDConfig.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDConfig.java @@ -6,6 +6,8 @@ import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; import com.launchdarkly.sdk.server.interfaces.HttpConfigurationFactory; +import com.launchdarkly.sdk.server.interfaces.LoggingConfiguration; +import com.launchdarkly.sdk.server.interfaces.LoggingConfigurationFactory; import java.net.URI; import java.time.Duration; @@ -27,8 +29,10 @@ public final class LDConfig { final boolean diagnosticOptOut; final EventProcessorFactory eventProcessorFactory; final HttpConfiguration httpConfig; + final LoggingConfiguration loggingConfig; final boolean offline; final Duration startWait; + final int threadPriority; protected LDConfig(Builder builder) { this.dataStoreFactory = builder.dataStoreFactory; @@ -38,8 +42,11 @@ protected LDConfig(Builder builder) { this.httpConfig = builder.httpConfigFactory == null ? Components.httpConfiguration().createHttpConfiguration() : builder.httpConfigFactory.createHttpConfiguration(); + this.loggingConfig = (builder.loggingConfigFactory == null ? Components.logging() : builder.loggingConfigFactory). + createLoggingConfiguration(); this.offline = builder.offline; this.startWait = builder.startWait; + this.threadPriority = builder.threadPriority; } LDConfig(LDConfig config) { @@ -48,8 +55,10 @@ protected LDConfig(Builder builder) { this.diagnosticOptOut = config.diagnosticOptOut; this.eventProcessorFactory = config.eventProcessorFactory; this.httpConfig = config.httpConfig; + this.loggingConfig = config.loggingConfig; this.offline = config.offline; this.startWait = config.startWait; + this.threadPriority = config.threadPriority; } /** @@ -69,8 +78,10 @@ public static class Builder { private boolean diagnosticOptOut = false; private EventProcessorFactory eventProcessorFactory = null; private HttpConfigurationFactory httpConfigFactory = null; + private LoggingConfigurationFactory loggingConfigFactory = null; private boolean offline = false; private Duration startWait = DEFAULT_START_WAIT; + private int threadPriority = Thread.MIN_PRIORITY; /** * Creates a builder with all configuration parameters set to the default @@ -161,6 +172,21 @@ public Builder http(HttpConfigurationFactory factory) { this.httpConfigFactory = factory; return this; } + + /** + * Sets the SDK's logging configuration, using a factory object. This object is normally a + * configuration builder obtained from {@link Components#logging()}, which has methods + * for setting individual logging-related properties. + * + * @param factory the factory object + * @return the builder + * @since 5.0.0 + * @see Components#logging() + */ + public Builder logging(LoggingConfigurationFactory factory) { + this.loggingConfigFactory = factory; + return this; + } /** * Set whether this client is offline. @@ -194,6 +220,25 @@ public Builder startWait(Duration startWait) { return this; } + /** + * Set the priority to use for all threads created by the SDK. + *

    + * By default, the SDK's worker threads use {@code Thread.MIN_PRIORITY} so that they will yield to + * application threads if the JVM is busy. You may increase this if you want the SDK to be prioritized + * over some other low-priority tasks. + *

    + * Values outside the range of [{@code Thread.MIN_PRIORITY}, {@code Thread.MAX_PRIORITY}] will be set + * to the minimum or maximum. + * + * @param threadPriority the priority for SDK threads + * @return the builder + * @since 5.0.0 + */ + public Builder threadPriority(int threadPriority) { + this.threadPriority = Math.max(Thread.MIN_PRIORITY, Math.min(Thread.MAX_PRIORITY, threadPriority)); + return this; + } + /** * Builds the configured {@link com.launchdarkly.sdk.server.LDConfig} object. * diff --git a/src/main/java/com/launchdarkly/sdk/server/LoggingConfigurationImpl.java b/src/main/java/com/launchdarkly/sdk/server/LoggingConfigurationImpl.java new file mode 100644 index 000000000..f3ce2c872 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/LoggingConfigurationImpl.java @@ -0,0 +1,18 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.interfaces.LoggingConfiguration; + +import java.time.Duration; + +final class LoggingConfigurationImpl implements LoggingConfiguration { + private final Duration logDataSourceOutageAsErrorAfter; + + LoggingConfigurationImpl(Duration logDataSourceOutageAsErrorAfter) { + this.logDataSourceOutageAsErrorAfter = logDataSourceOutageAsErrorAfter; + } + + @Override + public Duration getLogDataSourceOutageAsErrorAfter() { + return logDataSourceOutageAsErrorAfter; + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreStatusManager.java b/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreStatusManager.java similarity index 53% rename from src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreStatusManager.java rename to src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreStatusManager.java index a59528420..ffb30daab 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreStatusManager.java +++ b/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreStatusManager.java @@ -1,21 +1,16 @@ -package com.launchdarkly.sdk.server.integrations; +package com.launchdarkly.sdk.server; -import com.google.common.util.concurrent.ThreadFactoryBuilder; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider.Status; -import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider.StatusListener; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.ArrayList; -import java.util.List; import java.util.concurrent.Callable; -import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.ThreadFactory; import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; /** * Used internally to encapsulate the data store status broadcasting mechanism for PersistentDataStoreWrapper. @@ -27,42 +22,33 @@ final class PersistentDataStoreStatusManager { private static final Logger logger = LoggerFactory.getLogger(PersistentDataStoreStatusManager.class); static final int POLL_INTERVAL_MS = 500; // visible for testing - private final List listeners = new ArrayList<>(); + private final Consumer statusUpdater; private final ScheduledExecutorService scheduler; private final Callable statusPollFn; private final boolean refreshOnRecovery; private volatile boolean lastAvailable; private volatile ScheduledFuture pollerFuture; - PersistentDataStoreStatusManager(boolean refreshOnRecovery, boolean availableNow, Callable statusPollFn) { + PersistentDataStoreStatusManager( + boolean refreshOnRecovery, + boolean availableNow, + Callable statusPollFn, + Consumer statusUpdater, + ScheduledExecutorService sharedExecutor + ) { this.refreshOnRecovery = refreshOnRecovery; this.lastAvailable = availableNow; this.statusPollFn = statusPollFn; - - ThreadFactory threadFactory = new ThreadFactoryBuilder() - .setNameFormat("LaunchDarkly-DataStoreStatusManager-%d") - .build(); - scheduler = Executors.newSingleThreadScheduledExecutor(threadFactory); - // Using newSingleThreadScheduledExecutor avoids ambiguity about execution order if we might have - // have a StatusNotificationTask happening soon after another one. - } - - synchronized void addStatusListener(StatusListener listener) { - listeners.add(listener); - } - - synchronized void removeStatusListener(StatusListener listener) { - listeners.remove(listener); + this.statusUpdater = statusUpdater; + this.scheduler = sharedExecutor; } void updateAvailability(boolean available) { - StatusListener[] copyOfListeners = null; synchronized (this) { if (lastAvailable == available) { return; } lastAvailable = available; - copyOfListeners = listeners.toArray(new StatusListener[listeners.size()]); } Status status = new Status(available, available && refreshOnRecovery); @@ -71,10 +57,7 @@ void updateAvailability(boolean available) { logger.warn("Persistent store is available again"); } - // Notify all the subscribers (on a worker thread, so we can't be blocked by a slow listener). - if (copyOfListeners.length > 0) { - scheduler.schedule(new StatusNotificationTask(status, copyOfListeners), 0, TimeUnit.MILLISECONDS); - } + statusUpdater.accept(status); // If the store has just become unavailable, start a poller to detect when it comes back. If it has // become available, stop any polling we are currently doing. @@ -102,7 +85,12 @@ public void run() { }; synchronized (this) { if (pollerFuture == null) { - pollerFuture = scheduler.scheduleAtFixedRate(pollerTask, POLL_INTERVAL_MS, POLL_INTERVAL_MS, TimeUnit.MILLISECONDS); + pollerFuture = scheduler.scheduleAtFixedRate( + pollerTask, + POLL_INTERVAL_MS, + POLL_INTERVAL_MS, + TimeUnit.MILLISECONDS + ); } } } @@ -111,28 +99,4 @@ public void run() { synchronized boolean isAvailable() { return lastAvailable; } - - void close() { - scheduler.shutdown(); - } - - private static final class StatusNotificationTask implements Runnable { - private final Status status; - private final StatusListener[] listeners; - - StatusNotificationTask(Status status, StatusListener[] listeners) { - this.status = status; - this.listeners = listeners; - } - - public void run() { - for (StatusListener listener: listeners) { - try { - listener.dataStoreStatusChanged(status); - } catch (Exception e) { - logger.error("Unexpected error from StatusListener: {0}", e); - } - } - } - } } diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapper.java b/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapper.java similarity index 91% rename from src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapper.java rename to src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapper.java index f5868ee3a..b202dd453 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapper.java +++ b/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapper.java @@ -1,4 +1,4 @@ -package com.launchdarkly.sdk.server.integrations; +package com.launchdarkly.sdk.server; import com.google.common.base.Optional; import com.google.common.cache.CacheBuilder; @@ -7,15 +7,16 @@ import com.google.common.collect.ImmutableList; import com.google.common.util.concurrent.ListeningExecutorService; import com.google.common.util.concurrent.MoreExecutors; -import com.google.common.util.concurrent.ThreadFactoryBuilder; import com.google.common.util.concurrent.UncheckedExecutionException; +import com.launchdarkly.sdk.server.integrations.PersistentDataStoreBuilder; import com.launchdarkly.sdk.server.interfaces.DataStore; -import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider.CacheStats; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.SerializedItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; import com.launchdarkly.sdk.server.interfaces.PersistentDataStore; import org.slf4j.Logger; @@ -28,9 +29,7 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.atomic.AtomicBoolean; import static com.google.common.collect.Iterables.concat; @@ -45,9 +44,8 @@ *

    * This class is only constructed by {@link PersistentDataStoreBuilder}. */ -final class PersistentDataStoreWrapper implements DataStore, DataStoreStatusProvider { +final class PersistentDataStoreWrapper implements DataStore { private static final Logger logger = LoggerFactory.getLogger(PersistentDataStoreWrapper.class); - private static final String CACHE_REFRESH_THREAD_POOL_NAME_FORMAT = "CachingStoreWrapper-refresher-pool-%d"; private final PersistentDataStore core; private final LoadingCache> itemCache; @@ -57,13 +55,15 @@ final class PersistentDataStoreWrapper implements DataStore, DataStoreStatusProv private final boolean cacheIndefinitely; private final Set cachedDataKinds = new HashSet<>(); // this map is used in pollForAvailability() private final AtomicBoolean inited = new AtomicBoolean(false); - private final ListeningExecutorService executorService; + private final ListeningExecutorService cacheExecutor; PersistentDataStoreWrapper( final PersistentDataStore core, Duration cacheTtl, PersistentDataStoreBuilder.StaleValuesPolicy staleValuesPolicy, - boolean recordCacheStats + boolean recordCacheStats, + DataStoreUpdates dataStoreUpdates, + ScheduledExecutorService sharedExecutor ) { this.core = core; @@ -71,7 +71,7 @@ final class PersistentDataStoreWrapper implements DataStore, DataStoreStatusProv itemCache = null; allCache = null; initCache = null; - executorService = null; + cacheExecutor = null; cacheIndefinitely = false; } else { cacheIndefinitely = cacheTtl.isNegative(); @@ -95,22 +95,26 @@ public Boolean load(String key) throws Exception { }; if (staleValuesPolicy == PersistentDataStoreBuilder.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); + cacheExecutor = MoreExecutors.listeningDecorator(sharedExecutor); // 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); + itemLoader = CacheLoader.asyncReloading(itemLoader, cacheExecutor); } else { - executorService = null; + cacheExecutor = null; } itemCache = newCacheBuilder(cacheTtl, staleValuesPolicy, recordCacheStats).build(itemLoader); allCache = newCacheBuilder(cacheTtl, staleValuesPolicy, recordCacheStats).build(allLoader); initCache = newCacheBuilder(cacheTtl, staleValuesPolicy, recordCacheStats).build(initLoader); } - statusManager = new PersistentDataStoreStatusManager(!cacheIndefinitely, true, this::pollAvailabilityAfterOutage); + statusManager = new PersistentDataStoreStatusManager( + !cacheIndefinitely, + true, + this::pollAvailabilityAfterOutage, + dataStoreUpdates::updateStatus, + sharedExecutor + ); } private static CacheBuilder newCacheBuilder( @@ -139,10 +143,6 @@ private static CacheBuilder newCacheBuilder( @Override public void close() throws IOException { - if (executorService != null) { - executorService.shutdownNow(); - } - statusManager.close(); core.close(); } @@ -309,23 +309,12 @@ public boolean upsert(DataKind kind, String key, ItemDescriptor item) { } return updated; } - - @Override - public Status getStoreStatus() { - return new Status(statusManager.isAvailable(), false); - } - + @Override - public boolean addStatusListener(StatusListener listener) { - statusManager.addStatusListener(listener); + public boolean isStatusMonitoringEnabled() { return true; } - @Override - public void removeStatusListener(StatusListener listener) { - statusManager.removeStatusListener(listener); - } - @Override public CacheStats getCacheStats() { if (itemCache == null || allCache == null) { diff --git a/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java b/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java index 6bb689245..76fccaa00 100644 --- a/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java @@ -1,9 +1,11 @@ package com.launchdarkly.sdk.server; import com.google.common.annotations.VisibleForTesting; -import com.google.common.util.concurrent.ThreadFactoryBuilder; import com.launchdarkly.sdk.server.interfaces.DataSource; -import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorInfo; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorKind; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.State; +import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; import com.launchdarkly.sdk.server.interfaces.SerializationException; import org.slf4j.Logger; @@ -12,28 +14,37 @@ import java.io.IOException; import java.time.Duration; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; -import static com.launchdarkly.sdk.server.Util.httpErrorMessage; -import static com.launchdarkly.sdk.server.Util.isHttpErrorRecoverable; +import static com.launchdarkly.sdk.server.Util.checkIfErrorIsRecoverableAndLog; +import static com.launchdarkly.sdk.server.Util.httpErrorDescription; final class PollingProcessor implements DataSource { private static final Logger logger = LoggerFactory.getLogger(PollingProcessor.class); + private static final String ERROR_CONTEXT_MESSAGE = "on polling request"; + private static final String WILL_RETRY_MESSAGE = "will retry at next scheduled poll interval"; @VisibleForTesting final FeatureRequestor requestor; - private final DataStoreUpdates dataStoreUpdates; + private final DataSourceUpdates dataSourceUpdates; + private final ScheduledExecutorService scheduler; @VisibleForTesting final Duration pollInterval; - private AtomicBoolean initialized = new AtomicBoolean(false); - private ScheduledExecutorService scheduler = null; + private final AtomicBoolean initialized = new AtomicBoolean(false); + private volatile ScheduledFuture task; + private volatile CompletableFuture initFuture; - PollingProcessor(FeatureRequestor requestor, DataStoreUpdates dataStoreUpdates, Duration pollInterval) { + PollingProcessor( + FeatureRequestor requestor, + DataSourceUpdates dataSourceUpdates, + ScheduledExecutorService sharedExecutor, + Duration pollInterval + ) { this.requestor = requestor; // note that HTTP configuration is applied to the requestor when it is created - this.dataStoreUpdates = dataStoreUpdates; + this.dataSourceUpdates = dataSourceUpdates; + this.scheduler = sharedExecutor; this.pollInterval = pollInterval; } @@ -45,44 +56,68 @@ public boolean isInitialized() { @Override public void close() throws IOException { logger.info("Closing LaunchDarkly PollingProcessor"); - if (scheduler != null) { - scheduler.shutdown(); - } requestor.close(); + + // Even though the shared executor will be shut down when the LDClient is closed, it's still good + // behavior to remove our polling task now - especially because we might be running in a test + // environment where there isn't actually an LDClient. + synchronized (this) { + if (task != null) { + task.cancel(false); + task = null; + } + } } @Override public Future start() { logger.info("Starting LaunchDarkly polling client with interval: " + pollInterval.toMillis() + " milliseconds"); - final CompletableFuture initFuture = new CompletableFuture<>(); - ThreadFactory threadFactory = new ThreadFactoryBuilder() - .setNameFormat("LaunchDarkly-PollingProcessor-%d") - .build(); - scheduler = Executors.newScheduledThreadPool(1, threadFactory); - - scheduler.scheduleAtFixedRate(() -> { - try { - FeatureRequestor.AllData allData = requestor.getAllData(); - dataStoreUpdates.init(allData.toFullDataSet()); - if (!initialized.getAndSet(true)) { - logger.info("Initialized LaunchDarkly client."); - initFuture.complete(null); - } - } catch (HttpErrorException e) { - logger.error(httpErrorMessage(e.getStatus(), "polling request", "will retry")); - if (!isHttpErrorRecoverable(e.getStatus())) { - scheduler.shutdown(); - initFuture.complete(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.toString()); - logger.debug(e.toString(), e); - } catch (SerializationException e) { - logger.error("Polling request received malformed data: {}", e.toString()); + + synchronized (this) { + if (initFuture != null) { + return initFuture; } - }, 0L, pollInterval.toMillis(), TimeUnit.MILLISECONDS); - + initFuture = new CompletableFuture<>(); + task = scheduler.scheduleAtFixedRate(this::poll, 0L, pollInterval.toMillis(), TimeUnit.MILLISECONDS); + } + return initFuture; } -} \ No newline at end of file + + private void poll() { + FeatureRequestor.AllData allData = null; + + try { + allData = requestor.getAllData(); + } catch (HttpErrorException e) { + ErrorInfo errorInfo = ErrorInfo.fromHttpError(e.getStatus()); + boolean recoverable = checkIfErrorIsRecoverableAndLog(logger, httpErrorDescription(e.getStatus()), + ERROR_CONTEXT_MESSAGE, e.getStatus(), WILL_RETRY_MESSAGE); + if (recoverable) { + dataSourceUpdates.updateStatus(State.INTERRUPTED, errorInfo); + } else { + dataSourceUpdates.updateStatus(State.OFF, errorInfo); + initFuture.complete(null); // if client is initializing, make it stop waiting; has no effect if already inited + } + } catch (IOException e) { + checkIfErrorIsRecoverableAndLog(logger, e.toString(), ERROR_CONTEXT_MESSAGE, 0, WILL_RETRY_MESSAGE); + dataSourceUpdates.updateStatus(State.INTERRUPTED, ErrorInfo.fromException(ErrorKind.NETWORK_ERROR, e)); + } catch (SerializationException e) { + logger.error("Polling request received malformed data: {}", e.toString()); + dataSourceUpdates.updateStatus(State.INTERRUPTED, ErrorInfo.fromException(ErrorKind.INVALID_DATA, e)); + } catch (Exception e) { + logger.error("Unexpected error from polling processor: {}", e.toString()); + logger.debug(e.toString(), e); + dataSourceUpdates.updateStatus(State.INTERRUPTED, ErrorInfo.fromException(ErrorKind.UNKNOWN, e)); + } + + if (allData != null && dataSourceUpdates.init(allData.toFullDataSet())) { + if (!initialized.getAndSet(true)) { + logger.info("Initialized LaunchDarkly client."); + dataSourceUpdates.updateStatus(State.VALID, null); + initFuture.complete(null); + } + } + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java index 83b376abf..ea9d80a12 100644 --- a/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java @@ -10,11 +10,14 @@ import com.launchdarkly.eventsource.UnsuccessfulResponseException; import com.launchdarkly.sdk.server.DataModel.VersionedData; import com.launchdarkly.sdk.server.interfaces.DataSource; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorInfo; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorKind; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.State; +import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; -import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; import com.launchdarkly.sdk.server.interfaces.SerializationException; @@ -24,6 +27,7 @@ import java.io.IOException; import java.net.URI; import java.time.Duration; +import java.time.Instant; import java.util.AbstractMap; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -32,10 +36,10 @@ import static com.launchdarkly.sdk.server.DataModel.ALL_DATA_KINDS; import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; +import static com.launchdarkly.sdk.server.Util.checkIfErrorIsRecoverableAndLog; import static com.launchdarkly.sdk.server.Util.configureHttpClientBuilder; import static com.launchdarkly.sdk.server.Util.getHeadersBuilderFor; -import static com.launchdarkly.sdk.server.Util.httpErrorMessage; -import static com.launchdarkly.sdk.server.Util.isHttpErrorRecoverable; +import static com.launchdarkly.sdk.server.Util.httpErrorDescription; import okhttp3.Headers; import okhttp3.OkHttpClient; @@ -45,9 +49,11 @@ * 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. + * 1. If any event is malformed, we must assume the stream is broken and we may have missed updates. Set the + * data source state to INTERRUPTED, with an error kind of INVALID_DATA, and restart the stream. * 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. + * data store. We don't have to log this error because it is logged by DataSourceUpdatesImpl, which will also set + * our state to INTERRUPTED for us. * 2a. If the data store supports status notifications (which all persistent stores normally do), then we can * assume it has entered a failed state and will notify us once it is working again. If and when it recovers, then * it will tell us whether we need to restart the stream (to ensure that we haven't missed any updates), or @@ -55,8 +61,8 @@ * 2b. If the data store doesn't support status notifications (which is normally only true of the in-memory store) * then we don't know the significance of the error, but we must assume that updates have been lost, so we'll * restart the stream. - * 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. + * 3. If we receive an unrecoverable error like HTTP 401, we close the stream and don't retry, and set the state + * to OFF. Any other HTTP error or network error causes a retry with backoff, with a state of INTERRUPTED. * 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 @@ -70,8 +76,10 @@ final class StreamProcessor implements DataSource { private static final String INDIRECT_PATCH = "indirect/patch"; private static final Logger logger = LoggerFactory.getLogger(StreamProcessor.class); private static final Duration DEAD_CONNECTION_INTERVAL = Duration.ofSeconds(300); + private static final String ERROR_CONTEXT_MESSAGE = "in stream connection"; + private static final String WILL_RETRY_MESSAGE = "will retry"; - private final DataStoreUpdates dataStoreUpdates; + private final DataSourceUpdates dataSourceUpdates; private final HttpConfiguration httpConfig; private final Headers headers; @VisibleForTesting final URI streamUri; @@ -79,6 +87,7 @@ final class StreamProcessor implements DataSource { @VisibleForTesting final FeatureRequestor requestor; private final DiagnosticAccumulator diagnosticAccumulator; private final EventSourceCreator eventSourceCreator; + private final int threadPriority; private final DataStoreStatusProvider.StatusListener statusListener; private volatile EventSource es; private final AtomicBoolean initialized = new AtomicBoolean(false); @@ -115,17 +124,19 @@ static interface EventSourceCreator { String sdkKey, HttpConfiguration httpConfig, FeatureRequestor requestor, - DataStoreUpdates dataStoreUpdates, + DataSourceUpdates dataSourceUpdates, EventSourceCreator eventSourceCreator, + int threadPriority, DiagnosticAccumulator diagnosticAccumulator, URI streamUri, Duration initialReconnectDelay ) { - this.dataStoreUpdates = dataStoreUpdates; + this.dataSourceUpdates = dataSourceUpdates; this.httpConfig = httpConfig; this.requestor = requestor; this.diagnosticAccumulator = diagnosticAccumulator; - this.eventSourceCreator = eventSourceCreator != null ? eventSourceCreator : StreamProcessor::defaultEventSourceCreator; + this.eventSourceCreator = eventSourceCreator != null ? eventSourceCreator : this::defaultEventSourceCreator; + this.threadPriority = threadPriority; this.streamUri = streamUri; this.initialReconnectDelay = initialReconnectDelay; @@ -133,9 +144,10 @@ static interface EventSourceCreator { .add("Accept", "text/event-stream") .build(); - DataStoreStatusProvider.StatusListener statusListener = this::onStoreStatusChanged; - if (dataStoreUpdates.getStatusProvider().addStatusListener(statusListener)) { - this.statusListener = statusListener; + if (dataSourceUpdates.getDataStoreStatusProvider() != null && + dataSourceUpdates.getDataStoreStatusProvider().isStatusMonitoringEnabled()) { + this.statusListener = this::onStoreStatusChanged; + dataSourceUpdates.getDataStoreStatusProvider().addStatusListener(statusListener); } else { this.statusListener = null; } @@ -158,15 +170,26 @@ private void onStoreStatusChanged(DataStoreStatusProvider.Status newStatus) { private ConnectionErrorHandler createDefaultConnectionErrorHandler() { return (Throwable t) -> { recordStreamInit(true); + if (t instanceof UnsuccessfulResponseException) { int status = ((UnsuccessfulResponseException)t).getCode(); - logger.error(httpErrorMessage(status, "streaming connection", "will retry")); - if (!isHttpErrorRecoverable(status)) { - return Action.SHUTDOWN; + ErrorInfo errorInfo = ErrorInfo.fromHttpError(status); + + boolean recoverable = checkIfErrorIsRecoverableAndLog(logger, httpErrorDescription(status), + ERROR_CONTEXT_MESSAGE, status, WILL_RETRY_MESSAGE); + if (recoverable) { + dataSourceUpdates.updateStatus(State.INTERRUPTED, errorInfo); + esStarted = System.currentTimeMillis(); + return Action.PROCEED; + } else { + dataSourceUpdates.updateStatus(State.OFF, errorInfo); + return Action.SHUTDOWN; } - esStarted = System.currentTimeMillis(); - return Action.PROCEED; } + + checkIfErrorIsRecoverableAndLog(logger, t.toString(), ERROR_CONTEXT_MESSAGE, 0, WILL_RETRY_MESSAGE); + ErrorInfo errorInfo = ErrorInfo.fromException(t instanceof IOException ? ErrorKind.NETWORK_ERROR : ErrorKind.UNKNOWN, t); + dataSourceUpdates.updateStatus(State.INTERRUPTED, errorInfo); return Action.PROCEED; }; } @@ -206,12 +229,13 @@ private void recordStreamInit(boolean failed) { public void close() throws IOException { logger.info("Closing LaunchDarkly StreamProcessor"); if (statusListener != null) { - dataStoreUpdates.getStatusProvider().removeStatusListener(statusListener); + dataSourceUpdates.getDataStoreStatusProvider().removeStatusListener(statusListener); } if (es != null) { es.close(); } requestor.close(); + dataSourceUpdates.updateStatus(State.OFF, null); } @Override @@ -263,17 +287,22 @@ public void onMessage(String name, MessageEvent event) throws Exception { break; } lastStoreUpdateFailed = false; + dataSourceUpdates.updateStatus(State.VALID, null); } catch (StreamInputException e) { logger.error("LaunchDarkly service request failed or received invalid data: {}", e.toString()); logger.debug(e.toString(), e); + + ErrorInfo errorInfo = new ErrorInfo( + e.getCause() instanceof IOException ? ErrorKind.NETWORK_ERROR : ErrorKind.INVALID_DATA, + 0, + e.getCause() == null ? e.getMessage() : e.getCause().toString(), + Instant.now() + ); + dataSourceUpdates.updateStatus(State.INTERRUPTED, errorInfo); + es.restart(); } catch (StreamStoreException e) { // See item 2 in error handling comments at top of class - if (!lastStoreUpdateFailed) { - logger.error("Unexpected data store failure when storing updates from stream: {}", - e.getCause().toString()); - logger.debug(e.getCause().toString(), e.getCause()); - } if (statusListener == null) { if (!lastStoreUpdateFailed) { logger.warn("Restarting stream to ensure that we have the latest data"); @@ -292,10 +321,8 @@ private void handlePut(String eventData) throws StreamInputException, StreamStor esStarted = 0; PutData putData = parseStreamJson(PutData.class, eventData); FullDataSet allData = putData.data.toFullDataSet(); - try { - dataStoreUpdates.init(allData); - } catch (Exception e) { - throw new StreamStoreException(e); + if (!dataSourceUpdates.init(allData)) { + throw new StreamStoreException(); } if (!initialized.getAndSet(true)) { initFuture.complete(null); @@ -312,10 +339,8 @@ private void handlePatch(String eventData) throws StreamInputException, StreamSt DataKind kind = kindAndKey.getKey(); String key = kindAndKey.getValue(); VersionedData item = deserializeFromParsedJson(kind, data.data); - try { - dataStoreUpdates.upsert(kind, key, new ItemDescriptor(item.getVersion(), item)); - } catch (Exception e) { - throw new StreamStoreException(e); + if (!dataSourceUpdates.upsert(kind, key, new ItemDescriptor(item.getVersion(), item))) { + throw new StreamStoreException(); } } @@ -328,10 +353,8 @@ private void handleDelete(String eventData) throws StreamInputException, StreamS DataKind kind = kindAndKey.getKey(); String key = kindAndKey.getValue(); ItemDescriptor placeholder = new ItemDescriptor(data.version, null); - try { - dataStoreUpdates.upsert(kind, key, placeholder); - } catch (Exception e) { - throw new StreamStoreException(e); + if (!dataSourceUpdates.upsert(kind, key, placeholder)) { + throw new StreamStoreException(); } } @@ -343,10 +366,8 @@ private void handleIndirectPut() throws StreamInputException, StreamStoreExcepti throw new StreamInputException(e); } FullDataSet allData = putData.toFullDataSet(); - try { - dataStoreUpdates.init(allData); - } catch (Exception e) { - throw new StreamStoreException(e); + if (!dataSourceUpdates.init(allData)) { + throw new StreamStoreException(); } if (!initialized.getAndSet(true)) { initFuture.complete(null); @@ -367,10 +388,8 @@ private void handleIndirectPatch(String path) throws StreamInputException, Strea // could be that the request to the polling endpoint failed in some other way. But either way, we must // assume that we did not get valid data from LD so we have missed an update. } - try { - dataStoreUpdates.upsert(kind, key, new ItemDescriptor(item.getVersion(), item)); - } catch (Exception e) { - throw new StreamStoreException(e); + if (!dataSourceUpdates.upsert(kind, key, new ItemDescriptor(item.getVersion(), item))) { + throw new StreamStoreException(); } } @@ -386,8 +405,9 @@ public void onError(Throwable throwable) { } } - private static EventSource defaultEventSourceCreator(EventSourceParams params) { + private EventSource defaultEventSourceCreator(EventSourceParams params) { EventSource.Builder builder = new EventSource.Builder(params.handler, params.streamUri) + .threadPriority(threadPriority) .clientBuilderActions(new EventSource.Builder.ClientConfigurer() { public void configure(OkHttpClient.Builder builder) { configureHttpClientBuilder(params.httpConfig, builder); @@ -450,11 +470,7 @@ public StreamInputException(Throwable 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 StreamStoreException extends Exception {} private static final class PutData { FeatureRequestor.AllData data; diff --git a/src/main/java/com/launchdarkly/sdk/server/Util.java b/src/main/java/com/launchdarkly/sdk/server/Util.java index b77811c61..881e07eac 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Util.java +++ b/src/main/java/com/launchdarkly/sdk/server/Util.java @@ -3,7 +3,10 @@ import com.launchdarkly.sdk.server.interfaces.HttpAuthentication; import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; +import org.slf4j.Logger; + import java.io.IOException; +import java.time.Duration; import java.util.concurrent.TimeUnit; import static com.google.common.collect.Iterables.transform; @@ -106,22 +109,46 @@ static boolean isHttpErrorRecoverable(int statusCode) { } /** - * Builds an appropriate log message for an HTTP error status. - * @param statusCode the HTTP status - * @param context description of what we were trying to do - * @param recoverableMessage description of our behavior if the error is recoverable; typically "will retry" - * @return a message string + * Logs an HTTP error or network error at the appropriate level and determines whether it is recoverable + * (as defined by {@link #isHttpErrorRecoverable(int)}). + * + * @param logger the logger to log to + * @param errorDesc description of the error + * @param errorContext a phrase like "when doing such-and-such" + * @param statusCode HTTP status code, or 0 for a network error + * @param recoverableMessage a phrase like "will retry" to use if the error is recoverable + * @return true if the error is recoverable */ - static String httpErrorMessage(int statusCode, String context, String recoverableMessage) { - StringBuilder sb = new StringBuilder(); - sb.append("Received HTTP error ").append(statusCode); - switch (statusCode) { - case 401: - case 403: - sb.append(" (invalid SDK key)"); + static boolean checkIfErrorIsRecoverableAndLog( + Logger logger, + String errorDesc, + String errorContext, + int statusCode, + String recoverableMessage + ) { + if (statusCode > 0 && !isHttpErrorRecoverable(statusCode)) { + logger.error("Error {} (giving up permanently): {}", errorContext, errorDesc); + return false; + } else { + logger.warn("Error {} ({}): {}", errorContext, recoverableMessage, errorDesc); + return true; + } + } + + static String httpErrorDescription(int statusCode) { + return "HTTP error " + statusCode + + (statusCode == 401 || statusCode == 403 ? " (invalid SDK key)" : ""); + } + + static String describeDuration(Duration d) { + if (d.toMillis() % 1000 == 0) { + if (d.toMillis() % 60000 == 0) { + return d.toMinutes() + (d.toMinutes() == 1 ? " minute" : " minutes"); + } else { + long sec = d.toMillis() / 1000; + return sec + (sec == 1 ? " second" : " seconds"); + } } - sb.append(" for ").append(context).append(" - "); - sb.append(isHttpErrorRecoverable(statusCode) ? recoverableMessage : "giving up permanently"); - return sb.toString(); + return d.toMillis() + " milliseconds"; } } diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/EventProcessorBuilder.java b/src/main/java/com/launchdarkly/sdk/server/integrations/EventProcessorBuilder.java index 80274006e..271c4e204 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/EventProcessorBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/EventProcessorBuilder.java @@ -3,6 +3,8 @@ import com.launchdarkly.sdk.UserAttribute; import com.launchdarkly.sdk.server.Components; import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; +import com.launchdarkly.sdk.server.interfaces.EventSender; +import com.launchdarkly.sdk.server.interfaces.EventSenderFactory; import java.net.URI; import java.time.Duration; @@ -66,6 +68,7 @@ public abstract class EventProcessorBuilder implements EventProcessorFactory { protected Set privateAttributes; protected int userKeysCapacity = DEFAULT_USER_KEYS_CAPACITY; protected Duration userKeysFlushInterval = DEFAULT_USER_KEYS_FLUSH_INTERVAL; + protected EventSenderFactory eventSenderFactory = null; /** * Sets whether or not all optional user attributes should be hidden from LaunchDarkly. @@ -141,6 +144,21 @@ public EventProcessorBuilder diagnosticRecordingInterval(Duration diagnosticReco 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/sdk/server/integrations/FileDataSourceBuilder.java b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBuilder.java index 429f27382..77ba1f215 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBuilder.java @@ -3,7 +3,7 @@ import com.launchdarkly.sdk.server.interfaces.ClientContext; import com.launchdarkly.sdk.server.interfaces.DataSource; import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; -import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; +import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; import java.nio.file.InvalidPathException; import java.nio.file.Path; @@ -77,7 +77,7 @@ public FileDataSourceBuilder autoUpdate(boolean autoUpdate) { * Used internally by the LaunchDarkly client. */ @Override - public DataSource createDataSource(ClientContext context, DataStoreUpdates dataStoreUpdates) { - return new FileDataSourceImpl(dataStoreUpdates, sources, autoUpdate); + public DataSource createDataSource(ClientContext context, DataSourceUpdates dataSourceUpdates) { + return new FileDataSourceImpl(dataSourceUpdates, sources, autoUpdate); } } \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java index f4d93ffcb..02883af0b 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java @@ -7,11 +7,14 @@ import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FlagFileParser; import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FlagFileRep; import com.launchdarkly.sdk.server.interfaces.DataSource; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorInfo; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorKind; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.State; +import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; -import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -26,6 +29,7 @@ import java.nio.file.WatchKey; import java.nio.file.WatchService; import java.nio.file.Watchable; +import java.time.Instant; import java.util.AbstractMap; import java.util.ArrayList; import java.util.HashMap; @@ -50,13 +54,13 @@ final class FileDataSourceImpl implements DataSource { private static final Logger logger = LoggerFactory.getLogger(FileDataSourceImpl.class); - private final DataStoreUpdates dataStoreUpdates; + private final DataSourceUpdates dataSourceUpdates; private final DataLoader dataLoader; private final AtomicBoolean inited = new AtomicBoolean(false); private final FileWatcher fileWatcher; - FileDataSourceImpl(DataStoreUpdates dataStoreUpdates, List sources, boolean autoUpdate) { - this.dataStoreUpdates = dataStoreUpdates; + FileDataSourceImpl(DataSourceUpdates dataSourceUpdates, List sources, boolean autoUpdate) { + this.dataSourceUpdates = dataSourceUpdates; this.dataLoader = new DataLoader(sources); FileWatcher fw = null; @@ -82,9 +86,7 @@ public Future start() { // if we are told to reload by the file watcher. if (fileWatcher != null) { - fileWatcher.start(() -> { - FileDataSourceImpl.this.reload(); - }); + fileWatcher.start(this::reload); } return initFuture; @@ -96,9 +98,12 @@ private boolean reload() { dataLoader.load(builder); } catch (FileDataException e) { logger.error(e.getDescription()); + dataSourceUpdates.updateStatus(State.INTERRUPTED, + new ErrorInfo(ErrorKind.INVALID_DATA, 0, e.getDescription(), Instant.now())); return false; } - dataStoreUpdates.init(builder.build()); + dataSourceUpdates.init(builder.build()); + dataSourceUpdates.updateStatus(State.VALID, null); inited.set(true); return true; } diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/LoggingConfigurationBuilder.java b/src/main/java/com/launchdarkly/sdk/server/integrations/LoggingConfigurationBuilder.java new file mode 100644 index 000000000..9816f8962 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/LoggingConfigurationBuilder.java @@ -0,0 +1,57 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.interfaces.LoggingConfigurationFactory; + +import java.time.Duration; + +/** + * Contains methods for configuring the SDK's logging behavior. + *

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

    
    + *     LDConfig config = new LDConfig.Builder()
    + *         .logging(
    + *           Components.logging()
    + *             .logDataSourceOutageAsErrorAfter(Duration.ofSeconds(120))
    + *          )
    + *         .build();
    + * 
    + *

    + * Note that this class is abstract; the actual implementation is created by calling {@link Components#logging()}. + * + * @since 5.0.0 + */ +public abstract class LoggingConfigurationBuilder implements LoggingConfigurationFactory { + /** + * The default value for {@link #logDataSourceOutageAsErrorAfter(Duration)}: one minute. + */ + public static final Duration DEFAULT_LOG_DATA_SOURCE_OUTAGE_AS_ERROR_AFTER = Duration.ofMinutes(1); + + protected Duration logDataSourceOutageAsErrorAfter = DEFAULT_LOG_DATA_SOURCE_OUTAGE_AS_ERROR_AFTER; + + /** + * Sets the time threshold, if any, after which the SDK will log a data source outage at {@code ERROR} + * level instead of {@code WARN} level. + *

    + * A data source outage means that an error condition, such as a network interruption or an error from + * the LaunchDarkly service, is preventing the SDK from receiving feature flag updates. Many outages are + * brief and the SDK can recover from them quickly; in that case it may be undesirable to log an + * {@code ERROR} line, which might trigger an unwanted automated alert depending on your monitoring + * tools. So, by default, the SDK logs such errors at {@code WARN} level. However, if the amount of time + * specified by this method elapses before the data source starts working again, the SDK will log an + * additional message at {@code ERROR} level to indicate that this is a sustained problem. + *

    + * The default is {@link #DEFAULT_LOG_DATA_SOURCE_OUTAGE_AS_ERROR_AFTER}. Setting it to {@code null} + * will disable this feature, so you will only get {@code WARN} messages. + * + * @param logDataSourceOutageAsErrorAfter the error logging threshold, or null + * @return the builder + */ + public LoggingConfigurationBuilder logDataSourceOutageAsErrorAfter(Duration logDataSourceOutageAsErrorAfter) { + this.logDataSourceOutageAsErrorAfter = logDataSourceOutageAsErrorAfter; + return this; + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreBuilder.java b/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreBuilder.java index d80e825e6..50608411f 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreBuilder.java @@ -1,11 +1,8 @@ package com.launchdarkly.sdk.server.integrations; import com.launchdarkly.sdk.server.Components; -import com.launchdarkly.sdk.server.interfaces.ClientContext; -import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; -import com.launchdarkly.sdk.server.interfaces.PersistentDataStore; import com.launchdarkly.sdk.server.interfaces.PersistentDataStoreFactory; import java.time.Duration; @@ -33,7 +30,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. + * {@code cacheSeconds()} 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)}. @@ -45,10 +42,10 @@ public abstract class PersistentDataStoreBuilder implements DataStoreFactory { */ public static final Duration DEFAULT_CACHE_TTL = Duration.ofSeconds(15); - protected final PersistentDataStoreFactory persistentDataStoreFactory; // see Components for why this is not private - private Duration cacheTime = DEFAULT_CACHE_TTL; - private StaleValuesPolicy staleValuesPolicy = StaleValuesPolicy.EVICT; - private boolean recordCacheStats = false; + protected final PersistentDataStoreFactory persistentDataStoreFactory; // see Components for why these are not private + protected Duration cacheTime = DEFAULT_CACHE_TTL; + protected StaleValuesPolicy staleValuesPolicy = StaleValuesPolicy.EVICT; + protected boolean recordCacheStats = false; /** * Possible values for {@link #staleValuesPolicy(StaleValuesPolicy)}. @@ -196,13 +193,4 @@ public PersistentDataStoreBuilder recordCacheStats(boolean recordCacheStats) { this.recordCacheStats = recordCacheStats; return this; } - - /** - * Called by the SDK to create the data store instance. - */ - @Override - public DataStore createDataStore(ClientContext context) { - PersistentDataStore core = persistentDataStoreFactory.createPersistentDataStore(context); - return new PersistentDataStoreWrapper(core, cacheTime, staleValuesPolicy, recordCacheStats); - } } diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/ClientContext.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/ClientContext.java index 6c43e21c1..5320a9eb5 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/ClientContext.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/ClientContext.java @@ -3,9 +3,13 @@ /** * Context information provided by the {@link com.launchdarkly.sdk.server.LDClient} when creating components. *

    - * This is passed as a parameter to {@link DataStoreFactory#createDataStore(ClientContext)}, etc. The - * actual implementation class may contain other properties that are only relevant to the built-in SDK - * components and are therefore not part of the public interface; this allows the SDK to add its own + * This is passed as a parameter to {@link DataStoreFactory#createDataStore(ClientContext, DataStoreUpdates)}, + * etc. Component factories do not receive the entire {@link com.launchdarkly.sdk.server.LDConfig} because + * it could contain factory objects that have mutable state, and because components should not be able + * to access the configurations of unrelated components. + *

    + * The actual implementation class may contain other properties that are only relevant to the built-in + * SDK components and are therefore not part of the public interface; this allows the SDK to add its own * context information as needed without disturbing the public API. * * @since 5.0.0 @@ -13,19 +17,37 @@ public interface ClientContext { /** * The current {@link com.launchdarkly.sdk.server.LDClient} instance's SDK key. + * * @return the SDK key */ public String getSdkKey(); /** - * True if {@link com.launchdarkly.sdk.server.LDConfig.Builder#offline(boolean)} was set to true. + * True if the SDK was configured to be completely offline. + * * @return the offline status + * @see com.launchdarkly.sdk.server.LDConfig.Builder#offline(boolean) */ public boolean isOffline(); /** * The configured networking properties that apply to all components. + * * @return the HTTP configuration */ public HttpConfiguration getHttpConfiguration(); + + /** + * The configured logging properties that apply to all components. + * @return the logging configuration + */ + public LoggingConfiguration getLoggingConfiguration(); + + /** + * The thread priority that should be used for any worker threads created by SDK components. + * + * @return the thread priority + * @see com.launchdarkly.sdk.server.LDConfig.Builder#threadPriority(int) + */ + public int getThreadPriority(); } diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSource.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSource.java index 848420cb3..87075dad3 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSource.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSource.java @@ -7,6 +7,19 @@ /** * Interface for an object that receives updates to feature flags, user segments, and anything * else that might come from LaunchDarkly, and passes them to a {@link DataStore}. + *

    + * The standard implementations are: + *

      + *
    • {@link com.launchdarkly.sdk.server.Components#streamingDataSource()} (the default), which + * maintains a streaming connection to LaunchDarkly; + *
    • {@link com.launchdarkly.sdk.server.Components#pollingDataSource()}, which polls for + * updates at regular intervals; + *
    • {@link com.launchdarkly.sdk.server.Components#externalUpdatesOnly()}, which does nothing + * (on the assumption that another process will update the data store); + *
    • {@link com.launchdarkly.sdk.server.integrations.FileData}, which reads flag data from + * the filesystem. + *
    + * * @since 5.0.0 */ public interface DataSource extends Closeable { diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceFactory.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceFactory.java index 87f9c1482..f29ef7846 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceFactory.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceFactory.java @@ -10,10 +10,13 @@ public interface DataSourceFactory { /** * Creates an implementation instance. + *

    + * The new {@code DataSource} should not attempt to make any connections until + * {@link DataSource#start()} is called. * * @param context allows access to the client configuration - * @param dataStoreUpdates the component pushes data into the SDK via this interface + * @param dataSourceUpdates the component pushes data into the SDK via this interface * @return an {@link DataSource} */ - public DataSource createDataSource(ClientContext context, DataStoreUpdates dataStoreUpdates); + public DataSource createDataSource(ClientContext context, DataSourceUpdates dataSourceUpdates); } diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProvider.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProvider.java new file mode 100644 index 000000000..e103a44e2 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProvider.java @@ -0,0 +1,351 @@ +package com.launchdarkly.sdk.server.interfaces; + +import com.google.common.base.Strings; + +import java.time.Instant; +import java.util.Objects; + +/** + * An interface for querying the status of a {@link DataSource}. + *

    + * An implementation of this interface is returned by {@link com.launchdarkly.sdk.server.LDClientInterface#getDataSourceStatusProvider}. + * Application code never needs to implement this interface. + * + * @since 5.0.0 + */ +public interface DataSourceStatusProvider { + /** + * Returns the current status of the data source. + *

    + * All of the built-in data source implementations are guaranteed to update this status whenever they + * successfully initialize, encounter an error, or recover after an error. + *

    + * For a custom data source implementation, it is the responsibility of the data source to report its + * status via {@link DataSourceUpdates}; if it does not do so, the status will always be reported as + * {@link State#INITIALIZING}. + * + * @return the latest status; will never be null + */ + public Status getStatus(); + + /** + * Subscribes for notifications of status changes. + *

    + * The listener will be notified whenever any property of the status has changed. See {@link Status} for an + * explanation of the meaning of each property and what could cause it to change. + *

    + * Notifications will be dispatched on a worker thread. It is the listener's responsibility to return as soon as + * possible so as not to block subsequent notifications. + * + * @param listener the listener to add + */ + public void addStatusListener(StatusListener listener); + + /** + * Unsubscribes from notifications of status changes. + * + * @param listener the listener to remove; if no such listener was added, this does nothing + */ + public void removeStatusListener(StatusListener listener); + + /** + * An enumeration of possible values for {@link DataSourceStatusProvider.Status#getState()}. + */ + public enum State { + /** + * The initial state of the data source when the SDK is being initialized. + *

    + * If it encounters an error that requires it to retry initialization, the state will remain at + * {@link #INITIALIZING} until it either succeeds and becomes {@link #VALID}, or permanently fails and + * becomes {@link #OFF}. + */ + INITIALIZING, + + /** + * Indicates that the data source is currently operational and has not had any problems since the + * last time it received data. + *

    + * In streaming mode, this means that there is currently an open stream connection and that at least + * one initial message has been received on the stream. In polling mode, it means that the last poll + * request succeeded. + */ + VALID, + + /** + * Indicates that the data source encountered an error that it will attempt to recover from. + *

    + * In streaming mode, this means that the stream connection failed, or had to be dropped due to some + * other error, and will be retried after a backoff delay. In polling mode, it means that the last poll + * request failed, and a new poll request will be made after the configured polling interval. + */ + INTERRUPTED, + + /** + * Indicates that the data source has been permanently shut down. + *

    + * This could be because it encountered an unrecoverable error (for instance, the LaunchDarkly service + * rejected the SDK key; an invalid SDK key will never become valid), or because the SDK client was + * explicitly shut down. + */ + OFF; + } + + /** + * An enumeration describing the general type of an error reported in {@link ErrorInfo}. + * + * @see ErrorInfo#getKind() + */ + public static enum ErrorKind { + /** + * An unexpected error, such as an uncaught exception, further described by {@link ErrorInfo#getMessage()}. + */ + UNKNOWN, + + /** + * An I/O error such as a dropped connection. + */ + NETWORK_ERROR, + + /** + * The LaunchDarkly service returned an HTTP response with an error status, available with + * {@link ErrorInfo#getStatusCode()}. + */ + ERROR_RESPONSE, + + /** + * The SDK received malformed data from the LaunchDarkly service. + */ + INVALID_DATA, + + /** + * The data source itself is working, but when it tried to put an update into the data store, the data + * store failed (so the SDK may not have the latest data). + *

    + * Data source implementations do not need to report this kind of error; it will be automatically + * reported by the SDK whenever one of the update methods of {@link DataSourceUpdates} throws an exception. + */ + STORE_ERROR + } + + /** + * A description of an error condition that the data source encountered, + * + * @see Status#getLastError() + */ + public static final class ErrorInfo { + private final ErrorKind kind; + private final int statusCode; + private final String message; + private final Instant time; + + /** + * Constructs an instance. + * + * @param kind the general category of the error + * @param statusCode an HTTP status or zero + * @param message an error message if applicable, or null + * @param time the error timestamp + */ + public ErrorInfo(ErrorKind kind, int statusCode, String message, Instant time) { + this.kind = kind; + this.statusCode = statusCode; + this.message = message; + this.time = time; + } + + /** + * Constructs an instance based on an exception. + * + * @param kind the general category of the error + * @param t the exception + * @return an ErrorInfo + */ + public static ErrorInfo fromException(ErrorKind kind, Throwable t) { + return new ErrorInfo(kind, 0, t.toString(), Instant.now()); + } + + /** + * Constructs an instance based on an HTTP error status. + * + * @param statusCode the status code + * @return an ErrorInfo + */ + public static ErrorInfo fromHttpError(int statusCode) { + return new ErrorInfo(ErrorKind.ERROR_RESPONSE, statusCode, null, Instant.now()); + } + + /** + * Returns an enumerated value representing the general category of the error. + * + * @return the general category of the error + */ + public ErrorKind getKind() { + return kind; + } + + /** + * Returns the HTTP status code if the error was {@link ErrorKind#ERROR_RESPONSE}, or zero otherwise. + * + * @return an HTTP status or zero + */ + public int getStatusCode() { + return statusCode; + } + + /** + * Returns any additional human-readable information relevant to the error. The format of this message + * is subject to change and should not be relied on programmatically. + * + * @return an error message if applicable, or null + */ + public String getMessage() { + return message; + } + + /** + * Returns the date/time that the error occurred. + * + * @return the error timestamp + */ + public Instant getTime() { + return time; + } + + @Override + public boolean equals(Object other) { + if (other instanceof ErrorInfo) { + ErrorInfo o = (ErrorInfo)other; + return kind == o.kind && statusCode == o.statusCode && Objects.equals(message, o.message) && + Objects.equals(time, o.time); + } + return false; + } + + @Override + public int hashCode() { + return Objects.hash(kind, statusCode, message, time); + } + + @Override + public String toString() { + StringBuilder s = new StringBuilder(); + s.append(kind.toString()); + if (statusCode > 0 || !Strings.isNullOrEmpty(message)) { + s.append("("); + if (statusCode > 0) { + s.append(statusCode); + } + if (!Strings.isNullOrEmpty(message)) { + if (statusCode > 0) { + s.append(","); + } + s.append(message); + } + s.append(")"); + } + if (time != null) { + s.append("@"); + s.append(time.toString()); + } + return s.toString(); + } + } + + /** + * Information about the data source's status and about the last status change. + */ + public static final class Status { + private final State state; + private final Instant stateSince; + private final ErrorInfo lastError; + + /** + * Constructs a new instance. + * + * @param state the basic state as an enumeration + * @param stateSince timestamp of the last state transition + * @param lastError a description of the last error, or null if no errors have occurred since startup + */ + public Status(State state, Instant stateSince, ErrorInfo lastError) { + this.state = state; + this.stateSince = stateSince; + this.lastError = lastError; + } + + /** + * Returns an enumerated value representing the overall current state of the data source. + * + * @return the basic state + */ + public State getState() { + return state; + } + + /** + * Returns the date/time that the value of {@link #getState()} most recently changed. + *

    + * The meaning of this depends on the current state: + *

      + *
    • For {@link State#INITIALIZING}, it is the time that the SDK started initializing. + *
    • For {@link State#VALID}, it is the time that the data source most recently entered a valid + * state, after previously having been either {@link State#INITIALIZING} or {@link State#INTERRUPTED}. + *
    • For {@link State#INTERRUPTED}, it is the time that the data source most recently entered an + * error state, after previously having been {@link State#VALID}. + *
    • For {@link State#OFF}, it is the time that the data source encountered an unrecoverable error + * or that the SDK was explicitly shut down. + *
    + * + * @return the timestamp of the last state change + */ + public Instant getStateSince() { + return stateSince; + } + + /** + * Returns information about the last error that the data source encountered, if any. + *

    + * This property should be updated whenever the data source encounters a problem, even if it does + * not cause {@link #getState()} to change. For instance, if a stream connection fails and the + * state changes to {@link State#INTERRUPTED}, and then subsequent attempts to restart the + * connection also fail, the state will remain {@link State#INTERRUPTED} but the error information + * will be updated each time-- and the last error will still be reported in this property even if + * the state later becomes {@link State#VALID}. + * + * @return a description of the last error, or null if no errors have occurred since startup + */ + public ErrorInfo getLastError() { + return lastError; + } + + @Override + public boolean equals(Object other) { + if (other instanceof Status) { + Status o = (Status)other; + return state == o.state && Objects.equals(stateSince, o.stateSince) && Objects.equals(lastError, o.lastError); + } + return false; + } + + @Override + public int hashCode() { + return Objects.hash(state, stateSince, lastError); + } + + @Override + public String toString() { + return "Status(" + state + "," + stateSince + "," + lastError + ")"; + } + } + + /** + * Interface for receiving status change notifications. + */ + public static interface StatusListener { + /** + * Called when any property of the data source status has changed. + * + * @param newStatus the new status + */ + public void dataSourceStatusChanged(Status newStatus); + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceUpdates.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceUpdates.java new file mode 100644 index 000000000..730c784cd --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceUpdates.java @@ -0,0 +1,81 @@ +package com.launchdarkly.sdk.server.interfaces; + +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; + +/** + * Interface that a data source implementation will use to push data into the SDK. + *

    + * The data source interacts with this object, rather than manipulating the data store directly, so + * that the SDK can perform any other necessary operations that must happen when data is updated. + * + * @since 5.0.0 + */ +public interface DataSourceUpdates { + /** + * Completely overwrites the current contents of the data store with a set of items for each collection. + *

    + * If the underlying data store throws an error during this operation, the SDK will catch it, log it, + * and set the data source state to {@link DataSourceStatusProvider.State#INTERRUPTED} with an error of + * {@link DataSourceStatusProvider.ErrorKind#STORE_ERROR}. It will not rethrow the error to the data + * source, but will simply return {@code false} to indicate that the operation failed. + * + * @param allData a list of {@link DataStoreTypes.DataKind} instances and their corresponding data sets + * @return true if the update succeeded, false if it failed + */ + boolean init(FullDataSet allData); + + /** + * Updates or inserts an item in the specified collection. For updates, the object will only be + * updated if the existing version is less than the new version. + *

    + * To mark an item as deleted, pass an {@link ItemDescriptor} that contains a null, with a version + * number (you may use {@link ItemDescriptor#deletedItem(int)}). Deletions must be versioned so that + * they do not overwrite a later update in case updates are received out of order. + *

    + * If the underlying data store throws an error during this operation, the SDK will catch it, log it, + * and set the data source state to {@link DataSourceStatusProvider.State#INTERRUPTED} with an error of + * {@link DataSourceStatusProvider.ErrorKind#STORE_ERROR}. It will not rethrow the error to the data + * source, but will simply return {@code false} to indicate that the operation failed. + * + * @param kind specifies which collection to use + * @param key the unique key for the item within that collection + * @param item the item to insert or update + * @return true if the update succeeded, false if it failed + */ + boolean upsert(DataKind kind, String key, ItemDescriptor item); + + /** + * Returns an object that provides status tracking for the data store, if applicable. + *

    + * This may be useful if the data source needs to be aware of storage problems that might require it + * to take some special action: for instance, if a database outage may have caused some data to be + * lost and therefore the data should be re-requested from LaunchDarkly. + * + * @return a {@link DataStoreStatusProvider} + */ + DataStoreStatusProvider getDataStoreStatusProvider(); + + /** + * Informs the SDK of a change in the data source's status. + *

    + * Data source implementations should use this method if they have any concept of being in a valid + * state, a temporarily disconnected state, or a permanently stopped state. + *

    + * If {@code newState} is different from the previous state, and/or {@code newError} is non-null, the + * SDK will start returning the new status (adding a timestamp for the change) from + * {@link DataSourceStatusProvider#getStatus()}, and will trigger status change events to any + * registered listeners. + *

    + * A special case is that if {@code newState} is {@link DataSourceStatusProvider.State#INTERRUPTED}, + * but the previous state was {@link DataSourceStatusProvider.State#INITIALIZING}, the state will remain + * at {@link DataSourceStatusProvider.State#INITIALIZING} because {@link DataSourceStatusProvider.State#INTERRUPTED} + * is only meaningful after a successful startup. + * + * @param newState the data source state + * @param newError information about a new error, if any + * @see DataSourceStatusProvider + */ + void updateStatus(DataSourceStatusProvider.State newState, DataSourceStatusProvider.ErrorInfo newError); +} diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStore.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStore.java index 83f8d34cd..fbfb648a3 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStore.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStore.java @@ -1,5 +1,6 @@ package com.launchdarkly.sdk.server.interfaces; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider.CacheStats; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; @@ -78,4 +79,28 @@ public interface DataStore extends Closeable { * @return true if the store contains data */ boolean isInitialized(); + + /** + * Returns true if this data store implementation supports status monitoring. + *

    + * This is normally only true for persistent data stores created with + * {@link com.launchdarkly.sdk.server.Components#persistentDataStore(PersistentDataStoreFactory)}, + * but it could also be true for any custom {@link DataStore} implementation that makes use of the + * {@code statusUpdater} parameter provided to {@link DataStoreFactory#createDataStore(ClientContext, DataStoreUpdates)}. + * Returning true means that the store guarantees that if it ever enters an invalid state (that is, an + * operation has failed or it knows that operations cannot succeed at the moment), it will publish a + * status update, and will then publish another status update once it has returned to a valid state. + *

    + * The same value will be returned from {@link DataStoreStatusProvider#isStatusMonitoringEnabled()}. + * + * @return true if status monitoring is enabled + */ + boolean isStatusMonitoringEnabled(); + + /** + * Returns statistics about cache usage, if this data store implementation supports caching. + * + * @return a cache statistics object, or null if not applicable + */ + CacheStats getCacheStats(); } diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreFactory.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreFactory.java index 0ed2456ad..adfb1e06e 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreFactory.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreFactory.java @@ -12,7 +12,9 @@ public interface DataStoreFactory { * Creates an implementation instance. * * @param context allows access to the client configuration + * @param dataStoreUpdates the data store can use this object to report information back to + * the SDK if desired * @return a {@link DataStore} */ - DataStore createDataStore(ClientContext context); + DataStore createDataStore(ClientContext context, DataStoreUpdates dataStoreUpdates); } diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProvider.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProvider.java index 25b43b08b..35d602e52 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProvider.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProvider.java @@ -8,20 +8,34 @@ * An interface for querying the status of a persistent data store. *

    * An implementation of this interface is returned by {@link com.launchdarkly.sdk.server.LDClientInterface#getDataStoreStatusProvider}. - * If the data store is a persistent data store, then these methods are implemented by the SDK; if it is a custom - * class that implements this interface, then these methods delegate to the corresponding methods of the class; - * if it is the default in-memory data store, then these methods do nothing and return null values. + * Application code should not implement this interface. * * @since 5.0.0 */ public interface DataStoreStatusProvider { /** * Returns the current status of the store. + *

    + * This is only meaningful for persistent stores, or any other {@link DataStore} implementation that makes use of + * the reporting mechanism provided by {@link DataStoreFactory#createDataStore(ClientContext, DataStoreUpdates)}. + * For the default in-memory store, the status will always be reported as "available". * - * @return the latest status, or null if not available + * @return the latest status; will never be null */ public Status getStoreStatus(); + /** + * Indicates whether the current data store implementation supports status monitoring. + *

    + * This is normally true for all persistent data stores, and false for the default in-memory store. A true value + * means that any listeners added with {@link #addStatusListener(StatusListener)} can expect to be notified if + * there is any error in storing data, and then notified again when the error condition is resolved. A false + * value means that the status is not meaningful and listeners should not expect to be notified. + * + * @return true if status monitoring is enabled + */ + public boolean isStatusMonitoringEnabled(); + /** * Subscribes for notifications of status changes. *

    @@ -38,10 +52,8 @@ public interface DataStoreStatusProvider { * are using the default in-memory store rather than a persistent store. * * @param listener the listener to add - * @return true if the listener was added, or was already registered; false if the data store does not support - * status tracking */ - public boolean addStatusListener(StatusListener listener); + public void addStatusListener(StatusListener listener); /** * Unsubscribes from notifications of status changes. @@ -105,6 +117,25 @@ public boolean isAvailable() { public boolean isRefreshNeeded() { return refreshNeeded; } + + @Override + public boolean equals(Object other) { + if (other instanceof Status) { + Status o = (Status)other; + return available == o.available && refreshNeeded == o.refreshNeeded; + } + return false; + } + + @Override + public int hashCode() { + return Objects.hash(available, refreshNeeded); + } + + @Override + public String toString() { + return "Status(" + available + "," + refreshNeeded + ")"; + } } /** diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypes.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypes.java index 04fda02f9..7c9f9c800 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypes.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypes.java @@ -8,6 +8,9 @@ /** * Types that are used by the {@link DataStore} interface. + *

    + * Applications should never need to use any of these types unless they are implementing a custom + * data store. * * @since 5.0.0 */ @@ -264,6 +267,16 @@ public Iterable>> getData() { public FullDataSet(Iterable>> data) { this.data = data == null ? ImmutableList.of(): data; } + + @Override + public boolean equals(Object o) { + return o instanceof FullDataSet && data.equals(((FullDataSet)o).data); + } + + @Override + public int hashCode() { + return data.hashCode(); + } } /** @@ -292,5 +305,15 @@ public Iterable> getItems() { public KeyedItems(Iterable> items) { this.items = items == null ? ImmutableList.of() : items; } + + @Override + public boolean equals(Object o) { + return o instanceof KeyedItems && items.equals(((KeyedItems)o).items); + } + + @Override + public int hashCode() { + return items.hashCode(); + } } } diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreUpdates.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreUpdates.java index 409369833..bf7646d67 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreUpdates.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreUpdates.java @@ -1,55 +1,20 @@ package com.launchdarkly.sdk.server.interfaces; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; - /** - * Interface that a data source implementation will use to push data into the underlying - * data store. + * Interface that a data store implementation can use to report information back to the SDK. *

    - * This layer of indirection allows the SDK to perform any other necessary operations that must - * happen when data is updated, by providing its own implementation of {@link DataStoreUpdates}. + * The {@link DataStoreFactory} receives an implementation of this interface and can pass it to the + * data store that it creates, if desired. * * @since 5.0.0 */ public interface DataStoreUpdates { /** - * Overwrites the store's contents with a set of items for each collection. - *

    - * All previous data should be discarded, regardless of versioning. - *

    - * The update should be done atomically. If it cannot be done atomically, then the store - * must first add or update each item in the same order that they are given in the input - * data, and then delete any previously stored items that were not in the input data. - * - * @param allData a list of {@link DataStoreTypes.DataKind} instances and their corresponding data sets - */ - void init(FullDataSet allData); - - /** - * Updates or inserts an item in the specified collection. For updates, the object will only be - * updated if the existing version is less than the new version. - *

    - * The SDK may pass an {@link ItemDescriptor} that contains a null, to represent a placeholder - * for a deleted item. In that case, assuming the version is greater than any existing version of - * that item, the store should retain that placeholder rather than simply not storing anything. - * - * @param kind specifies which collection to use - * @param key the unique key for the item within that collection - * @param item the item to insert or update - */ - void upsert(DataKind kind, String key, ItemDescriptor item); - - /** - * Returns an object that provides status tracking for the data store, if applicable. + * Reports a change in the data store's operational status. *

    - * For data stores that do not support status tracking (the in-memory store, or a custom implementation - * that is not based on the SDK's usual persistent data store mechanism), it returns a stub - * implementation that returns null from {@link DataStoreStatusProvider#getStoreStatus()} and - * false from {@link DataStoreStatusProvider#addStatusListener(com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider.StatusListener)}. + * This is what makes the status monitoring mechanisms in {@link DataStoreStatusProvider} work. * - * @return a {@link DataStoreStatusProvider} + * @param newStatus the updated status properties */ - DataStoreStatusProvider getStatusProvider(); + void updateStatus(DataStoreStatusProvider.Status newStatus); } diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/EventSender.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/EventSender.java new file mode 100644 index 000000000..0325b590f --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/EventSender.java @@ -0,0 +1,94 @@ +package com.launchdarkly.sdk.server.interfaces; + +import java.io.Closeable; +import java.net.URI; +import java.util.Date; + +/** + * Interface for a component that can deliver preformatted event data. + * + * @see com.launchdarkly.sdk.server.integrations.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/sdk/server/interfaces/EventSenderFactory.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/EventSenderFactory.java new file mode 100644 index 000000000..7320c4593 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/EventSenderFactory.java @@ -0,0 +1,18 @@ +package com.launchdarkly.sdk.server.interfaces; + +/** + * Interface for a factory that creates some implementation of {@link EventSender}. + * + * @see com.launchdarkly.sdk.server.integrations.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/main/java/com/launchdarkly/sdk/server/interfaces/LoggingConfiguration.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/LoggingConfiguration.java new file mode 100644 index 000000000..695c02f7d --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/LoggingConfiguration.java @@ -0,0 +1,23 @@ +package com.launchdarkly.sdk.server.interfaces; + +import com.launchdarkly.sdk.server.integrations.LoggingConfigurationBuilder; + +import java.time.Duration; + +/** + * Encapsulates the SDK's general logging configuration. + *

    + * Use {@link LoggingConfigurationFactory} to construct an instance. + * + * @since 5.0.0 + */ +public interface LoggingConfiguration { + /** + * The time threshold, if any, after which the SDK will log a data source outage at {@code ERROR} + * level instead of {@code WARN} level. + * + * @return the error logging threshold, or null + * @see LoggingConfigurationBuilder#logDataSourceOutageAsErrorAfter(java.time.Duration) + */ + Duration getLogDataSourceOutageAsErrorAfter(); +} diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/LoggingConfigurationFactory.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/LoggingConfigurationFactory.java new file mode 100644 index 000000000..54f70bfc0 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/LoggingConfigurationFactory.java @@ -0,0 +1,16 @@ +package com.launchdarkly.sdk.server.interfaces; + +/** + * Interface for a factory that creates an {@link LoggingConfiguration}. + * + * @see com.launchdarkly.sdk.server.Components#logging() + * @see com.launchdarkly.sdk.server.LDConfig.Builder#logging(LoggingConfigurationFactory) + * @since 5.0.0 + */ +public interface LoggingConfigurationFactory { + /** + * Creates the configuration object. + * @return a {@link LoggingConfiguration} + */ + public LoggingConfiguration createLoggingConfiguration(); +} diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/PersistentDataStore.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/PersistentDataStore.java index 410baa062..03dea02d7 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/PersistentDataStore.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/PersistentDataStore.java @@ -23,19 +23,25 @@ * a version number, and can represent either a serialized object or a placeholder (tombstone) * for a deleted item. There are two approaches a persistent store implementation can use for * persisting this data: - * + *

    * 1. Preferably, it should store the version number and the {@link SerializedItemDescriptor#isDeleted()} * state separately so that the object does not need to be fully deserialized to read them. In * this case, deleted item placeholders can ignore the value of {@link SerializedItemDescriptor#getSerializedItem()} * on writes and can set it to null on reads. The store should never call {@link DataKind#deserialize(String)} * or {@link DataKind#serialize(DataStoreTypes.ItemDescriptor)}. - * + *

    * 2. If that isn't possible, then the store should simply persist the exact string from * {@link SerializedItemDescriptor#getSerializedItem()} on writes, and return the persisted * string on reads (returning zero for the version and false for {@link SerializedItemDescriptor#isDeleted()}). * The string is guaranteed to provide the SDK with enough information to infer the version and * the deleted state. On updates, the store must call {@link DataKind#deserialize(String)} in * order to inspect the version number of the existing item if any. + *

    + * Error handling is defined as follows: if any data store operation encounters a database error, or + * is otherwise unable to complete its task, it should throw a {@code RuntimeException} to make the SDK + * aware of this. The SDK will log the exception and will assume that the data store is now in a + * non-operational state; the SDK will then start polling {@link #isStoreAvailable()} to determine + * when the store has started working again. * * @since 5.0.0 */ diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/package-info.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/package-info.java index 5d38c8803..537034709 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/package-info.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/package-info.java @@ -1,7 +1,10 @@ /** - * The package for interfaces that allow customization of LaunchDarkly components. + * The package for interfaces that allow customization of LaunchDarkly components, and interfaces + * to other advanced SDK features. *

    - * 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. + * Most application will not need to refer to these types. You will use them if you are creating a + * plug-in component, such as a database integration, or if you use advanced features such as + * {@link com.launchdarkly.sdk.server.LDClientInterface#getDataStoreStatusProvider()} or + * {@link com.launchdarkly.sdk.server.LDClientInterface#registerFlagChangeListener(FlagChangeListener)}. */ package com.launchdarkly.sdk.server.interfaces; diff --git a/src/test/java/com/launchdarkly/sdk/server/DataStoreUpdatesImplTest.java b/src/test/java/com/launchdarkly/sdk/server/DataSourceUpdatesImplTest.java similarity index 62% rename from src/test/java/com/launchdarkly/sdk/server/DataStoreUpdatesImplTest.java rename to src/test/java/com/launchdarkly/sdk/server/DataSourceUpdatesImplTest.java index 4f041624c..cc6602dad 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataStoreUpdatesImplTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataSourceUpdatesImplTest.java @@ -11,6 +11,8 @@ import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; +import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; import org.easymock.Capture; import org.easymock.EasyMock; @@ -29,21 +31,21 @@ import static com.launchdarkly.sdk.server.ModelBuilders.ruleBuilder; import static com.launchdarkly.sdk.server.ModelBuilders.segmentBuilder; import static com.launchdarkly.sdk.server.TestComponents.inMemoryDataStore; +import static com.launchdarkly.sdk.server.TestComponents.sharedExecutor; import static org.easymock.EasyMock.replay; import static org.junit.Assert.assertEquals; import static org.junit.Assert.fail; @SuppressWarnings("javadoc") -public class DataStoreUpdatesImplTest extends EasyMockSupport { +public class DataSourceUpdatesImplTest extends EasyMockSupport { // Note that these tests must use the actual data model types for flags and segments, rather than the // TestItem type from DataStoreTestTypes, because the dependency behavior is based on the real data model. - @Test - public void doesNotTryToSendEventsIfThereIsNoEventPublisher() { - DataStore store = inMemoryDataStore(); - DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, null); - storeUpdates.upsert(DataModel.FEATURES, "key", new ItemDescriptor(1, flagBuilder("key").build())); - // the test is just that this doesn't cause an exception + private EventBroadcasterImpl flagChangeBroadcaster = + EventBroadcasterImpl.forFlagChangeEvents(TestComponents.sharedExecutor); + + private DataSourceUpdatesImpl makeInstance(DataStore store) { + return new DataSourceUpdatesImpl(store, null, flagChangeBroadcaster, null, sharedExecutor, null); } @Test @@ -55,22 +57,20 @@ public void sendsEventsOnInitForNewlyAddedFlags() throws Exception { .addAny(SEGMENTS, segmentBuilder("segment1").version(1).build()); - try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { - DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); + DataSourceUpdatesImpl storeUpdates = makeInstance(store); - storeUpdates.init(builder.build()); + storeUpdates.init(builder.build()); - FlagChangeEventSink eventSink = new FlagChangeEventSink(); - publisher.register(eventSink); - - builder.addAny(FEATURES, flagBuilder("flag2").version(1).build()) - .addAny(SEGMENTS, segmentBuilder("segment2").version(1).build()); - // the new segment triggers no events since nothing is using it - - storeUpdates.init(builder.build()); - - eventSink.expectEvents("flag2"); - } + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + flagChangeBroadcaster.register(eventSink); + + builder.addAny(FEATURES, flagBuilder("flag2").version(1).build()) + .addAny(SEGMENTS, segmentBuilder("segment2").version(1).build()); + // the new segment triggers no events since nothing is using it + + storeUpdates.init(builder.build()); + + eventSink.expectEvents("flag2"); } @Test @@ -82,18 +82,16 @@ public void sendsEventOnUpdateForNewlyAddedFlag() throws Exception { .addAny(SEGMENTS, segmentBuilder("segment1").version(1).build()); - try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { - DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); + DataSourceUpdatesImpl storeUpdates = makeInstance(store); - storeUpdates.init(builder.build()); - - FlagChangeEventSink eventSink = new FlagChangeEventSink(); - publisher.register(eventSink); - - storeUpdates.upsert(FEATURES, "flag2", new ItemDescriptor(1, flagBuilder("flag2").version(1).build())); - - eventSink.expectEvents("flag2"); - } + storeUpdates.init(builder.build()); + + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + flagChangeBroadcaster.register(eventSink); + + storeUpdates.upsert(FEATURES, "flag2", new ItemDescriptor(1, flagBuilder("flag2").version(1).build())); + + eventSink.expectEvents("flag2"); } @Test @@ -107,20 +105,18 @@ public void sendsEventsOnInitForUpdatedFlags() throws Exception { segmentBuilder("segment1").version(1).build(), segmentBuilder("segment2").version(1).build()); - try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { - DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); + DataSourceUpdatesImpl storeUpdates = makeInstance(store); - storeUpdates.init(builder.build()); + storeUpdates.init(builder.build()); - FlagChangeEventSink eventSink = new FlagChangeEventSink(); - publisher.register(eventSink); - - builder.addAny(FEATURES, flagBuilder("flag2").version(2).build()) // modified flag - .addAny(SEGMENTS, segmentBuilder("segment2").version(2).build()); // modified segment, but it's irrelevant - storeUpdates.init(builder.build()); - - eventSink.expectEvents("flag2"); - } + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + flagChangeBroadcaster.register(eventSink); + + builder.addAny(FEATURES, flagBuilder("flag2").version(2).build()) // modified flag + .addAny(SEGMENTS, segmentBuilder("segment2").version(2).build()); // modified segment, but it's irrelevant + storeUpdates.init(builder.build()); + + eventSink.expectEvents("flag2"); } @Test @@ -133,18 +129,16 @@ public void sendsEventOnUpdateForUpdatedFlag() throws Exception { .addAny(SEGMENTS, segmentBuilder("segment1").version(1).build()); - try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { - DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); + DataSourceUpdatesImpl storeUpdates = makeInstance(store); - storeUpdates.init(builder.build()); + storeUpdates.init(builder.build()); - FlagChangeEventSink eventSink = new FlagChangeEventSink(); - publisher.register(eventSink); - - storeUpdates.upsert(FEATURES, "flag2", new ItemDescriptor(2, flagBuilder("flag2").version(2).build())); + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + flagChangeBroadcaster.register(eventSink); - eventSink.expectEvents("flag2"); - } + storeUpdates.upsert(FEATURES, "flag2", new ItemDescriptor(2, flagBuilder("flag2").version(2).build())); + + eventSink.expectEvents("flag2"); } @Test @@ -157,22 +151,20 @@ public void sendsEventsOnInitForDeletedFlags() throws Exception { .addAny(SEGMENTS, segmentBuilder("segment1").version(1).build()); - try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { - DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); + DataSourceUpdatesImpl storeUpdates = makeInstance(store); - storeUpdates.init(builder.build()); + storeUpdates.init(builder.build()); - FlagChangeEventSink eventSink = new FlagChangeEventSink(); - publisher.register(eventSink); - - builder.remove(FEATURES, "flag2"); - builder.remove(SEGMENTS, "segment1"); // deleted segment isn't being used so it's irrelevant - // note that the full data set for init() will never include deleted item placeholders - - storeUpdates.init(builder.build()); - - eventSink.expectEvents("flag2"); - } + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + flagChangeBroadcaster.register(eventSink); + + builder.remove(FEATURES, "flag2"); + builder.remove(SEGMENTS, "segment1"); // deleted segment isn't being used so it's irrelevant + // note that the full data set for init() will never include deleted item placeholders + + storeUpdates.init(builder.build()); + + eventSink.expectEvents("flag2"); } @Test @@ -185,18 +177,16 @@ public void sendsEventOnUpdateForDeletedFlag() throws Exception { .addAny(SEGMENTS, segmentBuilder("segment1").version(1).build()); - try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { - DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); + DataSourceUpdatesImpl storeUpdates = makeInstance(store); - storeUpdates.init(builder.build()); - - FlagChangeEventSink eventSink = new FlagChangeEventSink(); - publisher.register(eventSink); - - storeUpdates.upsert(FEATURES, "flag2", ItemDescriptor.deletedItem(2)); - - eventSink.expectEvents("flag2"); - } + storeUpdates.init(builder.build()); + + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + flagChangeBroadcaster.register(eventSink); + + storeUpdates.upsert(FEATURES, "flag2", ItemDescriptor.deletedItem(2)); + + eventSink.expectEvents("flag2"); } @Test @@ -211,19 +201,17 @@ public void sendsEventsOnInitForFlagsWhosePrerequisitesChanged() throws Exceptio flagBuilder("flag5").version(1).prerequisites(prerequisite("flag4", 0)).build(), flagBuilder("flag6").version(1).build()); - try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { - DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); - - storeUpdates.init(builder.build()); - - FlagChangeEventSink eventSink = new FlagChangeEventSink(); - publisher.register(eventSink); + DataSourceUpdatesImpl storeUpdates = makeInstance(store); - builder.addAny(FEATURES, flagBuilder("flag1").version(2).build()); - storeUpdates.init(builder.build()); + storeUpdates.init(builder.build()); - eventSink.expectEvents("flag1", "flag2", "flag4", "flag5"); - } + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + flagChangeBroadcaster.register(eventSink); + + builder.addAny(FEATURES, flagBuilder("flag1").version(2).build()); + storeUpdates.init(builder.build()); + + eventSink.expectEvents("flag1", "flag2", "flag4", "flag5"); } @Test @@ -238,18 +226,16 @@ public void sendsEventsOnUpdateForFlagsWhosePrerequisitesChanged() throws Except flagBuilder("flag5").version(1).prerequisites(prerequisite("flag4", 0)).build(), flagBuilder("flag6").version(1).build()); - try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { - DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); + DataSourceUpdatesImpl storeUpdates = makeInstance(store); - storeUpdates.init(builder.build()); - - FlagChangeEventSink eventSink = new FlagChangeEventSink(); - publisher.register(eventSink); - - storeUpdates.upsert(FEATURES, "flag1", new ItemDescriptor(2, flagBuilder("flag1").version(2).build())); - - eventSink.expectEvents("flag1", "flag2", "flag4", "flag5"); - } + storeUpdates.init(builder.build()); + + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + flagChangeBroadcaster.register(eventSink); + + storeUpdates.upsert(FEATURES, "flag1", new ItemDescriptor(2, flagBuilder("flag1").version(2).build())); + + eventSink.expectEvents("flag1", "flag2", "flag4", "flag5"); } @Test @@ -269,18 +255,16 @@ public void sendsEventsOnInitForFlagsWhoseSegmentsChanged() throws Exception { segmentBuilder("segment1").version(1).build(), segmentBuilder("segment2").version(1).build()); - try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { - DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); + DataSourceUpdatesImpl storeUpdates = makeInstance(store); - storeUpdates.init(builder.build()); - - FlagChangeEventSink eventSink = new FlagChangeEventSink(); - publisher.register(eventSink); - - storeUpdates.upsert(SEGMENTS, "segment1", new ItemDescriptor(2, segmentBuilder("segment1").version(2).build())); - - eventSink.expectEvents("flag2", "flag4"); - } + storeUpdates.init(builder.build()); + + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + flagChangeBroadcaster.register(eventSink); + + storeUpdates.upsert(SEGMENTS, "segment1", new ItemDescriptor(2, segmentBuilder("segment1").version(2).build())); + + eventSink.expectEvents("flag2", "flag4"); } @Test @@ -300,19 +284,17 @@ public void sendsEventsOnUpdateForFlagsWhoseSegmentsChanged() throws Exception { segmentBuilder("segment1").version(1).build(), segmentBuilder("segment2").version(1).build()); - try (FlagChangeEventPublisher publisher = new FlagChangeEventPublisher()) { - DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, publisher); + DataSourceUpdatesImpl storeUpdates = makeInstance(store); - storeUpdates.init(builder.build()); - - FlagChangeEventSink eventSink = new FlagChangeEventSink(); - publisher.register(eventSink); - - builder.addAny(SEGMENTS, segmentBuilder("segment1").version(2).build()); - storeUpdates.init(builder.build()); - - eventSink.expectEvents("flag2", "flag4"); - } + storeUpdates.init(builder.build()); + + FlagChangeEventSink eventSink = new FlagChangeEventSink(); + flagChangeBroadcaster.register(eventSink); + + builder.addAny(SEGMENTS, segmentBuilder("segment1").version(2).build()); + storeUpdates.init(builder.build()); + + eventSink.expectEvents("flag2", "flag4"); } @Test @@ -326,7 +308,7 @@ public void dataSetIsPassedToDataStoreInCorrectOrder() throws Exception { store.init(EasyMock.capture(captureData)); replay(store); - DataStoreUpdatesImpl storeUpdates = new DataStoreUpdatesImpl(store, null); + DataSourceUpdatesImpl storeUpdates = makeInstance(store); storeUpdates.init(DEPENDENCY_ORDERING_TEST_DATA); Map> dataMap = toDataMap(captureData.getValue()); diff --git a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java index 5ed29e9e0..01beea18d 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java @@ -9,23 +9,25 @@ import com.launchdarkly.sdk.server.integrations.EventProcessorBuilder; import com.launchdarkly.sdk.server.interfaces.Event; import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; +import com.launchdarkly.sdk.server.interfaces.EventSender; +import com.launchdarkly.sdk.server.interfaces.EventSenderFactory; +import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; 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.time.Duration; 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.sdk.server.Components.sendEvents; import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; import static com.launchdarkly.sdk.server.TestComponents.clientContext; -import static com.launchdarkly.sdk.server.TestHttpUtil.httpsServerWithSelfSignedCert; -import static com.launchdarkly.sdk.server.TestHttpUtil.makeStartedServer; import static com.launchdarkly.sdk.server.TestUtil.hasJsonProperty; import static com.launchdarkly.sdk.server.TestUtil.isJsonArray; import static com.launchdarkly.sdk.server.TestUtil.simpleEvaluation; @@ -33,18 +35,13 @@ 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 { @@ -54,15 +51,14 @@ public class DefaultEventProcessorTest { private static final LDValue userJson = LDValue.buildObject().put("key", "userkey").put("name", "Red").build(); private static final LDValue filteredUserJson = LDValue.buildObject().put("key", "userkey") .put("privateAttrs", LDValue.buildArray().add("name").build()).build(); - 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) { @@ -86,11 +82,11 @@ public void builderHasDefaultConfiguration() throws Exception { assertThat(ec.allAttributesPrivate, is(false)); assertThat(ec.capacity, equalTo(EventProcessorBuilder.DEFAULT_CAPACITY)); assertThat(ec.diagnosticRecordingInterval, equalTo(EventProcessorBuilder.DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL)); + assertThat(ec.eventSender, instanceOf(DefaultEventSender.class)); assertThat(ec.eventsUri, equalTo(LDConfig.DEFAULT_EVENTS_URI)); assertThat(ec.flushInterval, equalTo(EventProcessorBuilder.DEFAULT_FLUSH_INTERVAL)); assertThat(ec.inlineUsersInEvents, is(false)); assertThat(ec.privateAttributes, equalTo(ImmutableSet.of())); - assertThat(ec.samplingInterval, equalTo(0)); assertThat(ec.userKeysCapacity, equalTo(EventProcessorBuilder.DEFAULT_USER_KEYS_CAPACITY)); assertThat(ec.userKeysFlushInterval, equalTo(EventProcessorBuilder.DEFAULT_USER_KEYS_FLUSH_INTERVAL)); } @@ -99,11 +95,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) .diagnosticRecordingInterval(Duration.ofSeconds(480)) + .eventSender(senderFactory(es)) .flushInterval(Duration.ofSeconds(99)) .privateAttributeNames("name", "dogs") .userKeysCapacity(555) @@ -113,11 +111,11 @@ public void builderCanSpecifyConfiguration() throws Exception { assertThat(ec.allAttributesPrivate, is(true)); assertThat(ec.capacity, equalTo(3333)); assertThat(ec.diagnosticRecordingInterval, equalTo(Duration.ofSeconds(480))); +assertThat(ec.eventSender, sameInstance((EventSender)es)); assertThat(ec.eventsUri, equalTo(uri)); assertThat(ec.flushInterval, equalTo(Duration.ofSeconds(99))); assertThat(ec.inlineUsersInEvents, is(false)); // will test this separately below assertThat(ec.privateAttributes, equalTo(ImmutableSet.of(UserAttribute.NAME, UserAttribute.forName("dogs")))); - assertThat(ec.samplingInterval, equalTo(0)); // can only set this with the deprecated config API assertThat(ec.userKeysCapacity, equalTo(555)); assertThat(ec.userKeysFlushInterval, equalTo(Duration.ofSeconds(101))); } @@ -133,267 +131,261 @@ public void builderCanSpecifyConfiguration() 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(); DataModel.FeatureFlag flag = flagBuilder("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(); DataModel.FeatureFlag flag = flagBuilder("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(); DataModel.FeatureFlag flag = flagBuilder("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(); DataModel.FeatureFlag flag = flagBuilder("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(); DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); EvaluationReason reason = EvaluationReason.ruleMatch(1, null); Event.FeatureRequest fe = EventFactory.DEFAULT_WITH_REASONS.newFeatureRequestEvent(flag, user, new Evaluator.EvalResult(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(); DataModel.FeatureFlag flag = flagBuilder("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; DataModel.FeatureFlag flag = flagBuilder("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; DataModel.FeatureFlag flag = flagBuilder("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; DataModel.FeatureFlag flag = flagBuilder("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.getCreationDate(), fe.getCreationDate()) - )); + 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.getCreationDate(), fe.getCreationDate()) + )); } @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; DataModel.FeatureFlag flag = flagBuilder("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.getCreationDate(), fe.getCreationDate()) - )); + 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.getCreationDate(), fe.getCreationDate()) + )); } @SuppressWarnings("unchecked") @Test public void twoFeatureEventsForSameUserGenerateOnlyOneIndexEvent() throws Exception { + MockEventSender es = new MockEventSender(); DataModel.FeatureFlag flag1 = flagBuilder("flagkey1").version(11).trackEvents(true).build(); DataModel.FeatureFlag flag2 = flagBuilder("flagkey2").version(22).trackEvents(true).build(); LDValue value = LDValue.of("value"); @@ -402,47 +394,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.getCreationDate(), fe2.getCreationDate()) - )); + 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.getCreationDate(), fe2.getCreationDate()) + )); } @SuppressWarnings("unchecked") @Test public void identifyEventMakesIndexEventUnnecessary() throws Exception { + MockEventSender es = new MockEventSender(); Event ie = EventFactory.DEFAULT.newIdentifyEvent(user); DataModel.FeatureFlag flag = flagBuilder("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(); DataModel.FeatureFlag flag1 = flagBuilder("flagkey1").version(11).build(); DataModel.FeatureFlag flag2 = flagBuilder("flagkey2").version(22).build(); LDValue value1 = LDValue.of("value1"); @@ -458,167 +448,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.getCreationDate(), fe2.getCreationDate()), - 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.getCreationDate(), fe2.getCreationDate()), + 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(); DataModel.FeatureFlag flag1 = flagBuilder("flagkey1").version(11).trackEvents(true).build(); DataModel.FeatureFlag flag2 = flagBuilder("flagkey2").version(22).trackEvents(true).build(); LDValue value = LDValue.of("value"); @@ -627,275 +606,124 @@ 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; + } + + Iterable getEventsFromLastRequest() throws Exception { + Params p = awaitRequest(); + LDValue a = LDValue.parse(p.data); + assertEquals(p.eventCount, a.size()); + return a.values(); } } - 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 Iterable getEventsFromLastRequest(MockWebServer server) throws Exception { - RecordedRequest req = server.takeRequest(0, TimeUnit.MILLISECONDS); - assertNotNull(req); - return gson.fromJson(req.getBody().readUtf8(), LDValue.class).values(); - } - private Matcher isIdentifyEvent(Event sourceEvent, LDValue user) { return allOf( hasJsonProperty("kind", "identify"), diff --git a/src/test/java/com/launchdarkly/sdk/server/DefaultEventSenderTest.java b/src/test/java/com/launchdarkly/sdk/server/DefaultEventSenderTest.java new file mode 100644 index 000000000..8e00942bd --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultEventSenderTest.java @@ -0,0 +1,339 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.interfaces.EventSender; +import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; + +import org.junit.Test; + +import java.net.URI; +import java.text.SimpleDateFormat; +import java.time.Duration; +import java.util.Date; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import static com.launchdarkly.sdk.server.TestHttpUtil.httpsServerWithSelfSignedCert; +import static com.launchdarkly.sdk.server.TestHttpUtil.makeStartedServer; +import static com.launchdarkly.sdk.server.interfaces.EventSender.EventDataKind.ANALYTICS; +import static com.launchdarkly.sdk.server.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 final Duration BRIEF_RETRY_DELAY = Duration.ofMillis(50); + + private static EventSender makeEventSender() { + return makeEventSender(Components.httpConfiguration().createHttpConfiguration()); + } + + private static EventSender makeEventSender(HttpConfiguration httpConfiguration) { + return new DefaultEventSender( + SDK_KEY, + httpConfiguration, + BRIEF_RETRY_DELAY + ); + } + + 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()); + } + } + + @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); + + 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/sdk/server/DiagnosticEventTest.java b/src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java index 4da6cc4e5..6014086bd 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java @@ -8,6 +8,7 @@ import com.launchdarkly.sdk.server.interfaces.ClientContext; import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; +import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; import com.launchdarkly.sdk.server.interfaces.DiagnosticDescription; import org.junit.Test; @@ -234,7 +235,7 @@ public LDValue describeConfiguration(LDConfig config) { } @Override - public DataStore createDataStore(ClientContext context) { + public DataStore createDataStore(ClientContext context, DataStoreUpdates dataStoreUpdates) { return null; } } diff --git a/src/test/java/com/launchdarkly/sdk/server/EventBroadcasterImplTest.java b/src/test/java/com/launchdarkly/sdk/server/EventBroadcasterImplTest.java new file mode 100644 index 000000000..66dcd5b13 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/EventBroadcasterImplTest.java @@ -0,0 +1,82 @@ +package com.launchdarkly.sdk.server; + +import org.junit.Test; + +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; + +import static com.launchdarkly.sdk.server.TestComponents.sharedExecutor; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; + +@SuppressWarnings("javadoc") +public class EventBroadcasterImplTest { + private EventBroadcasterImpl broadcaster = + new EventBroadcasterImpl<>(FakeListener::sendEvent, sharedExecutor); + + @Test + public void sendingEventWithNoListenersDoesNotCauseError() { + broadcaster.broadcast(new FakeEvent()); + } + + @Test + public void allListenersReceiveEvent() throws Exception { + BlockingQueue events1 = new LinkedBlockingQueue<>(); + BlockingQueue events2 = new LinkedBlockingQueue<>(); + FakeListener listener1 = events1::add; + FakeListener listener2 = events2::add; + broadcaster.register(listener1); + broadcaster.register(listener2); + + FakeEvent e1 = new FakeEvent(); + FakeEvent e2 = new FakeEvent(); + + broadcaster.broadcast(e1); + broadcaster.broadcast(e2); + + assertThat(events1.take(), is(e1)); + assertThat(events1.take(), is(e2)); + assertThat(events1.isEmpty(), is(true)); + + assertThat(events2.take(), is(e1)); + assertThat(events2.take(), is(e2)); + assertThat(events2.isEmpty(), is(true)); + } + + @Test + public void canUnregisterListener() throws Exception { + BlockingQueue events1 = new LinkedBlockingQueue<>(); + BlockingQueue events2 = new LinkedBlockingQueue<>(); + FakeListener listener1 = events1::add; + FakeListener listener2 = events2::add; + broadcaster.register(listener1); + broadcaster.register(listener2); + + FakeEvent e1 = new FakeEvent(); + FakeEvent e2 = new FakeEvent(); + FakeEvent e3 = new FakeEvent(); + + broadcaster.broadcast(e1); + + broadcaster.unregister(listener2); + broadcaster.broadcast(e2); + + broadcaster.register(listener2); + broadcaster.broadcast(e3); + + assertThat(events1.take(), is(e1)); + assertThat(events1.take(), is(e2)); + assertThat(events1.take(), is(e3)); + assertThat(events1.isEmpty(), is(true)); + + assertThat(events2.take(), is(e1)); + assertThat(events2.take(), is(e3)); // did not get e2 + assertThat(events2.isEmpty(), is(true)); + } + + static class FakeEvent {} + + static interface FakeListener { + void sendEvent(FakeEvent e); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientEndToEndTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientEndToEndTest.java index d75975bd9..bf41c5186 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientEndToEndTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientEndToEndTest.java @@ -4,12 +4,10 @@ import com.google.gson.JsonObject; import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; -import com.launchdarkly.sdk.server.DataModel; -import com.launchdarkly.sdk.server.LDClient; -import com.launchdarkly.sdk.server.LDConfig; import org.junit.Test; +import static com.launchdarkly.sdk.server.Components.externalUpdatesOnly; import static com.launchdarkly.sdk.server.Components.noEvents; import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; import static com.launchdarkly.sdk.server.TestHttpUtil.basePollingConfig; @@ -17,11 +15,13 @@ import static com.launchdarkly.sdk.server.TestHttpUtil.httpsServerWithSelfSignedCert; import static com.launchdarkly.sdk.server.TestHttpUtil.jsonResponse; import static com.launchdarkly.sdk.server.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 { @@ -143,6 +143,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)); diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientExternalUpdatesOnlyTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientExternalUpdatesOnlyTest.java index 089b3ec12..0da231105 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientExternalUpdatesOnlyTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientExternalUpdatesOnlyTest.java @@ -2,6 +2,7 @@ import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStore; import org.junit.Test; @@ -44,6 +45,8 @@ public void externalUpdatesOnlyClientIsInitialized() throws Exception { .build(); try (LDClient client = new LDClient("SDK_KEY", config)) { assertTrue(client.initialized()); + + assertEquals(DataSourceStatusProvider.State.VALID, client.getDataSourceStatusProvider().getStatus().getState()); } } diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java new file mode 100644 index 000000000..ae5368ed7 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java @@ -0,0 +1,277 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.DataModel.FeatureFlag; +import com.launchdarkly.sdk.server.DataStoreTestTypes.DataBuilder; +import com.launchdarkly.sdk.server.TestComponents.DataSourceFactoryThatExposesUpdater; +import com.launchdarkly.sdk.server.TestComponents.DataStoreFactoryThatExposesUpdater; +import com.launchdarkly.sdk.server.TestUtil.FlagChangeEventSink; +import com.launchdarkly.sdk.server.TestUtil.FlagValueChangeEventSink; +import com.launchdarkly.sdk.server.integrations.MockPersistentDataStore; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; +import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; +import com.launchdarkly.sdk.server.interfaces.FlagValueChangeEvent; + +import org.easymock.EasyMockSupport; +import org.junit.Test; + +import java.time.Instant; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; + +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.TestComponents.initedDataStore; +import static com.launchdarkly.sdk.server.TestComponents.specificDataStore; +import static com.launchdarkly.sdk.server.TestComponents.specificPersistentDataStore; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.Assert.assertEquals; + +/** + * This file contains tests for all of the event broadcaster/listener functionality in the client, plus + * related methods for looking at the same kinds of status values that can be broadcast to listeners. + * It uses mock implementations of the data source and data store, so that it is only the status + * monitoring mechanisms that are being tested, not the status behavior of specific real components. + *

    + * Parts of this functionality are also covered by lower-level component tests like + * DataSourceUpdatesImplTest. However, the tests here verify that the client is wiring the components + * together correctly so that they work from an application's point of view. + */ +@SuppressWarnings("javadoc") +public class LDClientListenersTest extends EasyMockSupport { + private final static String SDK_KEY = "SDK_KEY"; + + @Test + public void clientSendsFlagChangeEvents() throws Exception { + DataStore testDataStore = initedDataStore(); + DataBuilder initialData = new DataBuilder().addAny(DataModel.FEATURES, + flagBuilder("flagkey").version(1).build()); + DataSourceFactoryThatExposesUpdater updatableSource = new DataSourceFactoryThatExposesUpdater(initialData.build()); + LDConfig config = new LDConfig.Builder() + .dataStore(specificDataStore(testDataStore)) + .dataSource(updatableSource) + .events(Components.noEvents()) + .build(); + + try (LDClient client = new LDClient(SDK_KEY, config)) { + FlagChangeEventSink eventSink1 = new FlagChangeEventSink(); + FlagChangeEventSink eventSink2 = new FlagChangeEventSink(); + client.registerFlagChangeListener(eventSink1); + client.registerFlagChangeListener(eventSink2); + + eventSink1.expectNoEvents(); + eventSink2.expectNoEvents(); + + updatableSource.updateFlag(flagBuilder("flagkey").version(2).build()); + + FlagChangeEvent event1 = eventSink1.awaitEvent(); + FlagChangeEvent event2 = eventSink2.awaitEvent(); + assertThat(event1.getKey(), equalTo("flagkey")); + assertThat(event2.getKey(), equalTo("flagkey")); + eventSink1.expectNoEvents(); + eventSink2.expectNoEvents(); + + client.unregisterFlagChangeListener(eventSink1); + + updatableSource.updateFlag(flagBuilder("flagkey").version(3).build()); + + FlagChangeEvent event3 = eventSink2.awaitEvent(); + assertThat(event3.getKey(), equalTo("flagkey")); + eventSink1.expectNoEvents(); + eventSink2.expectNoEvents(); + } + } + + @Test + public void clientSendsFlagValueChangeEvents() throws Exception { + String flagKey = "important-flag"; + LDUser user = new LDUser("important-user"); + LDUser otherUser = new LDUser("unimportant-user"); + DataStore testDataStore = initedDataStore(); + + FeatureFlag alwaysFalseFlag = flagBuilder(flagKey).version(1).on(true).variations(false, true) + .fallthroughVariation(0).build(); + DataBuilder initialData = new DataBuilder().addAny(DataModel.FEATURES, alwaysFalseFlag); + + DataSourceFactoryThatExposesUpdater updatableSource = new DataSourceFactoryThatExposesUpdater(initialData.build()); + LDConfig config = new LDConfig.Builder() + .dataStore(specificDataStore(testDataStore)) + .dataSource(updatableSource) + .events(Components.noEvents()) + .build(); + + try (LDClient client = new LDClient(SDK_KEY, config)) { + FlagValueChangeEventSink eventSink1 = new FlagValueChangeEventSink(); + FlagValueChangeEventSink eventSink2 = new FlagValueChangeEventSink(); + client.registerFlagChangeListener(Components.flagValueMonitoringListener(client, flagKey, user, eventSink1)); + client.registerFlagChangeListener(Components.flagValueMonitoringListener(client, flagKey, otherUser, eventSink2)); + + eventSink1.expectNoEvents(); + eventSink2.expectNoEvents(); + + FeatureFlag flagIsTrueForMyUserOnly = flagBuilder(flagKey).version(2).on(true).variations(false, true) + .targets(ModelBuilders.target(1, user.getKey())).fallthroughVariation(0).build(); + updatableSource.updateFlag(flagIsTrueForMyUserOnly); + + // eventSink1 receives a value change event; eventSink2 doesn't because the flag's value hasn't changed for otherUser + FlagValueChangeEvent event1 = eventSink1.awaitEvent(); + assertThat(event1.getKey(), equalTo(flagKey)); + assertThat(event1.getOldValue(), equalTo(LDValue.of(false))); + assertThat(event1.getNewValue(), equalTo(LDValue.of(true))); + eventSink1.expectNoEvents(); + + eventSink2.expectNoEvents(); + } + } + + @Test + public void dataSourceStatusProviderReturnsLatestStatus() throws Exception { + DataSourceFactoryThatExposesUpdater updatableSource = new DataSourceFactoryThatExposesUpdater(new DataBuilder().build()); + LDConfig config = new LDConfig.Builder() + .dataSource(updatableSource) + .events(Components.noEvents()) + .build(); + + Instant timeBeforeStarting = Instant.now(); + try (LDClient client = new LDClient(SDK_KEY, config)) { + DataSourceStatusProvider.Status initialStatus = client.getDataSourceStatusProvider().getStatus(); + assertThat(initialStatus.getState(), equalTo(DataSourceStatusProvider.State.INITIALIZING)); + assertThat(initialStatus.getStateSince(), greaterThanOrEqualTo(timeBeforeStarting)); + assertThat(initialStatus.getLastError(), nullValue()); + + DataSourceStatusProvider.ErrorInfo errorInfo = new DataSourceStatusProvider.ErrorInfo( + DataSourceStatusProvider.ErrorKind.ERROR_RESPONSE, 401, null, Instant.now()); + updatableSource.dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.OFF, errorInfo); + + DataSourceStatusProvider.Status newStatus = client.getDataSourceStatusProvider().getStatus(); + assertThat(newStatus.getState(), equalTo(DataSourceStatusProvider.State.OFF)); + assertThat(newStatus.getStateSince(), greaterThanOrEqualTo(errorInfo.getTime())); + assertThat(newStatus.getLastError(), equalTo(errorInfo)); + } + } + + @Test + public void dataSourceStatusProviderSendsStatusUpdates() throws Exception { + DataSourceFactoryThatExposesUpdater updatableSource = new DataSourceFactoryThatExposesUpdater(new DataBuilder().build()); + LDConfig config = new LDConfig.Builder() + .dataSource(updatableSource) + .events(Components.noEvents()) + .build(); + + try (LDClient client = new LDClient(SDK_KEY, config)) { + BlockingQueue statuses = new LinkedBlockingQueue<>(); + client.getDataSourceStatusProvider().addStatusListener(statuses::add); + + DataSourceStatusProvider.ErrorInfo errorInfo = new DataSourceStatusProvider.ErrorInfo( + DataSourceStatusProvider.ErrorKind.ERROR_RESPONSE, 401, null, Instant.now()); + updatableSource.dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.OFF, errorInfo); + + DataSourceStatusProvider.Status newStatus = statuses.take(); + assertThat(newStatus.getState(), equalTo(DataSourceStatusProvider.State.OFF)); + assertThat(newStatus.getStateSince(), greaterThanOrEqualTo(errorInfo.getTime())); + assertThat(newStatus.getLastError(), equalTo(errorInfo)); + } + } + + @Test + public void dataStoreStatusMonitoringIsDisabledForInMemoryStore() throws Exception { + LDConfig config = new LDConfig.Builder() + .dataSource(Components.externalUpdatesOnly()) + .events(Components.noEvents()) + .build(); + try (LDClient client = new LDClient(SDK_KEY, config)) { + assertThat(client.getDataStoreStatusProvider().isStatusMonitoringEnabled(), equalTo(false)); + } + } + + @Test + public void dataStoreStatusMonitoringIsEnabledForPersistentStore() throws Exception { + LDConfig config = new LDConfig.Builder() + .dataSource(Components.externalUpdatesOnly()) + .dataStore( + Components.persistentDataStore(specificPersistentDataStore(new MockPersistentDataStore())) + ) + .events(Components.noEvents()) + .build(); + try (LDClient client = new LDClient(SDK_KEY, config)) { + assertThat(client.getDataStoreStatusProvider().isStatusMonitoringEnabled(), equalTo(true)); + } + } + + @Test + public void dataStoreStatusProviderReturnsLatestStatus() throws Exception { + DataStoreFactory underlyingStoreFactory = Components.persistentDataStore( + specificPersistentDataStore(new MockPersistentDataStore())); + DataStoreFactoryThatExposesUpdater factoryWithUpdater = new DataStoreFactoryThatExposesUpdater(underlyingStoreFactory); + LDConfig config = new LDConfig.Builder() + .dataSource(Components.externalUpdatesOnly()) + .dataStore(factoryWithUpdater) + .events(Components.noEvents()) + .build(); + try (LDClient client = new LDClient(SDK_KEY, config)) { + DataStoreStatusProvider.Status originalStatus = new DataStoreStatusProvider.Status(true, false); + DataStoreStatusProvider.Status newStatus = new DataStoreStatusProvider.Status(false, false); + assertThat(client.getDataStoreStatusProvider().getStoreStatus(), equalTo(originalStatus)); + factoryWithUpdater.dataStoreUpdates.updateStatus(newStatus); + assertThat(client.getDataStoreStatusProvider().getStoreStatus(), equalTo(newStatus)); + } + } + + @Test + public void dataStoreStatusProviderSendsStatusUpdates() throws Exception { + DataStoreFactory underlyingStoreFactory = Components.persistentDataStore( + specificPersistentDataStore(new MockPersistentDataStore())); + DataStoreFactoryThatExposesUpdater factoryWithUpdater = new DataStoreFactoryThatExposesUpdater(underlyingStoreFactory); + LDConfig config = new LDConfig.Builder() + .dataSource(Components.externalUpdatesOnly()) + .dataStore(factoryWithUpdater) + .events(Components.noEvents()) + .build(); + try (LDClient client = new LDClient(SDK_KEY, config)) { + BlockingQueue statuses = new LinkedBlockingQueue<>(); + client.getDataStoreStatusProvider().addStatusListener(statuses::add); + + DataStoreStatusProvider.Status newStatus = new DataStoreStatusProvider.Status(false, false); + factoryWithUpdater.dataStoreUpdates.updateStatus(newStatus); + + assertThat(statuses.take(), equalTo(newStatus)); + } + } + + @Test + public void eventsAreDispatchedOnTaskThread() throws Exception { + int desiredPriority = Thread.MAX_PRIORITY - 1; + BlockingQueue capturedThreads = new LinkedBlockingQueue<>(); + + DataStore testDataStore = initedDataStore(); + DataBuilder initialData = new DataBuilder().addAny(DataModel.FEATURES, + flagBuilder("flagkey").version(1).build()); + DataSourceFactoryThatExposesUpdater updatableSource = new DataSourceFactoryThatExposesUpdater(initialData.build()); + LDConfig config = new LDConfig.Builder() + .dataStore(specificDataStore(testDataStore)) + .dataSource(updatableSource) + .events(Components.noEvents()) + .threadPriority(desiredPriority) + .build(); + + try (LDClient client = new LDClient(SDK_KEY, config)) { + client.registerFlagChangeListener(params -> { + capturedThreads.add(Thread.currentThread()); + }); + + updatableSource.updateFlag(flagBuilder("flagkey").version(2).build()); + + Thread handlerThread = capturedThreads.take(); + + assertEquals(desiredPriority, handlerThread.getPriority()); + assertThat(handlerThread.getName(), containsString("LaunchDarkly-tasks")); + } + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientOfflineTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientOfflineTest.java index 3565554d7..3fd0b79e4 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientOfflineTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientOfflineTest.java @@ -3,11 +3,7 @@ import com.google.common.collect.ImmutableMap; import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; -import com.launchdarkly.sdk.server.Components; -import com.launchdarkly.sdk.server.FeatureFlagsState; -import com.launchdarkly.sdk.server.LDClient; -import com.launchdarkly.sdk.server.LDClientInterface; -import com.launchdarkly.sdk.server.LDConfig; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStore; import org.junit.Test; @@ -52,6 +48,8 @@ public void offlineClientIsInitialized() throws IOException { .build(); try (LDClient client = new LDClient("SDK_KEY", config)) { assertTrue(client.initialized()); + + assertEquals(DataSourceStatusProvider.State.VALID, client.getDataSourceStatusProvider().getStatus().getState()); } } diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java index d8440d7a2..32cf32690 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java @@ -2,21 +2,14 @@ import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; -import com.launchdarkly.sdk.server.DataModel.FeatureFlag; -import com.launchdarkly.sdk.server.DataStoreTestTypes.DataBuilder; -import com.launchdarkly.sdk.server.TestComponents.DataSourceFactoryThatExposesUpdater; -import com.launchdarkly.sdk.server.TestUtil.FlagChangeEventSink; -import com.launchdarkly.sdk.server.TestUtil.FlagValueChangeEventSink; import com.launchdarkly.sdk.server.interfaces.ClientContext; import com.launchdarkly.sdk.server.interfaces.DataSource; import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; import com.launchdarkly.sdk.server.interfaces.DataStore; -import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; +import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; import com.launchdarkly.sdk.server.interfaces.Event; import com.launchdarkly.sdk.server.interfaces.EventProcessor; import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; -import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; -import com.launchdarkly.sdk.server.interfaces.FlagValueChangeEvent; import org.easymock.Capture; import org.easymock.EasyMock; @@ -31,7 +24,6 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; -import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; import static com.launchdarkly.sdk.server.ModelBuilders.flagWithValue; import static com.launchdarkly.sdk.server.TestComponents.failedDataSource; import static com.launchdarkly.sdk.server.TestComponents.initedDataStore; @@ -44,8 +36,6 @@ import static org.easymock.EasyMock.expect; import static org.easymock.EasyMock.expectLastCall; import static org.easymock.EasyMock.isA; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.equalTo; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; @@ -169,7 +159,7 @@ public void sameDiagnosticAccumulatorPassedToFactoriesWhenSupported() throws IOE Capture capturedDataSourceContext = Capture.newInstance(); expect(mockDataSourceFactory.createDataSource(capture(capturedDataSourceContext), - isA(DataStoreUpdates.class))).andReturn(failedDataSource()); + isA(DataSourceUpdates.class))).andReturn(failedDataSource()); replayAll(); @@ -177,7 +167,7 @@ public void sameDiagnosticAccumulatorPassedToFactoriesWhenSupported() throws IOE verifyAll(); DiagnosticAccumulator acc = ((DefaultEventProcessor)client.eventProcessor).dispatcher.diagnosticAccumulator; assertNotNull(acc); - assertSame(acc, ClientContextImpl.getDiagnosticAccumulator(capturedDataSourceContext.getValue())); + assertSame(acc, ClientContextImpl.get(capturedDataSourceContext.getValue()).diagnosticAccumulator); } } @@ -193,14 +183,14 @@ public void nullDiagnosticAccumulatorPassedToFactoriesWhenOptedOut() throws IOEx Capture capturedDataSourceContext = Capture.newInstance(); expect(mockDataSourceFactory.createDataSource(capture(capturedDataSourceContext), - isA(DataStoreUpdates.class))).andReturn(failedDataSource()); + isA(DataSourceUpdates.class))).andReturn(failedDataSource()); replayAll(); try (LDClient client = new LDClient(SDK_KEY, config)) { verifyAll(); assertNull(((DefaultEventProcessor)client.eventProcessor).dispatcher.diagnosticAccumulator); - assertNull(ClientContextImpl.getDiagnosticAccumulator(capturedDataSourceContext.getValue())); + assertNull(ClientContextImpl.get(capturedDataSourceContext.getValue()).diagnosticAccumulator); } } @@ -222,14 +212,14 @@ public void nullDiagnosticAccumulatorPassedToUpdateFactoryWhenEventProcessorDoes Capture capturedDataSourceContext = Capture.newInstance(); expect(mockEventProcessorFactory.createEventProcessor(capture(capturedEventContext))).andReturn(mockEventProcessor); expect(mockDataSourceFactory.createDataSource(capture(capturedDataSourceContext), - isA(DataStoreUpdates.class))).andReturn(failedDataSource()); + isA(DataSourceUpdates.class))).andReturn(failedDataSource()); replayAll(); try (LDClient client = new LDClient(SDK_KEY, config)) { verifyAll(); - assertNull(ClientContextImpl.getDiagnosticAccumulator(capturedEventContext.getValue())); - assertNull(ClientContextImpl.getDiagnosticAccumulator(capturedDataSourceContext.getValue())); + assertNull(ClientContextImpl.get(capturedEventContext.getValue()).diagnosticAccumulator); + assertNull(ClientContextImpl.get(capturedDataSourceContext.getValue()).diagnosticAccumulator); } } @@ -382,91 +372,6 @@ public void evaluationUsesStoreIfStoreIsInitializedButClientIsNot() throws Excep verifyAll(); } - @Test - public void clientSendsFlagChangeEvents() throws Exception { - // The logic for sending change events is tested in detail in DataStoreUpdatesImplTest, but here we'll - // verify that the client is actually telling DataStoreUpdatesImpl about updates, and managing the - // listener list. - DataStore testDataStore = initedDataStore(); - DataBuilder initialData = new DataBuilder().addAny(DataModel.FEATURES, - flagBuilder("flagkey").version(1).build()); - DataSourceFactoryThatExposesUpdater updatableSource = new DataSourceFactoryThatExposesUpdater(initialData.build()); - LDConfig config = new LDConfig.Builder() - .dataStore(specificDataStore(testDataStore)) - .dataSource(updatableSource) - .events(Components.noEvents()) - .build(); - - client = new LDClient(SDK_KEY, config); - - FlagChangeEventSink eventSink1 = new FlagChangeEventSink(); - FlagChangeEventSink eventSink2 = new FlagChangeEventSink(); - client.registerFlagChangeListener(eventSink1); - client.registerFlagChangeListener(eventSink2); - - eventSink1.expectNoEvents(); - eventSink2.expectNoEvents(); - - updatableSource.updateFlag(flagBuilder("flagkey").version(2).build()); - - FlagChangeEvent event1 = eventSink1.awaitEvent(); - FlagChangeEvent event2 = eventSink2.awaitEvent(); - assertThat(event1.getKey(), equalTo("flagkey")); - assertThat(event2.getKey(), equalTo("flagkey")); - eventSink1.expectNoEvents(); - eventSink2.expectNoEvents(); - - client.unregisterFlagChangeListener(eventSink1); - - updatableSource.updateFlag(flagBuilder("flagkey").version(3).build()); - - FlagChangeEvent event3 = eventSink2.awaitEvent(); - assertThat(event3.getKey(), equalTo("flagkey")); - eventSink1.expectNoEvents(); - eventSink2.expectNoEvents(); - } - - @Test - public void clientSendsFlagValueChangeEvents() throws Exception { - String flagKey = "important-flag"; - LDUser user = new LDUser("important-user"); - LDUser otherUser = new LDUser("unimportant-user"); - DataStore testDataStore = initedDataStore(); - - FeatureFlag alwaysFalseFlag = flagBuilder(flagKey).version(1).on(true).variations(false, true) - .fallthroughVariation(0).build(); - DataBuilder initialData = new DataBuilder().addAny(DataModel.FEATURES, alwaysFalseFlag); - - DataSourceFactoryThatExposesUpdater updatableSource = new DataSourceFactoryThatExposesUpdater(initialData.build()); - LDConfig config = new LDConfig.Builder() - .dataStore(specificDataStore(testDataStore)) - .dataSource(updatableSource) - .events(Components.noEvents()) - .build(); - - client = new LDClient(SDK_KEY, config); - FlagValueChangeEventSink eventSink1 = new FlagValueChangeEventSink(); - FlagValueChangeEventSink eventSink2 = new FlagValueChangeEventSink(); - client.registerFlagChangeListener(Components.flagValueMonitoringListener(client, flagKey, user, eventSink1)); - client.registerFlagChangeListener(Components.flagValueMonitoringListener(client, flagKey, otherUser, eventSink2)); - - eventSink1.expectNoEvents(); - eventSink2.expectNoEvents(); - - FeatureFlag flagIsTrueForMyUserOnly = flagBuilder(flagKey).version(2).on(true).variations(false, true) - .targets(ModelBuilders.target(1, user.getKey())).fallthroughVariation(0).build(); - updatableSource.updateFlag(flagIsTrueForMyUserOnly); - - // eventSink1 receives a value change event; eventSink2 doesn't because the flag's value hasn't changed for otherUser - FlagValueChangeEvent event1 = eventSink1.awaitEvent(); - assertThat(event1.getKey(), equalTo(flagKey)); - assertThat(event1.getOldValue(), equalTo(LDValue.of(false))); - assertThat(event1.getNewValue(), equalTo(LDValue.of(true))); - eventSink1.expectNoEvents(); - - eventSink2.expectNoEvents(); - } - private void expectEventsSent(int count) { eventProcessor.sendEvent(anyObject(Event.class)); if (count > 0) { diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapperTest.java b/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperTest.java similarity index 92% rename from src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapperTest.java rename to src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperTest.java index 1db918cbd..60821ea2a 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreWrapperTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperTest.java @@ -1,10 +1,15 @@ -package com.launchdarkly.sdk.server.integrations; +package com.launchdarkly.sdk.server; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; +import com.launchdarkly.sdk.server.PersistentDataStoreStatusManager; +import com.launchdarkly.sdk.server.PersistentDataStoreWrapper; import com.launchdarkly.sdk.server.DataStoreTestTypes.DataBuilder; import com.launchdarkly.sdk.server.DataStoreTestTypes.TestItem; +import com.launchdarkly.sdk.server.integrations.MockPersistentDataStore; +import com.launchdarkly.sdk.server.integrations.PersistentDataStoreBuilder; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider.CacheStats; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.SerializedItemDescriptor; @@ -26,6 +31,7 @@ import static com.launchdarkly.sdk.server.DataStoreTestTypes.toDataMap; import static com.launchdarkly.sdk.server.DataStoreTestTypes.toItemsMap; import static com.launchdarkly.sdk.server.DataStoreTestTypes.toSerialized; +import static com.launchdarkly.sdk.server.TestComponents.sharedExecutor; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.greaterThan; @@ -42,6 +48,9 @@ public class PersistentDataStoreWrapperTest { private final TestMode testMode; private final MockPersistentDataStore core; private final PersistentDataStoreWrapper wrapper; + private final EventBroadcasterImpl statusBroadcaster; + private final DataStoreUpdatesImpl dataStoreUpdates; + private final DataStoreStatusProvider dataStoreStatusProvider; static class TestMode { final boolean cached; @@ -94,8 +103,21 @@ public PersistentDataStoreWrapperTest(TestMode testMode) { this.testMode = testMode; this.core = new MockPersistentDataStore(); this.core.persistOnlyAsString = testMode.persistOnlyAsString; - this.wrapper = new PersistentDataStoreWrapper(core, testMode.getCacheTtl(), - PersistentDataStoreBuilder.StaleValuesPolicy.EVICT, false); + this.wrapper = new PersistentDataStoreWrapper( + core, + testMode.getCacheTtl(), + PersistentDataStoreBuilder.StaleValuesPolicy.EVICT, + false, + this::updateStatus, + sharedExecutor + ); + this.statusBroadcaster = EventBroadcasterImpl.forDataStoreStatus(sharedExecutor); + this.dataStoreUpdates = new DataStoreUpdatesImpl(statusBroadcaster); + this.dataStoreStatusProvider = new DataStoreStatusProviderImpl(wrapper, dataStoreUpdates); + } + + private void updateStatus(DataStoreStatusProvider.Status status) { + dataStoreUpdates.updateStatus(status); } @After @@ -445,8 +467,14 @@ public void initializedCanCacheFalseResult() throws Exception { assumeThat(testMode.isCached(), is(true)); // We need to create a different object for this test so we can set a short cache TTL - try (PersistentDataStoreWrapper wrapper1 = new PersistentDataStoreWrapper(core, - Duration.ofMillis(500), PersistentDataStoreBuilder.StaleValuesPolicy.EVICT, false)) { + try (PersistentDataStoreWrapper wrapper1 = new PersistentDataStoreWrapper( + core, + Duration.ofMillis(500), + PersistentDataStoreBuilder.StaleValuesPolicy.EVICT, + false, + this::updateStatus, + sharedExecutor + )) { assertThat(wrapper1.isInitialized(), is(false)); assertThat(core.initedQueryCount, equalTo(1)); @@ -468,11 +496,17 @@ public void initializedCanCacheFalseResult() throws Exception { public void canGetCacheStats() throws Exception { assumeThat(testMode.isCachedWithFiniteTtl(), is(true)); - try (PersistentDataStoreWrapper w = new PersistentDataStoreWrapper(core, - Duration.ofSeconds(30), PersistentDataStoreBuilder.StaleValuesPolicy.EVICT, true)) { - DataStoreStatusProvider.CacheStats stats = w.getCacheStats(); + try (PersistentDataStoreWrapper w = new PersistentDataStoreWrapper( + core, + Duration.ofSeconds(30), + PersistentDataStoreBuilder.StaleValuesPolicy.EVICT, + true, + this::updateStatus, + sharedExecutor + )) { + CacheStats stats = w.getCacheStats(); - assertThat(stats, equalTo(new DataStoreStatusProvider.CacheStats(0, 0, 0, 0, 0, 0))); + assertThat(stats, equalTo(new CacheStats(0, 0, 0, 0, 0, 0))); // Cause a cache miss w.get(TEST_ITEMS, "key1"); @@ -510,7 +544,7 @@ public void canGetCacheStats() throws Exception { @Test public void statusIsOkInitially() throws Exception { - DataStoreStatusProvider.Status status = wrapper.getStoreStatus(); + DataStoreStatusProvider.Status status = dataStoreStatusProvider.getStoreStatus(); assertThat(status.isAvailable(), is(true)); assertThat(status.isRefreshNeeded(), is(false)); } @@ -519,7 +553,7 @@ public void statusIsOkInitially() throws Exception { public void statusIsUnavailableAfterError() throws Exception { causeStoreError(core, wrapper); - DataStoreStatusProvider.Status status = wrapper.getStoreStatus(); + DataStoreStatusProvider.Status status = dataStoreStatusProvider.getStoreStatus(); assertThat(status.isAvailable(), is(false)); assertThat(status.isRefreshNeeded(), is(false)); } @@ -527,7 +561,7 @@ public void statusIsUnavailableAfterError() throws Exception { @Test public void statusListenerIsNotifiedOnFailureAndRecovery() throws Exception { final BlockingQueue statuses = new LinkedBlockingQueue<>(); - wrapper.addStatusListener(statuses::add); + dataStoreStatusProvider.addStatusListener(statuses::add); causeStoreError(core, wrapper); @@ -551,7 +585,7 @@ public void cacheIsWrittenToStoreAfterRecoveryIfTtlIsInfinite() throws Exception assumeThat(testMode.isCachedIndefinitely(), is(true)); final BlockingQueue statuses = new LinkedBlockingQueue<>(); - wrapper.addStatusListener(statuses::add); + dataStoreStatusProvider.addStatusListener(statuses::add); TestItem item1v1 = new TestItem("key1", 1); TestItem item1v2 = item1v1.withVersion(2); @@ -607,7 +641,7 @@ public void statusRemainsUnavailableIfStoreSaysItIsAvailableButInitFails() throw // Most of this test is identical to cacheIsWrittenToStoreAfterRecoveryIfTtlIsInfinite() except as noted below. final BlockingQueue statuses = new LinkedBlockingQueue<>(); - wrapper.addStatusListener(statuses::add); + dataStoreStatusProvider.addStatusListener(statuses::add); TestItem item1v1 = new TestItem("key1", 1); TestItem item1v2 = item1v1.withVersion(2); diff --git a/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java index b43a2e2c5..c7418341e 100644 --- a/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java @@ -1,32 +1,41 @@ package com.launchdarkly.sdk.server; -import com.launchdarkly.sdk.server.Components; -import com.launchdarkly.sdk.server.DataModel; -import com.launchdarkly.sdk.server.DefaultFeatureRequestor; -import com.launchdarkly.sdk.server.FeatureRequestor; -import com.launchdarkly.sdk.server.HttpErrorException; -import com.launchdarkly.sdk.server.InMemoryDataStore; -import com.launchdarkly.sdk.server.LDConfig; -import com.launchdarkly.sdk.server.PollingProcessor; +import com.launchdarkly.sdk.server.TestComponents.MockDataSourceUpdates; +import com.launchdarkly.sdk.server.TestComponents.MockDataStoreStatusProvider; import com.launchdarkly.sdk.server.integrations.PollingDataSourceBuilder; import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorKind; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.State; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.Status; import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; +import org.junit.Before; import org.junit.Test; import java.io.IOException; import java.net.URI; import java.time.Duration; import java.util.HashMap; +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 static com.launchdarkly.sdk.server.TestComponents.clientContext; -import static com.launchdarkly.sdk.server.TestComponents.dataStoreUpdates; +import static com.launchdarkly.sdk.server.TestComponents.dataStoreThatThrowsException; +import static com.launchdarkly.sdk.server.TestComponents.sharedExecutor; +import static com.launchdarkly.sdk.server.TestUtil.requireDataSourceStatus; +import static com.launchdarkly.sdk.server.TestUtil.requireDataSourceStatusEventually; +import static com.launchdarkly.sdk.server.TestUtil.shouldNotTimeOut; +import static com.launchdarkly.sdk.server.TestUtil.shouldTimeOut; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNotSame; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; @@ -34,7 +43,25 @@ public class PollingProcessorTest { private static final String SDK_KEY = "sdk-key"; private static final Duration LENGTHY_INTERVAL = Duration.ofSeconds(60); - + + private MockDataSourceUpdates dataSourceUpdates; + private MockFeatureRequestor requestor; + + @Before + public void setup() { + DataStore store = new InMemoryDataStore(); + dataSourceUpdates = TestComponents.dataSourceUpdates(store, new MockDataStoreStatusProvider()); + requestor = new MockFeatureRequestor(); + } + + private PollingProcessor makeProcessor() { + return makeProcessor(LENGTHY_INTERVAL); + } + + private PollingProcessor makeProcessor(Duration pollInterval) { + return new PollingProcessor(requestor, dataSourceUpdates, sharedExecutor, pollInterval); + } + @Test public void builderHasDefaultConfiguration() throws Exception { DataSourceFactory f = Components.pollingDataSource(); @@ -58,25 +85,30 @@ public void builderCanSpecifyConfiguration() throws Exception { @Test public void testConnectionOk() throws Exception { - MockFeatureRequestor requestor = new MockFeatureRequestor(); requestor.allData = new FeatureRequestor.AllData(new HashMap<>(), new HashMap<>()); - DataStore store = new InMemoryDataStore(); - try (PollingProcessor pollingProcessor = new PollingProcessor(requestor, dataStoreUpdates(store), LENGTHY_INTERVAL)) { + BlockingQueue statuses = new LinkedBlockingQueue<>(); + dataSourceUpdates.statusBroadcaster.register(statuses::add); + + try (PollingProcessor pollingProcessor = makeProcessor()) { Future initFuture = pollingProcessor.start(); initFuture.get(1000, TimeUnit.MILLISECONDS); + assertTrue(pollingProcessor.isInitialized()); - assertTrue(store.isInitialized()); + assertEquals(requestor.allData.toFullDataSet(), dataSourceUpdates.awaitInit()); + + requireDataSourceStatus(statuses, State.VALID); } } @Test public void testConnectionProblem() throws Exception { - MockFeatureRequestor requestor = new MockFeatureRequestor(); requestor.ioException = new IOException("This exception is part of a test and yes you should be seeing it."); - DataStore store = new InMemoryDataStore(); - try (PollingProcessor pollingProcessor = new PollingProcessor(requestor, dataStoreUpdates(store), LENGTHY_INTERVAL)) { + BlockingQueue statuses = new LinkedBlockingQueue<>(); + dataSourceUpdates.statusBroadcaster.register(statuses::add); + + try (PollingProcessor pollingProcessor = makeProcessor()) { Future initFuture = pollingProcessor.start(); try { initFuture.get(200L, TimeUnit.MILLISECONDS); @@ -85,10 +117,38 @@ public void testConnectionProblem() throws Exception { } assertFalse(initFuture.isDone()); assertFalse(pollingProcessor.isInitialized()); - assertFalse(store.isInitialized()); + assertEquals(0, dataSourceUpdates.receivedInits.size()); + + Status status = requireDataSourceStatus(statuses, State.INITIALIZING); + assertNotNull(status.getLastError()); + assertEquals(ErrorKind.NETWORK_ERROR, status.getLastError().getKind()); } } + @Test + public void testDataStoreFailure() throws Exception { + DataStore badStore = dataStoreThatThrowsException(new RuntimeException("sorry")); + DataStoreStatusProvider badStoreStatusProvider = new MockDataStoreStatusProvider(false); + dataSourceUpdates = TestComponents.dataSourceUpdates(badStore, badStoreStatusProvider); + + requestor.allData = new FeatureRequestor.AllData(new HashMap<>(), new HashMap<>()); + + BlockingQueue statuses = new LinkedBlockingQueue<>(); + dataSourceUpdates.statusBroadcaster.register(statuses::add); + + try (PollingProcessor pollingProcessor = makeProcessor()) { + pollingProcessor.start(); + + assertEquals(requestor.allData.toFullDataSet(), dataSourceUpdates.awaitInit()); + + assertFalse(pollingProcessor.isInitialized()); + + Status status = requireDataSourceStatus(statuses, State.INITIALIZING); + assertNotNull(status.getLastError()); + assertEquals(ErrorKind.STORE_ERROR, status.getLastError().getKind()); + } + } + @Test public void http400ErrorIsRecoverable() throws Exception { testRecoverableHttpError(400); @@ -119,46 +179,78 @@ public void http500ErrorIsRecoverable() throws Exception { testRecoverableHttpError(500); } - private void testUnrecoverableHttpError(int status) throws Exception { - MockFeatureRequestor requestor = new MockFeatureRequestor(); - requestor.httpException = new HttpErrorException(status); - DataStore store = new InMemoryDataStore(); + private void testUnrecoverableHttpError(int statusCode) throws Exception { + requestor.httpException = new HttpErrorException(statusCode); - try (PollingProcessor pollingProcessor = new PollingProcessor(requestor, dataStoreUpdates(store), LENGTHY_INTERVAL)) { + BlockingQueue statuses = new LinkedBlockingQueue<>(); + dataSourceUpdates.statusBroadcaster.register(statuses::add); + + try (PollingProcessor pollingProcessor = makeProcessor()) { long startTime = System.currentTimeMillis(); Future initFuture = pollingProcessor.start(); - try { - initFuture.get(10, TimeUnit.SECONDS); - } catch (TimeoutException ignored) { - fail("Should not have timed out"); - } + + shouldNotTimeOut(initFuture, Duration.ofSeconds(2)); assertTrue((System.currentTimeMillis() - startTime) < 9000); assertTrue(initFuture.isDone()); assertFalse(pollingProcessor.isInitialized()); + + Status status = requireDataSourceStatus(statuses, State.OFF); + assertNotNull(status.getLastError()); + assertEquals(ErrorKind.ERROR_RESPONSE, status.getLastError().getKind()); + assertEquals(statusCode, status.getLastError().getStatusCode()); } } - private void testRecoverableHttpError(int status) throws Exception { - MockFeatureRequestor requestor = new MockFeatureRequestor(); - requestor.httpException = new HttpErrorException(status); - DataStore store = new InMemoryDataStore(); + private void testRecoverableHttpError(int statusCode) throws Exception { + HttpErrorException httpError = new HttpErrorException(statusCode); + Duration shortInterval = Duration.ofMillis(20); + requestor.httpException = httpError; - try (PollingProcessor pollingProcessor = new PollingProcessor(requestor, dataStoreUpdates(store), LENGTHY_INTERVAL)) { + BlockingQueue statuses = new LinkedBlockingQueue<>(); + dataSourceUpdates.statusBroadcaster.register(statuses::add); + + try (PollingProcessor pollingProcessor = makeProcessor(shortInterval)) { Future initFuture = pollingProcessor.start(); - try { - initFuture.get(200, TimeUnit.MILLISECONDS); - fail("expected timeout"); - } catch (TimeoutException ignored) { - } + + // first poll gets an error + shouldTimeOut(initFuture, Duration.ofMillis(200)); assertFalse(initFuture.isDone()); assertFalse(pollingProcessor.isInitialized()); + + Status status1 = requireDataSourceStatus(statuses, State.INITIALIZING); + assertNotNull(status1.getLastError()); + assertEquals(ErrorKind.ERROR_RESPONSE, status1.getLastError().getKind()); + assertEquals(statusCode, status1.getLastError().getStatusCode()); + + // now make it so the requestor will succeed + requestor.allData = new FeatureRequestor.AllData(new HashMap<>(), new HashMap<>()); + requestor.httpException = null; + + shouldNotTimeOut(initFuture, Duration.ofSeconds(2)); + assertTrue(initFuture.isDone()); + assertTrue(pollingProcessor.isInitialized()); + + // status should now be VALID (although there might have been more failed polls before that) + Status status2 = requireDataSourceStatusEventually(statuses, State.VALID, State.INITIALIZING); + assertNotNull(status2.getLastError()); + assertEquals(ErrorKind.ERROR_RESPONSE, status2.getLastError().getKind()); + assertEquals(statusCode, status2.getLastError().getStatusCode()); + + // simulate another error of the same kind - the difference is now the state will be INTERRUPTED + requestor.httpException = httpError; + + Status status3 = requireDataSourceStatusEventually(statuses, State.INTERRUPTED, State.VALID); + assertNotNull(status3.getLastError()); + assertEquals(ErrorKind.ERROR_RESPONSE, status3.getLastError().getKind()); + assertEquals(statusCode, status3.getLastError().getStatusCode()); + assertNotSame(status1.getLastError(), status3.getLastError()); // it's a new error object of the same kind } } private static class MockFeatureRequestor implements FeatureRequestor { - AllData allData; - HttpErrorException httpException; - IOException ioException; + volatile AllData allData; + volatile HttpErrorException httpException; + volatile IOException ioException; public void close() throws IOException {} diff --git a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java index ce466454f..4392aaf2d 100644 --- a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java @@ -5,9 +5,16 @@ import com.launchdarkly.eventsource.EventSource; import com.launchdarkly.eventsource.MessageEvent; import com.launchdarkly.eventsource.UnsuccessfulResponseException; +import com.launchdarkly.sdk.server.StreamProcessor.EventSourceParams; +import com.launchdarkly.sdk.server.TestComponents.MockDataSourceUpdates; +import com.launchdarkly.sdk.server.TestComponents.MockDataStoreStatusProvider; import com.launchdarkly.sdk.server.TestComponents.MockEventSourceCreator; import com.launchdarkly.sdk.server.integrations.StreamingDataSourceBuilder; import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorKind; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.State; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.Status; +import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; @@ -24,8 +31,6 @@ import java.util.concurrent.CompletableFuture; 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; @@ -35,10 +40,13 @@ import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; import static com.launchdarkly.sdk.server.ModelBuilders.segmentBuilder; import static com.launchdarkly.sdk.server.TestComponents.clientContext; +import static com.launchdarkly.sdk.server.TestComponents.dataSourceUpdates; import static com.launchdarkly.sdk.server.TestComponents.dataStoreThatThrowsException; -import static com.launchdarkly.sdk.server.TestComponents.dataStoreUpdates; import static com.launchdarkly.sdk.server.TestHttpUtil.eventStreamResponse; import static com.launchdarkly.sdk.server.TestHttpUtil.makeStartedServer; +import static com.launchdarkly.sdk.server.TestUtil.requireDataSourceStatus; +import static com.launchdarkly.sdk.server.TestUtil.shouldNotTimeOut; +import static com.launchdarkly.sdk.server.TestUtil.shouldTimeOut; import static com.launchdarkly.sdk.server.TestUtil.upsertFlag; import static com.launchdarkly.sdk.server.TestUtil.upsertSegment; import static com.launchdarkly.sdk.server.integrations.StreamingDataSourceBuilder.DEFAULT_INITIAL_RECONNECT_DELAY; @@ -51,9 +59,10 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNotSame; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; import okhttp3.HttpUrl; import okhttp3.mockwebserver.MockWebServer; @@ -74,6 +83,8 @@ public class StreamProcessorTest extends EasyMockSupport { "data: {\"data\":{\"flags\":{},\"segments\":{}}}\n\n"; private InMemoryDataStore dataStore; + private MockDataSourceUpdates dataSourceUpdates; + private MockDataStoreStatusProvider dataStoreStatusProvider; private FeatureRequestor mockRequestor; private EventSource mockEventSource; private MockEventSourceCreator mockEventSourceCreator; @@ -81,6 +92,8 @@ public class StreamProcessorTest extends EasyMockSupport { @Before public void setup() { dataStore = new InMemoryDataStore(); + dataStoreStatusProvider = new MockDataStoreStatusProvider(); + dataSourceUpdates = TestComponents.dataSourceUpdates(dataStore, dataStoreStatusProvider); mockRequestor = createStrictMock(FeatureRequestor.class); mockEventSource = createMock(EventSource.class); mockEventSourceCreator = new MockEventSourceCreator(mockEventSource); @@ -90,7 +103,7 @@ public void setup() { public void builderHasDefaultConfiguration() throws Exception { DataSourceFactory f = Components.streamingDataSource(); try (StreamProcessor sp = (StreamProcessor)f.createDataSource(clientContext(SDK_KEY, LDConfig.DEFAULT), - dataStoreUpdates(dataStore))) { + dataSourceUpdates)) { assertThat(sp.initialReconnectDelay, equalTo(StreamingDataSourceBuilder.DEFAULT_INITIAL_RECONNECT_DELAY)); assertThat(sp.streamUri, equalTo(LDConfig.DEFAULT_STREAM_URI)); assertThat(((DefaultFeatureRequestor)sp.requestor).baseUri, equalTo(LDConfig.DEFAULT_BASE_URI)); @@ -106,7 +119,7 @@ public void builderCanSpecifyConfiguration() throws Exception { .initialReconnectDelay(Duration.ofMillis(5555)) .pollingBaseURI(pollUri); try (StreamProcessor sp = (StreamProcessor)f.createDataSource(clientContext(SDK_KEY, LDConfig.DEFAULT), - dataStoreUpdates(dataStore))) { + dataSourceUpdates(dataStore))) { assertThat(sp.initialReconnectDelay, equalTo(Duration.ofMillis(5555))); assertThat(sp.streamUri, equalTo(streamUri)); assertThat(((DefaultFeatureRequestor)sp.requestor).baseUri, equalTo(pollUri)); @@ -474,22 +487,22 @@ public void http500ErrorIsRecoverable() throws Exception { @Test public void putEventWithInvalidJsonCausesStreamRestart() throws Exception { - verifyEventCausesStreamRestartWithInMemoryStore("put", "{sorry"); + verifyInvalidDataEvent("put", "{sorry"); } @Test public void putEventWithWellFormedJsonButInvalidDataCausesStreamRestart() throws Exception { - verifyEventCausesStreamRestartWithInMemoryStore("put", "{\"data\":{\"flags\":3}}"); + verifyInvalidDataEvent("put", "{\"data\":{\"flags\":3}}"); } @Test public void patchEventWithInvalidJsonCausesStreamRestart() throws Exception { - verifyEventCausesStreamRestartWithInMemoryStore("patch", "{sorry"); + verifyInvalidDataEvent("patch", "{sorry"); } @Test public void patchEventWithWellFormedJsonButInvalidDataCausesStreamRestart() throws Exception { - verifyEventCausesStreamRestartWithInMemoryStore("patch", "{\"path\":\"/flags/flagkey\", \"data\":{\"rules\":3}}"); + verifyInvalidDataEvent("patch", "{\"path\":\"/flags/flagkey\", \"data\":{\"rules\":3}}"); } @Test @@ -499,7 +512,7 @@ public void patchEventWithInvalidPathCausesNoStreamRestart() throws Exception { @Test public void deleteEventWithInvalidJsonCausesStreamRestart() throws Exception { - verifyEventCausesStreamRestartWithInMemoryStore("delete", "{sorry"); + verifyInvalidDataEvent("delete", "{sorry"); } @Test @@ -526,8 +539,6 @@ public void indirectPatchWithFailedPollCausesStreamRestart() throws Exception { @Test public void restartsStreamIfStoreNeedsRefresh() throws Exception { - TestComponents.DataStoreWithStatusUpdates storeWithStatus = new TestComponents.DataStoreWithStatusUpdates(dataStore); - CompletableFuture restarted = new CompletableFuture<>(); mockEventSource.start(); expectLastCall(); @@ -543,11 +554,11 @@ public void restartsStreamIfStoreNeedsRefresh() throws Exception { replayAll(); - try (StreamProcessor sp = createStreamProcessorWithStore(storeWithStatus)) { + try (StreamProcessor sp = createStreamProcessor(STREAM_URI)) { sp.start(); - storeWithStatus.broadcastStatusChange(new DataStoreStatusProvider.Status(false, false)); - storeWithStatus.broadcastStatusChange(new DataStoreStatusProvider.Status(true, true)); + dataStoreStatusProvider.updateStatus(new DataStoreStatusProvider.Status(false, false)); + dataStoreStatusProvider.updateStatus(new DataStoreStatusProvider.Status(true, true)); restarted.get(); } @@ -555,8 +566,6 @@ public void restartsStreamIfStoreNeedsRefresh() throws Exception { @Test public void doesNotRestartStreamIfStoreHadOutageButDoesNotNeedRefresh() throws Exception { - TestComponents.DataStoreWithStatusUpdates storeWithStatus = new TestComponents.DataStoreWithStatusUpdates(dataStore); - CompletableFuture restarted = new CompletableFuture<>(); mockEventSource.start(); expectLastCall(); @@ -572,11 +581,11 @@ public void doesNotRestartStreamIfStoreHadOutageButDoesNotNeedRefresh() throws E replayAll(); - try (StreamProcessor sp = createStreamProcessorWithStore(storeWithStatus)) { + try (StreamProcessor sp = createStreamProcessor(STREAM_URI)) { sp.start(); - storeWithStatus.broadcastStatusChange(new DataStoreStatusProvider.Status(false, false)); - storeWithStatus.broadcastStatusChange(new DataStoreStatusProvider.Status(true, false)); + dataStoreStatusProvider.updateStatus(new DataStoreStatusProvider.Status(false, false)); + dataStoreStatusProvider.updateStatus(new DataStoreStatusProvider.Status(true, false)); Thread.sleep(500); assertFalse(restarted.isDone()); @@ -585,25 +594,28 @@ public void doesNotRestartStreamIfStoreHadOutageButDoesNotNeedRefresh() throws E @Test public void storeFailureOnPutCausesStreamRestart() throws Exception { - DataStore badStore = dataStoreThatThrowsException(new RuntimeException("sorry")); + MockDataSourceUpdates badUpdates = dataSourceUpdatesThatMakesUpdatesFailAndDoesNotSupportStatusMonitoring(); expectStreamRestart(); replayAll(); - try (StreamProcessor sp = createStreamProcessorWithStore(badStore)) { + try (StreamProcessor sp = createStreamProcessorWithStoreUpdates(badUpdates)) { sp.start(); EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; handler.onMessage("put", emptyPutEvent()); + + assertNotNull(badUpdates.getLastStatus().getLastError()); + assertEquals(ErrorKind.STORE_ERROR, badUpdates.getLastStatus().getLastError().getKind()); } verifyAll(); } @Test public void storeFailureOnPatchCausesStreamRestart() throws Exception { - DataStore badStore = dataStoreThatThrowsException(new RuntimeException("sorry")); + MockDataSourceUpdates badUpdates = dataSourceUpdatesThatMakesUpdatesFailAndDoesNotSupportStatusMonitoring(); expectStreamRestart(); replayAll(); - try (StreamProcessor sp = createStreamProcessorWithStore(badStore)) { + try (StreamProcessor sp = createStreamProcessorWithStoreUpdates(badUpdates)) { sp.start(); EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; handler.onMessage("patch", @@ -614,11 +626,11 @@ public void storeFailureOnPatchCausesStreamRestart() throws Exception { @Test public void storeFailureOnDeleteCausesStreamRestart() throws Exception { - DataStore badStore = dataStoreThatThrowsException(new RuntimeException("sorry")); + MockDataSourceUpdates badUpdates = dataSourceUpdatesThatMakesUpdatesFailAndDoesNotSupportStatusMonitoring(); expectStreamRestart(); replayAll(); - try (StreamProcessor sp = createStreamProcessorWithStore(badStore)) { + try (StreamProcessor sp = createStreamProcessorWithStoreUpdates(badUpdates)) { sp.start(); EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; handler.onMessage("delete", @@ -629,12 +641,12 @@ public void storeFailureOnDeleteCausesStreamRestart() throws Exception { @Test public void storeFailureOnIndirectPutCausesStreamRestart() throws Exception { - DataStore badStore = dataStoreThatThrowsException(new RuntimeException("sorry")); + MockDataSourceUpdates badUpdates = dataSourceUpdatesThatMakesUpdatesFailAndDoesNotSupportStatusMonitoring(); setupRequestorToReturnAllDataWithFlag(FEATURE); expectStreamRestart(); replayAll(); - try (StreamProcessor sp = createStreamProcessorWithStore(badStore)) { + try (StreamProcessor sp = createStreamProcessorWithStoreUpdates(badUpdates)) { sp.start(); EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; handler.onMessage("indirect/put", new MessageEvent("")); @@ -644,13 +656,13 @@ public void storeFailureOnIndirectPutCausesStreamRestart() throws Exception { @Test public void storeFailureOnIndirectPatchCausesStreamRestart() throws Exception { - DataStore badStore = dataStoreThatThrowsException(new RuntimeException("sorry")); + MockDataSourceUpdates badUpdates = dataSourceUpdatesThatMakesUpdatesFailAndDoesNotSupportStatusMonitoring(); setupRequestorToReturnAllDataWithFlag(FEATURE); expectStreamRestart(); replayAll(); - try (StreamProcessor sp = createStreamProcessorWithStore(badStore)) { + try (StreamProcessor sp = createStreamProcessorWithStoreUpdates(badUpdates)) { sp.start(); EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; handler.onMessage("indirect/put", new MessageEvent("")); @@ -658,6 +670,12 @@ public void storeFailureOnIndirectPatchCausesStreamRestart() throws Exception { verifyAll(); } + private MockDataSourceUpdates dataSourceUpdatesThatMakesUpdatesFailAndDoesNotSupportStatusMonitoring() { + DataStore badStore = dataStoreThatThrowsException(new RuntimeException("sorry")); + DataStoreStatusProvider badStoreStatusProvider = new MockDataStoreStatusProvider(false); + return TestComponents.dataSourceUpdates(badStore, badStoreStatusProvider); + } + private void verifyEventCausesNoStreamRestart(String eventName, String eventData) throws Exception { expectNoStreamRestart(); verifyEventBehavior(eventName, eventData); @@ -678,6 +696,19 @@ private void verifyEventBehavior(String eventName, String eventData) throws Exce verifyAll(); } + private void verifyInvalidDataEvent(String eventName, String eventData) throws Exception { + BlockingQueue statuses = new LinkedBlockingQueue<>(); + dataSourceUpdates.statusBroadcaster.register(statuses::add); + + verifyEventCausesStreamRestartWithInMemoryStore(eventName, eventData); + + // We did not allow the stream to successfully process an event before causing the error, so the + // state will still be INITIALIZING, but we should be able to see that an error happened. + Status status = requireDataSourceStatus(statuses, State.INITIALIZING); + assertNotNull(status.getLastError()); + assertEquals(ErrorKind.INVALID_DATA, status.getLastError().getKind()); + } + private void expectNoStreamRestart() throws Exception { mockEventSource.start(); expectLastCall().times(1); @@ -771,44 +802,71 @@ public Action onConnectionError(Throwable t) { } } - private void testUnrecoverableHttpError(int status) throws Exception { - UnsuccessfulResponseException e = new UnsuccessfulResponseException(status); + private void testUnrecoverableHttpError(int statusCode) throws Exception { + UnsuccessfulResponseException e = new UnsuccessfulResponseException(statusCode); long startTime = System.currentTimeMillis(); StreamProcessor sp = createStreamProcessor(STREAM_URI); Future initFuture = sp.start(); + BlockingQueue statuses = new LinkedBlockingQueue<>(); + dataSourceUpdates.statusBroadcaster.register(statuses::add); + ConnectionErrorHandler errorHandler = mockEventSourceCreator.getNextReceivedParams().errorHandler; ConnectionErrorHandler.Action action = errorHandler.onConnectionError(e); assertEquals(ConnectionErrorHandler.Action.SHUTDOWN, action); - try { - initFuture.get(10, TimeUnit.SECONDS); - } catch (TimeoutException ignored) { - fail("Should not have timed out"); - } + shouldNotTimeOut(initFuture, Duration.ofSeconds(2)); assertTrue((System.currentTimeMillis() - startTime) < 9000); assertTrue(initFuture.isDone()); assertFalse(sp.isInitialized()); + + Status newStatus = requireDataSourceStatus(statuses, State.OFF); + assertEquals(ErrorKind.ERROR_RESPONSE, newStatus.getLastError().getKind()); + assertEquals(statusCode, newStatus.getLastError().getStatusCode()); } - private void testRecoverableHttpError(int status) throws Exception { - UnsuccessfulResponseException e = new UnsuccessfulResponseException(status); + private void testRecoverableHttpError(int statusCode) throws Exception { + UnsuccessfulResponseException e = new UnsuccessfulResponseException(statusCode); long startTime = System.currentTimeMillis(); StreamProcessor sp = createStreamProcessor(STREAM_URI); Future initFuture = sp.start(); - ConnectionErrorHandler errorHandler = mockEventSourceCreator.getNextReceivedParams().errorHandler; + BlockingQueue statuses = new LinkedBlockingQueue<>(); + dataSourceUpdates.statusBroadcaster.register(statuses::add); + + // simulate error + EventSourceParams eventSourceParams = mockEventSourceCreator.getNextReceivedParams(); + ConnectionErrorHandler errorHandler = eventSourceParams.errorHandler; ConnectionErrorHandler.Action action = errorHandler.onConnectionError(e); assertEquals(ConnectionErrorHandler.Action.PROCEED, action); - try { - initFuture.get(200, TimeUnit.MILLISECONDS); - fail("Expected timeout"); - } catch (TimeoutException ignored) { - } + shouldTimeOut(initFuture, Duration.ofMillis(200)); assertTrue((System.currentTimeMillis() - startTime) >= 200); assertFalse(initFuture.isDone()); assertFalse(sp.isInitialized()); + + Status failureStatus1 = requireDataSourceStatus(statuses, State.INITIALIZING); + assertEquals(ErrorKind.ERROR_RESPONSE, failureStatus1.getLastError().getKind()); + assertEquals(statusCode, failureStatus1.getLastError().getStatusCode()); + + // simulate successful retry + eventSourceParams.handler.onMessage("put", emptyPutEvent()); + + shouldNotTimeOut(initFuture, Duration.ofSeconds(2)); + assertTrue(initFuture.isDone()); + assertTrue(sp.isInitialized()); + + Status successStatus = requireDataSourceStatus(statuses, State.VALID); + assertSame(failureStatus1.getLastError(), successStatus.getLastError()); + + // simulate another error of the same kind - the difference is now the state will be INTERRUPTED + action = errorHandler.onConnectionError(e); + assertEquals(ConnectionErrorHandler.Action.PROCEED, action); + + Status failureStatus2 = requireDataSourceStatus(statuses, State.INTERRUPTED); + assertEquals(ErrorKind.ERROR_RESPONSE, failureStatus2.getLastError().getKind()); + assertEquals(statusCode, failureStatus2.getLastError().getStatusCode()); + assertNotSame(failureStatus2.getLastError(), failureStatus1.getLastError()); // a new instance of the same kind of error } private StreamProcessor createStreamProcessor(URI streamUri) { @@ -820,19 +878,45 @@ private StreamProcessor createStreamProcessor(LDConfig config, URI streamUri) { } private StreamProcessor createStreamProcessor(LDConfig config, URI streamUri, DiagnosticAccumulator diagnosticAccumulator) { - return new StreamProcessor(SDK_KEY, config.httpConfig, mockRequestor, dataStoreUpdates(dataStore), - mockEventSourceCreator, diagnosticAccumulator, - streamUri, DEFAULT_INITIAL_RECONNECT_DELAY); + return new StreamProcessor( + SDK_KEY, + config.httpConfig, + mockRequestor, + dataSourceUpdates, + mockEventSourceCreator, + Thread.MIN_PRIORITY, + diagnosticAccumulator, + streamUri, + DEFAULT_INITIAL_RECONNECT_DELAY + ); } private StreamProcessor createStreamProcessorWithRealHttp(LDConfig config, URI streamUri) { - return new StreamProcessor(SDK_KEY, config.httpConfig, mockRequestor, dataStoreUpdates(dataStore), null, null, - streamUri, DEFAULT_INITIAL_RECONNECT_DELAY); - } - - private StreamProcessor createStreamProcessorWithStore(DataStore store) { - return new StreamProcessor(SDK_KEY, LDConfig.DEFAULT.httpConfig, mockRequestor, dataStoreUpdates(store), - mockEventSourceCreator, null, STREAM_URI, DEFAULT_INITIAL_RECONNECT_DELAY); + return new StreamProcessor( + SDK_KEY, + config.httpConfig, + mockRequestor, + dataSourceUpdates, + null, + Thread.MIN_PRIORITY, + null, + streamUri, + DEFAULT_INITIAL_RECONNECT_DELAY + ); + } + + private StreamProcessor createStreamProcessorWithStoreUpdates(DataSourceUpdates storeUpdates) { + return new StreamProcessor( + SDK_KEY, + LDConfig.DEFAULT.httpConfig, + mockRequestor, + storeUpdates, + mockEventSourceCreator, + Thread.MIN_PRIORITY, + null, + STREAM_URI, + DEFAULT_INITIAL_RECONNECT_DELAY + ); } private String featureJson(String key, int version) { diff --git a/src/test/java/com/launchdarkly/sdk/server/TestComponents.java b/src/test/java/com/launchdarkly/sdk/server/TestComponents.java index cace1de30..3a0bb84d7 100644 --- a/src/test/java/com/launchdarkly/sdk/server/TestComponents.java +++ b/src/test/java/com/launchdarkly/sdk/server/TestComponents.java @@ -7,9 +7,14 @@ import com.launchdarkly.sdk.server.interfaces.ClientContext; import com.launchdarkly.sdk.server.interfaces.DataSource; import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorInfo; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.State; +import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider.CacheStats; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; @@ -18,6 +23,10 @@ import com.launchdarkly.sdk.server.interfaces.Event; import com.launchdarkly.sdk.server.interfaces.EventProcessor; import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; +import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; +import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; +import com.launchdarkly.sdk.server.interfaces.PersistentDataStore; +import com.launchdarkly.sdk.server.interfaces.PersistentDataStoreFactory; import java.io.IOException; import java.util.ArrayList; @@ -25,33 +34,43 @@ import java.util.Set; import java.util.concurrent.BlockingQueue; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import static com.launchdarkly.sdk.server.DataModel.FEATURES; @SuppressWarnings("javadoc") public class TestComponents { + static ScheduledExecutorService sharedExecutor = Executors.newSingleThreadScheduledExecutor(); + public static ClientContext clientContext(final String sdkKey, final LDConfig config) { - return new ClientContextImpl(sdkKey, config, null); + return new ClientContextImpl(sdkKey, config, sharedExecutor, null); } public static ClientContext clientContext(final String sdkKey, final LDConfig config, DiagnosticAccumulator diagnosticAccumulator) { - return new ClientContextImpl(sdkKey, config, diagnosticAccumulator); + return new ClientContextImpl(sdkKey, config, sharedExecutor, diagnosticAccumulator); } public static DataSourceFactory dataSourceWithData(FullDataSet data) { - return (context, dataStoreUpdates) -> new DataSourceWithData(data, dataStoreUpdates); + return (context, dataSourceUpdates) -> new DataSourceWithData(data, dataSourceUpdates); } - public static DataStore dataStoreThatThrowsException(final RuntimeException e) { + public static DataStore dataStoreThatThrowsException(RuntimeException e) { return new DataStoreThatThrowsException(e); } - public static DataStoreUpdates dataStoreUpdates(final DataStore store) { - return new DataStoreUpdatesImpl(store, null); + public static MockDataSourceUpdates dataSourceUpdates(DataStore store) { + return dataSourceUpdates(store, null); } + public static MockDataSourceUpdates dataSourceUpdates(DataStore store, DataStoreStatusProvider dataStoreStatusProvider) { + return new MockDataSourceUpdates(store, dataStoreStatusProvider); + } + static EventsConfiguration defaultEventsConfig() { return makeEventsConfig(false, false, null); } @@ -74,21 +93,30 @@ static EventsConfiguration makeEventsConfig(boolean allAttributesPrivate, boolea Set privateAttributes) { return new EventsConfiguration( allAttributesPrivate, - 0, null, EventProcessorBuilder.DEFAULT_FLUSH_INTERVAL, + 0, + null, + null, + EventProcessorBuilder.DEFAULT_FLUSH_INTERVAL, inlineUsersInEvents, privateAttributes, - 0, 0, EventProcessorBuilder.DEFAULT_USER_KEYS_FLUSH_INTERVAL, - EventProcessorBuilder.DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL); + 0, + EventProcessorBuilder.DEFAULT_USER_KEYS_FLUSH_INTERVAL, + EventProcessorBuilder.DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL + ); } public static DataSourceFactory specificDataSource(final DataSource up) { - return (context, dataStoreUpdates) -> up; + return (context, dataSourceUpdates) -> up; } public static DataStoreFactory specificDataStore(final DataStore store) { - return context -> store; + return (context, statusUpdater) -> store; } + public static PersistentDataStoreFactory specificPersistentDataStore(final PersistentDataStore store) { + return context -> store; + } + public static EventProcessorFactory specificEventProcessor(final EventProcessor ep) { return context -> ep; } @@ -110,20 +138,20 @@ public void flush() {} public static class DataSourceFactoryThatExposesUpdater implements DataSourceFactory { private final FullDataSet initialData; - private DataStoreUpdates dataStoreUpdates; + DataSourceUpdates dataSourceUpdates; public DataSourceFactoryThatExposesUpdater(FullDataSet initialData) { this.initialData = initialData; } @Override - public DataSource createDataSource(ClientContext context, DataStoreUpdates dataStoreUpdates) { - this.dataStoreUpdates = dataStoreUpdates; - return dataSourceWithData(initialData).createDataSource(context, dataStoreUpdates); + public DataSource createDataSource(ClientContext context, DataSourceUpdates dataSourceUpdates) { + this.dataSourceUpdates = dataSourceUpdates; + return dataSourceWithData(initialData).createDataSource(context, dataSourceUpdates); } public void updateFlag(FeatureFlag flag) { - dataStoreUpdates.upsert(FEATURES, flag.getKey(), new ItemDescriptor(flag.getVersion(), flag)); + dataSourceUpdates.upsert(FEATURES, flag.getKey(), new ItemDescriptor(flag.getVersion(), flag)); } } @@ -142,15 +170,15 @@ public void close() throws IOException { private static class DataSourceWithData implements DataSource { private final FullDataSet data; - private final DataStoreUpdates dataStoreUpdates; + private final DataSourceUpdates dataSourceUpdates; - DataSourceWithData(FullDataSet data, DataStoreUpdates dataStoreUpdates) { + DataSourceWithData(FullDataSet data, DataSourceUpdates dataSourceUpdates) { this.data = data; - this.dataStoreUpdates = dataStoreUpdates; + this.dataSourceUpdates = dataSourceUpdates; } public Future start() { - dataStoreUpdates.init(data); + dataSourceUpdates.init(data); return CompletableFuture.completedFuture(null); } @@ -162,6 +190,84 @@ public void close() throws IOException { } } + public static class MockDataSourceUpdates implements DataSourceUpdates { + private final DataSourceUpdatesImpl wrappedInstance; + private final DataStoreStatusProvider dataStoreStatusProvider; + public final EventBroadcasterImpl flagChangeEventBroadcaster; + public final EventBroadcasterImpl + statusBroadcaster; + public final BlockingQueue> receivedInits = new LinkedBlockingQueue<>(); + + public MockDataSourceUpdates(DataStore store, DataStoreStatusProvider dataStoreStatusProvider) { + this.dataStoreStatusProvider = dataStoreStatusProvider; + this.flagChangeEventBroadcaster = EventBroadcasterImpl.forFlagChangeEvents(sharedExecutor); + this.statusBroadcaster = EventBroadcasterImpl.forDataSourceStatus(sharedExecutor); + this.wrappedInstance = new DataSourceUpdatesImpl( + store, + dataStoreStatusProvider, + flagChangeEventBroadcaster, + statusBroadcaster, + sharedExecutor, + null + ); + } + + @Override + public boolean init(FullDataSet allData) { + receivedInits.add(allData); + return wrappedInstance.init(allData); + } + + @Override + public boolean upsert(DataKind kind, String key, ItemDescriptor item) { + return wrappedInstance.upsert(kind, key, item); + } + + @Override + public DataStoreStatusProvider getDataStoreStatusProvider() { + return dataStoreStatusProvider; + } + + @Override + public void updateStatus(State newState, ErrorInfo newError) { + wrappedInstance.updateStatus(newState, newError); + } + + public DataSourceStatusProvider.Status getLastStatus() { + return wrappedInstance.getLastStatus(); + } + + // this method is surfaced for use by tests in other packages that can't see the EventBroadcasterImpl class + public void register(DataSourceStatusProvider.StatusListener listener) { + statusBroadcaster.register(listener); + } + + public FullDataSet awaitInit() { + try { + FullDataSet value = receivedInits.poll(5, TimeUnit.SECONDS); + if (value != null) { + return value; + } + } catch (InterruptedException e) {} + throw new RuntimeException("did not receive expected init call"); + } + } + + public static class DataStoreFactoryThatExposesUpdater implements DataStoreFactory { + public volatile DataStoreUpdates dataStoreUpdates; + private final DataStoreFactory wrappedFactory; + + public DataStoreFactoryThatExposesUpdater(DataStoreFactory wrappedFactory) { + this.wrappedFactory = wrappedFactory; + } + + @Override + public DataStore createDataStore(ClientContext context, DataStoreUpdates dataStoreUpdates) { + this.dataStoreUpdates = dataStoreUpdates; + return wrappedFactory.createDataStore(context, dataStoreUpdates); + } + } + private static class DataStoreThatThrowsException implements DataStore { private final RuntimeException e; @@ -190,72 +296,62 @@ public boolean upsert(DataKind kind, String key, ItemDescriptor item) { public boolean isInitialized() { return true; } + + public boolean isStatusMonitoringEnabled() { + return false; + } + + public CacheStats getCacheStats() { + return null; + } } - public static class DataStoreWithStatusUpdates implements DataStore, DataStoreStatusProvider { - private final DataStore wrappedStore; - private final List listeners = new ArrayList<>(); - volatile Status currentStatus = new Status(true, false); + public static class MockDataStoreStatusProvider implements DataStoreStatusProvider { + public final EventBroadcasterImpl statusBroadcaster; + private final AtomicReference lastStatus; + private final boolean statusMonitoringEnabled; - DataStoreWithStatusUpdates(DataStore wrappedStore) { - this.wrappedStore = wrappedStore; + public MockDataStoreStatusProvider() { + this(true); } - public void broadcastStatusChange(final Status newStatus) { - currentStatus = newStatus; - final StatusListener[] ls; - synchronized (this) { - ls = listeners.toArray(new StatusListener[listeners.size()]); - } - Thread t = new Thread(() -> { - for (StatusListener l: ls) { - l.dataStoreStatusChanged(newStatus); - } - }); - t.start(); + public MockDataStoreStatusProvider(boolean statusMonitoringEnabled) { + this.statusBroadcaster = EventBroadcasterImpl.forDataStoreStatus(sharedExecutor); + this.lastStatus = new AtomicReference<>(new DataStoreStatusProvider.Status(true, false)); + this.statusMonitoringEnabled = statusMonitoringEnabled; } - public void close() throws IOException { - wrappedStore.close(); - } - - public ItemDescriptor get(DataKind kind, String key) { - return wrappedStore.get(kind, key); - } - - public KeyedItems getAll(DataKind kind) { - return wrappedStore.getAll(kind); - } - - public void init(FullDataSet allData) { - wrappedStore.init(allData); - } - - public boolean upsert(DataKind kind, String key, ItemDescriptor item) { - return wrappedStore.upsert(kind, key, item); - } - - public boolean isInitialized() { - return wrappedStore.isInitialized(); + // visible for tests + public void updateStatus(DataStoreStatusProvider.Status newStatus) { + if (newStatus != null) { + DataStoreStatusProvider.Status oldStatus = lastStatus.getAndSet(newStatus); + if (!newStatus.equals(oldStatus)) { + statusBroadcaster.broadcast(newStatus); + } + } } - + + @Override public Status getStoreStatus() { - return currentStatus; + return lastStatus.get(); } - public boolean addStatusListener(StatusListener listener) { - synchronized (this) { - listeners.add(listener); - } - return true; + @Override + public void addStatusListener(StatusListener listener) { + statusBroadcaster.register(listener); } + @Override public void removeStatusListener(StatusListener listener) { - synchronized (this) { - listeners.remove(listener); - } + statusBroadcaster.unregister(listener); } + @Override + public boolean isStatusMonitoringEnabled() { + return statusMonitoringEnabled; + } + + @Override public CacheStats getCacheStats() { return null; } diff --git a/src/test/java/com/launchdarkly/sdk/server/TestUtil.java b/src/test/java/com/launchdarkly/sdk/server/TestUtil.java index c81630b5c..95789c978 100644 --- a/src/test/java/com/launchdarkly/sdk/server/TestUtil.java +++ b/src/test/java/com/launchdarkly/sdk/server/TestUtil.java @@ -7,6 +7,7 @@ import com.launchdarkly.sdk.LDValueType; import com.launchdarkly.sdk.server.DataModel.FeatureFlag; import com.launchdarkly.sdk.server.DataModel.Segment; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; @@ -18,16 +19,23 @@ import org.hamcrest.Matcher; import org.hamcrest.TypeSafeDiagnosingMatcher; +import java.time.Duration; +import java.time.Instant; import java.util.HashSet; import java.util.Set; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.function.Supplier; import static com.launchdarkly.sdk.server.DataModel.FEATURES; import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.fail; @@ -44,6 +52,23 @@ public class TestUtil { */ public static final Gson TEST_GSON_INSTANCE = new Gson(); + // repeats until action returns non-null value, throws exception on timeout + public static T repeatWithTimeout(Duration timeout, Duration interval, Supplier action) { + Instant deadline = Instant.now().plus(timeout); + while (Instant.now().isBefore(deadline)) { + T result = action.get(); + if (result != null) { + return result; + } + try { + Thread.sleep(interval.toMillis()); + } catch (InterruptedException e) { // it's annoying to have to keep declaring this exception further up the call chain + throw new RuntimeException(e); + } + } + throw new RuntimeException("timed out after " + timeout); + } + public static void upsertFlag(DataStore store, FeatureFlag flag) { store.upsert(FEATURES, flag.getKey(), new ItemDescriptor(flag.getVersion(), flag)); } @@ -51,6 +76,51 @@ public static void upsertFlag(DataStore store, FeatureFlag flag) { public static void upsertSegment(DataStore store, Segment segment) { store.upsert(SEGMENTS, segment.getKey(), new ItemDescriptor(segment.getVersion(), segment)); } + + public static void shouldNotTimeOut(Future future, Duration interval) throws ExecutionException, InterruptedException { + try { + future.get(interval.toMillis(), TimeUnit.MILLISECONDS); + } catch (TimeoutException ignored) { + fail("Should not have timed out"); + } + } + + public static void shouldTimeOut(Future future, Duration interval) throws ExecutionException, InterruptedException { + try { + future.get(interval.toMillis(), TimeUnit.MILLISECONDS); + fail("Expected timeout"); + } catch (TimeoutException ignored) { + } + } + + public static DataSourceStatusProvider.Status requireDataSourceStatus(BlockingQueue statuses) { + try { + DataSourceStatusProvider.Status status = statuses.poll(1, TimeUnit.SECONDS); + assertNotNull(status); + return status; + } catch (InterruptedException e) { // it's annoying to have to keep declaring this exception further up the call chain + throw new RuntimeException(e); + } + } + + public static DataSourceStatusProvider.Status requireDataSourceStatus(BlockingQueue statuses, + DataSourceStatusProvider.State expectedState) { + DataSourceStatusProvider.Status status = requireDataSourceStatus(statuses); + assertEquals(expectedState, status.getState()); + return status; + } + + public static DataSourceStatusProvider.Status requireDataSourceStatusEventually(BlockingQueue statuses, + DataSourceStatusProvider.State expectedState, DataSourceStatusProvider.State possibleStateBeforeThat) { + return repeatWithTimeout(Duration.ofSeconds(2), Duration.ZERO, () -> { + DataSourceStatusProvider.Status status = requireDataSourceStatus(statuses); + if (status.getState() == expectedState) { + return status; + } + assertEquals(possibleStateBeforeThat, status.getState()); + return null; + }); + } public static class FlagChangeEventSink extends FlagChangeEventSinkBase implements FlagChangeListener { @Override diff --git a/src/test/java/com/launchdarkly/sdk/server/UtilTest.java b/src/test/java/com/launchdarkly/sdk/server/UtilTest.java index ef7ffed7f..93c097c23 100644 --- a/src/test/java/com/launchdarkly/sdk/server/UtilTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/UtilTest.java @@ -37,4 +37,15 @@ public void testSocketTimeout() { shutdownHttpClient(httpClient); } } + + @Test + public void describeDuration() { + assertEquals("15 milliseconds", Util.describeDuration(Duration.ofMillis(15))); + assertEquals("1500 milliseconds", Util.describeDuration(Duration.ofMillis(1500))); + assertEquals("1 second", Util.describeDuration(Duration.ofMillis(1000))); + assertEquals("2 seconds", Util.describeDuration(Duration.ofMillis(2000))); + assertEquals("70 seconds", Util.describeDuration(Duration.ofMillis(70000))); + assertEquals("1 minute", Util.describeDuration(Duration.ofMillis(60000))); + assertEquals("2 minutes", Util.describeDuration(Duration.ofMillis(120000))); + } } diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java index 0861c178c..249badc61 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java @@ -1,7 +1,10 @@ package com.launchdarkly.sdk.server.integrations; import com.launchdarkly.sdk.server.LDConfig; +import com.launchdarkly.sdk.server.TestComponents; +import com.launchdarkly.sdk.server.TestComponents.MockDataSourceUpdates; import com.launchdarkly.sdk.server.interfaces.DataSource; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStore; import org.junit.Test; @@ -10,32 +13,41 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.time.Duration; +import java.util.concurrent.BlockingQueue; import java.util.concurrent.Future; +import java.util.concurrent.LinkedBlockingQueue; import static com.google.common.collect.Iterables.size; import static com.launchdarkly.sdk.server.DataModel.FEATURES; import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; import static com.launchdarkly.sdk.server.DataStoreTestTypes.toItemsMap; import static com.launchdarkly.sdk.server.TestComponents.clientContext; -import static com.launchdarkly.sdk.server.TestComponents.dataStoreUpdates; import static com.launchdarkly.sdk.server.TestComponents.inMemoryDataStore; +import static com.launchdarkly.sdk.server.TestUtil.repeatWithTimeout; +import static com.launchdarkly.sdk.server.TestUtil.requireDataSourceStatus; +import static com.launchdarkly.sdk.server.TestUtil.requireDataSourceStatusEventually; import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.ALL_FLAG_KEYS; import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.ALL_SEGMENT_KEYS; import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.getResourceContents; import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.resourceFilePath; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; -import static org.junit.Assert.fail; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; @SuppressWarnings("javadoc") public class FileDataSourceTest { private static final Path badFilePath = Paths.get("no-such-file.json"); - private final DataStore store = inMemoryDataStore(); + private final DataStore store; + private MockDataSourceUpdates dataSourceUpdates; private final LDConfig config = new LDConfig.Builder().build(); private final FileDataSourceBuilder factory; public FileDataSourceTest() throws Exception { + store = inMemoryDataStore(); + dataSourceUpdates = TestComponents.dataSourceUpdates(store); factory = makeFactoryWithFile(resourceFilePath("all-properties.json")); } @@ -44,7 +56,7 @@ private static FileDataSourceBuilder makeFactoryWithFile(Path path) { } private DataSource makeDataSource(FileDataSourceBuilder builder) { - return builder.createDataSource(clientContext("", config), dataStoreUpdates(store)); + return builder.createDataSource(clientContext("", config), dataSourceUpdates); } @Test @@ -81,7 +93,20 @@ public void initializedIsTrueAfterSuccessfulLoad() throws Exception { assertThat(fp.isInitialized(), equalTo(true)); } } - + + @Test + public void statusIsValidAfterSuccessfulLoad() throws Exception { + BlockingQueue statuses = new LinkedBlockingQueue<>(); + dataSourceUpdates.register(statuses::add); + + try (DataSource fp = makeDataSource(factory)) { + fp.start(); + assertThat(fp.isInitialized(), equalTo(true)); + + requireDataSourceStatus(statuses, DataSourceStatusProvider.State.VALID); + } + } + @Test public void startFutureIsCompletedAfterUnsuccessfulLoad() throws Exception { factory.filePaths(badFilePath); @@ -99,6 +124,22 @@ public void initializedIsFalseAfterUnsuccessfulLoad() throws Exception { assertThat(fp.isInitialized(), equalTo(false)); } } + + @Test + public void statusIsInitializingAfterUnsuccessfulLoad() throws Exception { + BlockingQueue statuses = new LinkedBlockingQueue<>(); + dataSourceUpdates.register(statuses::add); + + factory.filePaths(badFilePath); + try (DataSource fp = makeDataSource(factory)) { + fp.start(); + assertThat(fp.isInitialized(), equalTo(false)); + + DataSourceStatusProvider.Status status = requireDataSourceStatus(statuses, DataSourceStatusProvider.State.INITIALIZING); + assertNotNull(status.getLastError()); + assertEquals(DataSourceStatusProvider.ErrorKind.INVALID_DATA, status.getLastError().getKind()); + } + } @Test public void modifiedFileIsNotReloadedIfAutoUpdateIsOff() throws Exception { @@ -125,22 +166,19 @@ public void modifiedFileIsNotReloadedIfAutoUpdateIsOff() throws Exception { public void modifiedFileIsReloadedIfAutoUpdateIsOn() throws Exception { File file = makeTempFlagFile(); FileDataSourceBuilder factory1 = makeFactoryWithFile(file.toPath()).autoUpdate(true); - long maxMsToWait = 10000; try { setFileContents(file, getResourceContents("flag-only.json")); // this file has 1 flag try (DataSource fp = makeDataSource(factory1)) { 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) { + repeatWithTimeout(Duration.ofSeconds(10), Duration.ofMillis(500), () -> { if (toItemsMap(store.getAll(FEATURES)).size() == ALL_FLAG_KEYS.size()) { - // success - return; + // success - return a non-null value to make repeatWithTimeout end + return fp; } - Thread.sleep(500); - } - fail("Waited " + maxMsToWait + "ms after modifying file and it did not reload"); + return null; + }); } } finally { file.delete(); @@ -149,24 +187,29 @@ public void modifiedFileIsReloadedIfAutoUpdateIsOn() throws Exception { @Test public void ifFilesAreBadAtStartTimeAutoUpdateCanStillLoadGoodDataLater() throws Exception { + BlockingQueue statuses = new LinkedBlockingQueue<>(); + dataSourceUpdates.register(statuses::add); + File file = makeTempFlagFile(); setFileContents(file, "not valid"); FileDataSourceBuilder factory1 = makeFactoryWithFile(file.toPath()).autoUpdate(true); - long maxMsToWait = 10000; try { try (DataSource fp = makeDataSource(factory1)) { 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) { + repeatWithTimeout(Duration.ofSeconds(10), Duration.ofMillis(500), () -> { if (toItemsMap(store.getAll(FEATURES)).size() > 0) { - // success - return; + // success - status is now VALID, after having first been INITIALIZING - can still see that an error occurred + DataSourceStatusProvider.Status status = requireDataSourceStatusEventually(statuses, + DataSourceStatusProvider.State.VALID, DataSourceStatusProvider.State.INITIALIZING); + assertNotNull(status.getLastError()); + assertEquals(DataSourceStatusProvider.ErrorKind.INVALID_DATA, status.getLastError().getKind()); + + return status; } - Thread.sleep(500); - } - fail("Waited " + maxMsToWait + "ms after modifying file and it did not reload"); + return null; + }); } } finally { file.delete(); diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/LoggingConfigurationBuilderTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/LoggingConfigurationBuilderTest.java new file mode 100644 index 000000000..eef9e5dae --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/LoggingConfigurationBuilderTest.java @@ -0,0 +1,34 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.interfaces.LoggingConfiguration; + +import org.junit.Test; + +import java.time.Duration; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +@SuppressWarnings("javadoc") +public class LoggingConfigurationBuilderTest { + @Test + public void testDefaults() { + LoggingConfiguration c = Components.logging().createLoggingConfiguration(); + assertEquals(LoggingConfigurationBuilder.DEFAULT_LOG_DATA_SOURCE_OUTAGE_AS_ERROR_AFTER, + c.getLogDataSourceOutageAsErrorAfter()); + } + + @Test + public void logDataSourceOutageAsErrorAfter() { + LoggingConfiguration c1 = Components.logging() + .logDataSourceOutageAsErrorAfter(Duration.ofMinutes(9)) + .createLoggingConfiguration(); + assertEquals(Duration.ofMinutes(9), c1.getLogDataSourceOutageAsErrorAfter()); + + LoggingConfiguration c2 = Components.logging() + .logDataSourceOutageAsErrorAfter(null) + .createLoggingConfiguration(); + assertNull(c2.getLogDataSourceOutageAsErrorAfter()); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/MockPersistentDataStore.java b/src/test/java/com/launchdarkly/sdk/server/integrations/MockPersistentDataStore.java index f8697dbb7..3f7fca20a 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/MockPersistentDataStore.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/MockPersistentDataStore.java @@ -22,14 +22,14 @@ public static final class MockDatabaseInstance { Map initedByPrefix = new HashMap<>(); } - final Map> data; - final AtomicBoolean inited; - final AtomicInteger initedCount = new AtomicInteger(0); - volatile int initedQueryCount; - volatile boolean persistOnlyAsString; - volatile boolean unavailable; - volatile RuntimeException fakeError; - volatile Runnable updateHook; + public final Map> data; + public final AtomicBoolean inited; + public final AtomicInteger initedCount = new AtomicInteger(0); + public volatile int initedQueryCount; + public volatile boolean persistOnlyAsString; + public volatile boolean unavailable; + public volatile RuntimeException fakeError; + public volatile Runnable updateHook; public MockPersistentDataStore() { this.data = new HashMap<>(); diff --git a/src/test/resources/logback.xml b/src/test/resources/logback.xml index 757bb2429..6be0de84e 100644 --- a/src/test/resources/logback.xml +++ b/src/test/resources/logback.xml @@ -7,7 +7,7 @@ - + From e8621a22cd87855f21fb095b57e81d56e3910ab4 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 13 May 2020 18:38:33 -0700 Subject: [PATCH 436/673] typo --- .../com/launchdarkly/sdk/server/interfaces/package-info.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/package-info.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/package-info.java index 537034709..b47a42367 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/package-info.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/package-info.java @@ -2,7 +2,7 @@ * The package for interfaces that allow customization of LaunchDarkly components, and interfaces * to other advanced SDK features. *

    - * Most application will not need to refer to these types. You will use them if you are creating a + * Most applications will not need to refer to these types. You will use them if you are creating a * plug-in component, such as a database integration, or if you use advanced features such as * {@link com.launchdarkly.sdk.server.LDClientInterface#getDataStoreStatusProvider()} or * {@link com.launchdarkly.sdk.server.LDClientInterface#registerFlagChangeListener(FlagChangeListener)}. From c5340c8ad58ca50b07fe0fa766a1c29d8845b243 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 14 May 2020 17:19:56 -0700 Subject: [PATCH 437/673] typo --- .../sdk/server/interfaces/DataSourceStatusProvider.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProvider.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProvider.java index e103a44e2..0233cab8e 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProvider.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProvider.java @@ -128,7 +128,7 @@ public static enum ErrorKind { } /** - * A description of an error condition that the data source encountered, + * A description of an error condition that the data source encountered. * * @see Status#getLastError() */ From 6035ee1b043b5c8ce18160cfbc8492167569dea5 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 15 May 2020 12:06:43 -0700 Subject: [PATCH 438/673] add DataSourceStatusProvider.waitFor() --- .../server/DataSourceStatusProviderImpl.java | 19 +++-- .../sdk/server/DataSourceUpdatesImpl.java | 31 ++++++- .../com/launchdarkly/sdk/server/LDClient.java | 2 +- .../interfaces/DataSourceStatusProvider.java | 37 +++++++- .../sdk/server/LDClientListenersTest.java | 85 +++++++++++++++++++ 5 files changed, 164 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/DataSourceStatusProviderImpl.java b/src/main/java/com/launchdarkly/sdk/server/DataSourceStatusProviderImpl.java index cadb09ca5..9eff71c84 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DataSourceStatusProviderImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/DataSourceStatusProviderImpl.java @@ -2,23 +2,30 @@ import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; -import java.util.function.Supplier; +import java.time.Duration; final class DataSourceStatusProviderImpl implements DataSourceStatusProvider { private final EventBroadcasterImpl dataSourceStatusNotifier; - private final Supplier statusSupplier; + private final DataSourceUpdatesImpl dataSourceUpdates; - DataSourceStatusProviderImpl(EventBroadcasterImpl dataSourceStatusNotifier, - Supplier statusSupplier) { + DataSourceStatusProviderImpl( + EventBroadcasterImpl dataSourceStatusNotifier, + DataSourceUpdatesImpl dataSourceUpdates + ) { this.dataSourceStatusNotifier = dataSourceStatusNotifier; - this.statusSupplier = statusSupplier; + this.dataSourceUpdates = dataSourceUpdates; } @Override public Status getStatus() { - return statusSupplier.get(); + return dataSourceUpdates.getLastStatus(); } + @Override + public boolean waitFor(State desiredState, Duration timeout) throws InterruptedException { + return dataSourceUpdates.waitFor(desiredState, timeout); + } + @Override public void addStatusListener(StatusListener listener) { dataSourceStatusNotifier.register(listener); diff --git a/src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java b/src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java index 70ef123c1..573d81a35 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java @@ -52,6 +52,7 @@ final class DataSourceUpdatesImpl implements DataSourceUpdates { private final DataModelDependencies.DependencyTracker dependencyTracker = new DataModelDependencies.DependencyTracker(); private final DataStoreStatusProvider dataStoreStatusProvider; private final OutageTracker outageTracker; + private final Object stateLock = new Object(); private volatile Status currentStatus; private volatile boolean lastStoreUpdateFailed = false; @@ -142,7 +143,7 @@ public void updateStatus(State newState, ErrorInfo newError) { Status statusToBroadcast = null; - synchronized (this) { + synchronized (stateLock) { Status oldStatus = currentStatus; if (newState == State.INTERRUPTED && oldStatus.getState() == State.INITIALIZING) { @@ -156,6 +157,7 @@ public void updateStatus(State newState, ErrorInfo newError) { newError == null ? currentStatus.getLastError() : newError ); statusToBroadcast = currentStatus; + stateLock.notifyAll(); } outageTracker.trackDataSourceState(newState, newError); @@ -166,12 +168,37 @@ public void updateStatus(State newState, ErrorInfo newError) { } } + // package-private - called from DataSourceStatusProviderImpl Status getLastStatus() { - synchronized (this) { + synchronized (stateLock) { return currentStatus; } } + // package-private - called from DataSourceStatusProviderImpl + boolean waitFor(State desiredState, Duration timeout) throws InterruptedException { + long deadline = System.currentTimeMillis() + timeout.toMillis(); + synchronized (stateLock) { + while (true) { + if (currentStatus.getState() == desiredState) { + return true; + } + if (currentStatus.getState() == State.OFF) { + return false; + } + if (timeout.isZero()) { + stateLock.wait(); + } else { + long now = System.currentTimeMillis(); + if (now >= deadline) { + return false; + } + stateLock.wait(deadline - now); + } + } + } + } + private boolean hasFlagChangeEventListeners() { return flagChangeEventNotifier.hasListeners(); } diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClient.java b/src/main/java/com/launchdarkly/sdk/server/LDClient.java index f5c170627..4bff28314 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClient.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClient.java @@ -194,7 +194,7 @@ public DataModel.Segment getSegment(String key) { ); this.dataSourceUpdates = dataSourceUpdates; this.dataSource = dataSourceFactory.createDataSource(context, dataSourceUpdates); - this.dataSourceStatusProvider = new DataSourceStatusProviderImpl(dataSourceStatusNotifier, dataSourceUpdates::getLastStatus); + this.dataSourceStatusProvider = new DataSourceStatusProviderImpl(dataSourceStatusNotifier, dataSourceUpdates); Future startFuture = dataSource.start(); if (!config.startWait.isZero() && !config.startWait.isNegative()) { diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProvider.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProvider.java index 0233cab8e..0968a00ae 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProvider.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProvider.java @@ -2,11 +2,14 @@ import com.google.common.base.Strings; +import java.time.Duration; import java.time.Instant; import java.util.Objects; /** - * An interface for querying the status of a {@link DataSource}. + * An interface for querying the status of a {@link DataSource}. The data source is the component + * that receives updates to feature flag data; normally this is a streaming connection, but it could + * be polling or file data depending on your configuration. *

    * An implementation of this interface is returned by {@link com.launchdarkly.sdk.server.LDClientInterface#getDataSourceStatusProvider}. * Application code never needs to implement this interface. @@ -48,6 +51,38 @@ public interface DataSourceStatusProvider { */ public void removeStatusListener(StatusListener listener); + /** + * A synchronous method for waiting for a desired connection state. + *

    + * If the current state is already {@code desiredState} when this method is called, it immediately returns. + * Otherwise, it blocks until 1. the state has become {@code desiredState}, 2. the state has become + * {@link State#OFF} (since that is a permanent condition), 3. the specified timeout elapses, or 4. + * the current thread is deliberately interrupted with {@link Thread#interrupt()}. + *

    + * A scenario in which this might be useful is if you want to create the {@code LDClient} without waiting + * for it to initialize, and then wait for initialization at a later time or on a different thread: + *

    
    +   *     // create the client but do not wait
    +   *     LDConfig config = new LDConfig.Builder().startWait(Duration.ZERO).build();
    +   *     client = new LDClient(sdkKey, config);
    +   *     
    +   *     // later, possibly on another thread:
    +   *     boolean inited = client.getDataSourceStatusProvider().waitFor(
    +   *         DataSourceStatusProvider.State.VALID, Duration.ofSeconds(10));
    +   *     if (!inited) {
    +   *         // do whatever is appropriate if initialization has timed out
    +   *     }       
    +   * 
    + * + * @param desiredState the desired connection state (normally this would be {@link State#VALID}) + * @param timeout the maximum amount of time to wait-- or {@link Duration#ZERO} to block indefinitely + * (although it will still return if the thread is explicitly interrupted) + * @return true if the connection is now in the desired state; false if it timed out, or if the state + * changed to {@link State#OFF} and that was not the desired state + * @throws InterruptedException if {@link Thread#interrupt()} was called on this thread while blocked + */ + public boolean waitFor(State desiredState, Duration timeout) throws InterruptedException; + /** * An enumeration of possible values for {@link DataSourceStatusProvider.Status#getState()}. */ diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java index ae5368ed7..a310991fc 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java @@ -19,6 +19,7 @@ import org.easymock.EasyMockSupport; import org.junit.Test; +import java.time.Duration; import java.time.Instant; import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; @@ -31,6 +32,7 @@ import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.hamcrest.Matchers.lessThan; import static org.hamcrest.Matchers.nullValue; import static org.junit.Assert.assertEquals; @@ -180,6 +182,89 @@ public void dataSourceStatusProviderSendsStatusUpdates() throws Exception { } } + @Test + public void dataSourceStatusProviderWaitForStatusWithStatusAlreadyCorrect() throws Exception { + DataSourceFactoryThatExposesUpdater updatableSource = new DataSourceFactoryThatExposesUpdater(new DataBuilder().build()); + LDConfig config = new LDConfig.Builder() + .dataSource(updatableSource) + .events(Components.noEvents()) + .build(); + + try (LDClient client = new LDClient(SDK_KEY, config)) { + updatableSource.dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.VALID, null); + + boolean success = client.getDataSourceStatusProvider().waitFor(DataSourceStatusProvider.State.VALID, + Duration.ofMillis(500)); + assertThat(success, equalTo(true)); + } + } + + @Test + public void dataSourceStatusProviderWaitForStatusSucceeds() throws Exception { + DataSourceFactoryThatExposesUpdater updatableSource = new DataSourceFactoryThatExposesUpdater(new DataBuilder().build()); + LDConfig config = new LDConfig.Builder() + .dataSource(updatableSource) + .events(Components.noEvents()) + .build(); + + try (LDClient client = new LDClient(SDK_KEY, config)) { + new Thread(() -> { + System.out.println("in thread"); + try { + Thread.sleep(100); + } catch (InterruptedException e) { + System.out.println("interrupted"); + } + System.out.println("updating"); + updatableSource.dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.VALID, null); + }).start(); + + boolean success = client.getDataSourceStatusProvider().waitFor(DataSourceStatusProvider.State.VALID, + Duration.ofMillis(500)); + assertThat(success, equalTo(true)); + } + } + + @Test + public void dataSourceStatusProviderWaitForStatusTimesOut() throws Exception { + DataSourceFactoryThatExposesUpdater updatableSource = new DataSourceFactoryThatExposesUpdater(new DataBuilder().build()); + LDConfig config = new LDConfig.Builder() + .dataSource(updatableSource) + .events(Components.noEvents()) + .build(); + + try (LDClient client = new LDClient(SDK_KEY, config)) { + long timeStart = System.currentTimeMillis(); + boolean success = client.getDataSourceStatusProvider().waitFor(DataSourceStatusProvider.State.VALID, + Duration.ofMillis(300)); + long timeEnd = System.currentTimeMillis(); + assertThat(success, equalTo(false)); + assertThat(timeEnd - timeStart, greaterThanOrEqualTo(270L)); + } + } + + @Test + public void dataSourceStatusProviderWaitForStatusEndsIfShutDown() throws Exception { + DataSourceFactoryThatExposesUpdater updatableSource = new DataSourceFactoryThatExposesUpdater(new DataBuilder().build()); + LDConfig config = new LDConfig.Builder() + .dataSource(updatableSource) + .events(Components.noEvents()) + .build(); + + try (LDClient client = new LDClient(SDK_KEY, config)) { + new Thread(() -> { + updatableSource.dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.OFF, null); + }).start(); + + long timeStart = System.currentTimeMillis(); + boolean success = client.getDataSourceStatusProvider().waitFor(DataSourceStatusProvider.State.VALID, + Duration.ofMillis(500)); + long timeEnd = System.currentTimeMillis(); + assertThat(success, equalTo(false)); + assertThat(timeEnd - timeStart, lessThan(500L)); + } + } + @Test public void dataStoreStatusMonitoringIsDisabledForInMemoryStore() throws Exception { LDConfig config = new LDConfig.Builder() From 60e182501938c1cb33470cde237cec74072ffdac Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 15 May 2020 12:09:02 -0700 Subject: [PATCH 439/673] comment fix --- .../sdk/server/interfaces/DataSourceStatusProvider.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProvider.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProvider.java index 0968a00ae..b6392ef8d 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProvider.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProvider.java @@ -76,7 +76,7 @@ public interface DataSourceStatusProvider { * * @param desiredState the desired connection state (normally this would be {@link State#VALID}) * @param timeout the maximum amount of time to wait-- or {@link Duration#ZERO} to block indefinitely - * (although it will still return if the thread is explicitly interrupted) + * (unless the thread is explicitly interrupted) * @return true if the connection is now in the desired state; false if it timed out, or if the state * changed to {@link State#OFF} and that was not the desired state * @throws InterruptedException if {@link Thread#interrupt()} was called on this thread while blocked From 88fa2e0ac156518ed53a322433d18160558b7585 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 15 May 2020 12:41:54 -0700 Subject: [PATCH 440/673] typo --- .../sdk/server/interfaces/LoggingConfigurationFactory.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/LoggingConfigurationFactory.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/LoggingConfigurationFactory.java index 54f70bfc0..e2bd3e635 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/LoggingConfigurationFactory.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/LoggingConfigurationFactory.java @@ -1,7 +1,7 @@ package com.launchdarkly.sdk.server.interfaces; /** - * Interface for a factory that creates an {@link LoggingConfiguration}. + * Interface for a factory that creates a {@link LoggingConfiguration}. * * @see com.launchdarkly.sdk.server.Components#logging() * @see com.launchdarkly.sdk.server.LDConfig.Builder#logging(LoggingConfigurationFactory) From c32bebdbc453e4dab5a6fe1b22dc83bae53e27f3 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 15 May 2020 14:14:56 -0700 Subject: [PATCH 441/673] rm debugging --- .../java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java b/src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java index 573d81a35..28da505a7 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java @@ -319,7 +319,6 @@ private void recordError(ErrorInfo newError) { // Accumulate how many times each kind of error has occurred during the outage - use just the basic // properties as the key so the map won't expand indefinitely ErrorInfo basicErrorInfo = new ErrorInfo(newError.getKind(), newError.getStatusCode(), null, null); - LDClient.logger.warn("recordError(" + basicErrorInfo + ")"); errorCounts.compute(basicErrorInfo, (key, oldValue) -> oldValue == null ? 1 : oldValue.intValue() + 1); } From 74c082845cf4391753edb6a88fae36305da8e579 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 15 May 2020 17:34:21 -0700 Subject: [PATCH 442/673] improve comments --- .../com/launchdarkly/sdk/server/LDClient.java | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClient.java b/src/main/java/com/launchdarkly/sdk/server/LDClient.java index 4bff28314..3ec38a4bc 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClient.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClient.java @@ -87,7 +87,8 @@ public final class LDClient implements LDClientInterface { * 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()}. + * initialization has succeeded by calling {@link #initialized()}. If you prefer to customize + * this behavior, use {@link LDClient#LDClient(String, LDConfig)} instead. * * @param sdkKey the SDK key for your LaunchDarkly environment * @see LDClient#LDClient(String, LDConfig) @@ -123,7 +124,23 @@ private static final DataModel.Segment getSegment(DataStore store, String key) { * 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()}. + * whether initialization has succeeded by calling {@link #initialized()}. + *

    + * If you prefer to have the constructor return immediately, and then wait for initialization to finish + * at some other point, you can use {@link #getDataSourceStatusProvider()} as follows: + *

    
    +   *     LDConfig config = new LDConfig.Builder()
    +   *         .startWait(Duration.ZERO)
    +   *         .build();
    +   *     LDClient client = new LDClient(sdkKey, config);
    +   *     
    +   *     // later, when you want to wait for initialization to finish:
    +   *     boolean inited = client.getDataSourceStatusProvider().waitFor(
    +   *         DataSourceStatusProvider.State.VALID, Duration.ofSeconds(10));
    +   *     if (!inited) {
    +   *         // do whatever is appropriate if initialization has timed out
    +   *     }
    +   * 
    * * @param sdkKey the SDK key for your LaunchDarkly environment * @param config a client configuration object From bc090575c069c1d1c2579da59d3ca82566502fa3 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 19 May 2020 12:37:25 -0700 Subject: [PATCH 443/673] 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 2106219dc3dc48e50d3269f657c6568265afa2a5 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 19 May 2020 13:36:30 -0700 Subject: [PATCH 444/673] move flag change stuff into FlagTracker facade, simplify value listener --- .../launchdarkly/sdk/server/Components.java | 28 ----- .../sdk/server/FlagTrackerImpl.java | 67 +++++++++++ .../server/FlagValueMonitoringListener.java | 42 ------- .../com/launchdarkly/sdk/server/LDClient.java | 19 ++-- .../sdk/server/LDClientInterface.java | 42 ++----- .../server/interfaces/FlagChangeEvent.java | 3 +- .../server/interfaces/FlagChangeListener.java | 27 +++-- .../sdk/server/interfaces/FlagTracker.java | 79 +++++++++++++ .../interfaces/FlagValueChangeEvent.java | 6 +- .../interfaces/FlagValueChangeListener.java | 11 +- .../sdk/server/interfaces/package-info.java | 2 +- .../sdk/server/DataSourceUpdatesImplTest.java | 64 ++++++----- .../sdk/server/FlagTrackerImplTest.java | 107 ++++++++++++++++++ .../sdk/server/LDClientListenersTest.java | 80 +++++++------ .../com/launchdarkly/sdk/server/TestUtil.java | 73 ++++-------- 15 files changed, 396 insertions(+), 254 deletions(-) create mode 100644 src/main/java/com/launchdarkly/sdk/server/FlagTrackerImpl.java delete mode 100644 src/main/java/com/launchdarkly/sdk/server/FlagValueMonitoringListener.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/interfaces/FlagTracker.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/FlagTrackerImplTest.java diff --git a/src/main/java/com/launchdarkly/sdk/server/Components.java b/src/main/java/com/launchdarkly/sdk/server/Components.java index 43ca745f0..b61203327 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Components.java +++ b/src/main/java/com/launchdarkly/sdk/server/Components.java @@ -1,6 +1,5 @@ package com.launchdarkly.sdk.server; -import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.server.DiagnosticEvent.ConfigProperty; import com.launchdarkly.sdk.server.integrations.EventProcessorBuilder; @@ -22,8 +21,6 @@ import com.launchdarkly.sdk.server.interfaces.EventProcessor; import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; import com.launchdarkly.sdk.server.interfaces.EventSender; -import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; -import com.launchdarkly.sdk.server.interfaces.FlagValueChangeListener; import com.launchdarkly.sdk.server.interfaces.HttpAuthentication; import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; import com.launchdarkly.sdk.server.interfaces.LoggingConfiguration; @@ -291,31 +288,6 @@ public static LoggingConfigurationBuilder logging() { return new LoggingConfigurationBuilderImpl(); } - /** - * Convenience method for creating a {@link FlagChangeListener} that tracks a flag's value for a specific user. - *

    - * This listener instance should only be used with a single {@link LDClient} instance. When you first - * register it by calling {@link LDClientInterface#registerFlagChangeListener(FlagChangeListener)}, it - * immediately evaluates the flag. It then re-evaluates the flag whenever there is an update, and calls - * your {@link FlagValueChangeListener} if and only if the resulting value has changed. - *

    - * See {@link FlagValueChangeListener} for more information and examples. - * - * @param client the same client instance that you will be registering this listener with - * @param flagKey the flag key to be evaluated - * @param user the user properties for evaluation - * @param valueChangeListener an object that you provide which will be notified of changes - * @return a {@link FlagChangeListener} to be passed to {@link LDClientInterface#registerFlagChangeListener(FlagChangeListener)} - * - * @since 5.0.0 - * @see FlagValueChangeListener - * @see FlagChangeListener - */ - public static FlagChangeListener flagValueMonitoringListener(LDClientInterface client, String flagKey, LDUser user, - FlagValueChangeListener valueChangeListener) { - return new FlagValueMonitoringListener(client, flagKey, user, valueChangeListener); - } - private static final class InMemoryDataStoreFactory implements DataStoreFactory, DiagnosticDescription { static final DataStoreFactory INSTANCE = new InMemoryDataStoreFactory(); @Override diff --git a/src/main/java/com/launchdarkly/sdk/server/FlagTrackerImpl.java b/src/main/java/com/launchdarkly/sdk/server/FlagTrackerImpl.java new file mode 100644 index 000000000..a8f5e1467 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/FlagTrackerImpl.java @@ -0,0 +1,67 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; +import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; +import com.launchdarkly.sdk.server.interfaces.FlagTracker; +import com.launchdarkly.sdk.server.interfaces.FlagValueChangeEvent; +import com.launchdarkly.sdk.server.interfaces.FlagValueChangeListener; + +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiFunction; + +final class FlagTrackerImpl implements FlagTracker { + private final EventBroadcasterImpl flagChangeBroadcaster; + private final BiFunction evaluateFn; + + FlagTrackerImpl( + EventBroadcasterImpl flagChangeBroadcaster, + BiFunction evaluateFn + ) { + this.flagChangeBroadcaster = flagChangeBroadcaster; + this.evaluateFn = evaluateFn; + } + + @Override + public void addFlagChangeListener(FlagChangeListener listener) { + flagChangeBroadcaster.register(listener); + } + + @Override + public void removeFlagChangeListener(FlagChangeListener listener) { + flagChangeBroadcaster.unregister(listener); + } + + @Override + public FlagChangeListener addFlagValueChangeListener(String flagKey, LDUser user, FlagValueChangeListener listener) { + FlagValueChangeAdapter adapter = new FlagValueChangeAdapter(flagKey, user, listener); + addFlagChangeListener(adapter); + return adapter; + } + + private final class FlagValueChangeAdapter implements FlagChangeListener { + private final String flagKey; + private final LDUser user; + private final FlagValueChangeListener listener; + private final AtomicReference value; + + FlagValueChangeAdapter(String flagKey, LDUser user, FlagValueChangeListener listener) { + this.flagKey = flagKey; + this.user = user; + this.listener = listener; + this.value = new AtomicReference<>(evaluateFn.apply(flagKey, user)); + } + + @Override + public void onFlagChange(FlagChangeEvent event) { + if (event.getKey().equals(flagKey)) { + LDValue newValue = evaluateFn.apply(flagKey, user); + LDValue oldValue = value.getAndSet(newValue); + if (!newValue.equals(oldValue)) { + listener.onFlagValueChange(new FlagValueChangeEvent(flagKey, oldValue, newValue)); + } + } + } + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/FlagValueMonitoringListener.java b/src/main/java/com/launchdarkly/sdk/server/FlagValueMonitoringListener.java deleted file mode 100644 index 0d756bdae..000000000 --- a/src/main/java/com/launchdarkly/sdk/server/FlagValueMonitoringListener.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.launchdarkly.sdk.server; - -import com.launchdarkly.sdk.LDUser; -import com.launchdarkly.sdk.LDValue; -import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; -import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; -import com.launchdarkly.sdk.server.interfaces.FlagValueChangeEvent; -import com.launchdarkly.sdk.server.interfaces.FlagValueChangeListener; - -import java.util.concurrent.atomic.AtomicReference; - -/** - * Implementation of the flag change listener wrapper provided by - * {@link Components#flagValueMonitoringListener(LDClientInterface, String, com.launchdarkly.sdk.LDUser, com.launchdarkly.sdk.server.interfaces.FlagValueChangeListener)}. - * This class is deliberately not public, it is an implementation detail. - */ -final class FlagValueMonitoringListener implements FlagChangeListener { - private final LDClientInterface client; - private final AtomicReference currentValue = new AtomicReference<>(LDValue.ofNull()); - private final String flagKey; - private final LDUser user; - private final FlagValueChangeListener valueChangeListener; - - public FlagValueMonitoringListener(LDClientInterface client, String flagKey, LDUser user, FlagValueChangeListener valueChangeListener) { - this.client = client; - this.flagKey = flagKey; - this.user = user; - this.valueChangeListener = valueChangeListener; - currentValue.set(client.jsonValueVariation(flagKey, user, LDValue.ofNull())); - } - - @Override - public void onFlagChange(FlagChangeEvent event) { - if (event.getKey().equals(flagKey)) { - LDValue newValue = client.jsonValueVariation(flagKey, user, LDValue.ofNull()); - LDValue previousValue = currentValue.getAndSet(newValue); - if (!newValue.equals(previousValue)) { - valueChangeListener.onFlagValueChange(new FlagValueChangeEvent(flagKey, previousValue, newValue)); - } - } - } -} diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClient.java b/src/main/java/com/launchdarkly/sdk/server/LDClient.java index f5c170627..bd8c4cd68 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClient.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClient.java @@ -20,6 +20,7 @@ import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; +import com.launchdarkly.sdk.server.interfaces.FlagTracker; import org.apache.commons.codec.binary.Hex; import org.slf4j.Logger; @@ -68,7 +69,8 @@ public final class LDClient implements LDClientInterface { private final DataSourceUpdates dataSourceUpdates; private final DataStoreStatusProviderImpl dataStoreStatusProvider; private final DataSourceStatusProviderImpl dataSourceStatusProvider; - private final EventBroadcasterImpl flagChangeEventNotifier; + private final FlagTrackerImpl flagTracker; + private final EventBroadcasterImpl flagChangeBroadcaster; private final ScheduledExecutorService sharedExecutor; /** @@ -176,7 +178,9 @@ public DataModel.Segment getSegment(String key) { } }); - this.flagChangeEventNotifier = EventBroadcasterImpl.forFlagChangeEvents(sharedExecutor); + this.flagChangeBroadcaster = EventBroadcasterImpl.forFlagChangeEvents(sharedExecutor); + this.flagTracker = new FlagTrackerImpl(flagChangeBroadcaster, + (key, user) -> jsonValueVariation(key, user, LDValue.ofNull())); this.dataStoreStatusProvider = new DataStoreStatusProviderImpl(this.dataStore, dataStoreUpdates); @@ -187,7 +191,7 @@ public DataModel.Segment getSegment(String key) { DataSourceUpdatesImpl dataSourceUpdates = new DataSourceUpdatesImpl( dataStore, dataStoreStatusProvider, - flagChangeEventNotifier, + flagChangeBroadcaster, dataSourceStatusNotifier, sharedExecutor, config.loggingConfig.getLogDataSourceOutageAsErrorAfter() @@ -457,13 +461,8 @@ private Evaluator.EvalResult evaluateInternal(String featureKey, LDUser user, LD } @Override - public void registerFlagChangeListener(FlagChangeListener listener) { - flagChangeEventNotifier.register(listener); - } - - @Override - public void unregisterFlagChangeListener(FlagChangeListener listener) { - flagChangeEventNotifier.unregister(listener); + public FlagTracker getFlagTracker() { + return flagTracker; } @Override diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClientInterface.java b/src/main/java/com/launchdarkly/sdk/server/LDClientInterface.java index 440ef3571..577e0d48e 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClientInterface.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClientInterface.java @@ -5,7 +5,7 @@ import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; -import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; +import com.launchdarkly.sdk.server.interfaces.FlagTracker; import java.io.Closeable; import java.io.IOException; @@ -227,44 +227,16 @@ public interface LDClientInterface extends Closeable { boolean isOffline(); /** - * Registers a listener to be notified of feature flag changes. + * Returns an interface for tracking changes in feature flag configurations. *

    - * The listener will be notified whenever the SDK receives any change to any feature flag's configuration, - * or to a user segment that is referenced by a feature flag. If the updated flag is used as a prerequisite - * for other flags, the SDK assumes that those flags may now behave differently and sends events for them - * as well. - *

    - * Note that this does not necessarily mean the flag's value has changed for any particular user, only that - * some part of the flag configuration was changed so that it may return a different value than it - * previously returned for some user. - *

    - * Change events only work if the SDK is actually connecting to LaunchDarkly (or using the file data source). - * If the SDK is only reading flags from a database ({@link Components#externalUpdatesOnly()}) then it cannot - * know when there is a change, because flags are read on an as-needed basis. - *

    - * The listener will be called from a worker thread. - *

    - * Calling this method for an already-registered listener has no effect. - * - * @param listener the event listener to register - * @see #unregisterFlagChangeListener(FlagChangeListener) - * @see FlagChangeListener + * The {@link FlagTracker} contains methods for requesting notifications about feature flag changes using + * an event listener model. + * + * @return a {@link FlagTracker} * @since 5.0.0 */ - void registerFlagChangeListener(FlagChangeListener listener); + FlagTracker getFlagTracker(); - /** - * Unregisters a listener so that it will no longer be notified of feature flag changes. - *

    - * Calling this method for a listener that was not previously registered has no effect. - * - * @param listener the event listener to unregister - * @see #registerFlagChangeListener(FlagChangeListener) - * @see FlagChangeListener - * @since 5.0.0 - */ - void unregisterFlagChangeListener(FlagChangeListener listener); - /** * Returns an interface for tracking the status of the data source. *

    diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagChangeEvent.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagChangeEvent.java index b72a49c4f..2fa317489 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagChangeEvent.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagChangeEvent.java @@ -8,8 +8,7 @@ * @since 5.0.0 * @see FlagChangeListener * @see FlagValueChangeEvent - * @see com.launchdarkly.sdk.server.LDClientInterface#registerFlagChangeListener(FlagChangeListener) - * @see com.launchdarkly.sdk.server.LDClientInterface#unregisterFlagChangeListener(FlagChangeListener) + * @see FlagTracker#addFlagChangeListener(FlagChangeListener) */ public class FlagChangeEvent { private final String key; diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagChangeListener.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagChangeListener.java index 42f8093cd..81249939f 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagChangeListener.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagChangeListener.java @@ -3,21 +3,28 @@ /** * An event listener that is notified when a feature flag's configuration has changed. *

    - * As described in {@link com.launchdarkly.sdk.server.LDClientInterface#registerFlagChangeListener(FlagChangeListener)}, - * this notification does not mean that the flag now returns a different value for any particular user, - * only that it may do so. LaunchDarkly feature flags can be configured to return a single value - * for all users, or to have complex targeting behavior. To know what effect the change would have for - * any given set of user properties, you would need to re-evaluate the flag by calling one of the + * As described in {@link FlagTracker#addFlagChangeListener(FlagChangeListener)}, this notification + * does not mean that the flag now returns a different value for any particular user, only that it + * may do so. LaunchDarkly feature flags can be configured to return a single value for all + * users, or to have complex targeting behavior. To know what effect the change would have for any + * given set of user properties, you would need to re-evaluate the flag by calling one of the * {@code variation} methods on the client. - *

    + * + *

    
    + *     FlagChangeListener listenForChanges = event -> {
    + *         System.out.println("a flag has changed: " + event.getKey());
    + *     };
    + *     client.getFlagTracker().addFlagChangeListener(listenForChanges);
    + * 
    + * * In simple use cases where you know that the flag configuration does not vary per user, or where you * know ahead of time what user properties you will evaluate the flag with, it may be more convenient - * to use {@link FlagValueChangeListener}. - * + * to use {@link FlagValueChangeListener}. + * * @since 5.0.0 * @see FlagValueChangeListener - * @see com.launchdarkly.sdk.server.LDClientInterface#registerFlagChangeListener(FlagChangeListener) - * @see com.launchdarkly.sdk.server.LDClientInterface#unregisterFlagChangeListener(FlagChangeListener) + * @see FlagTracker#addFlagChangeListener(FlagChangeListener) + * @see FlagTracker#removeFlagChangeListener(FlagChangeListener) */ public interface FlagChangeListener { /** diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagTracker.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagTracker.java new file mode 100644 index 000000000..f60442101 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagTracker.java @@ -0,0 +1,79 @@ +package com.launchdarkly.sdk.server.interfaces; + +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.server.Components; + +/** + * An interface for tracking changes in feature flag configurations. + *

    + * An implementation of this interface is returned by {@link com.launchdarkly.sdk.server.LDClientInterface#getFlagTracker()}. + * Application code never needs to implement this interface. + * + * @since 5.0.0 + */ +public interface FlagTracker { + /** + * Registers a listener to be notified of feature flag changes in general. + *

    + * The listener will be notified whenever the SDK receives any change to any feature flag's configuration, + * or to a user segment that is referenced by a feature flag. If the updated flag is used as a prerequisite + * for other flags, the SDK assumes that those flags may now behave differently and sends events for them + * as well. + *

    + * Note that this does not necessarily mean the flag's value has changed for any particular user, only that + * some part of the flag configuration was changed so that it may return a different value than it + * previously returned for some user. If you want to track flag value changes, use + * {@link #addFlagValueChangeListener(String, LDUser, FlagValueChangeListener)} instead. + *

    + * Change events only work if the SDK is actually connecting to LaunchDarkly (or using the file data source). + * If the SDK is only reading flags from a database ({@link Components#externalUpdatesOnly()}) then it cannot + * know when there is a change, because flags are read on an as-needed basis. + *

    + * The listener will be called from a worker thread. + *

    + * Calling this method for an already-registered listener has no effect. + * + * @param listener the event listener to register + * @see #removeFlagChangeListener(FlagChangeListener) + * @see FlagChangeListener + * @see #addFlagValueChangeListener(String, LDUser, FlagValueChangeListener) + */ + public void addFlagChangeListener(FlagChangeListener listener); + + /** + * Unregisters a listener so that it will no longer be notified of feature flag changes. + *

    + * Calling this method for a listener that was not previously registered has no effect. + * + * @param listener the event listener to unregister + * @see #addFlagChangeListener(FlagChangeListener) + * @see #addFlagValueChangeListener(String, LDUser, FlagValueChangeListener) + * @see FlagChangeListener + */ + public void removeFlagChangeListener(FlagChangeListener listener); + + /** + * Registers a listener to be notified of a change in a specific feature flag's value for a specific set of + * user properties. + *

    + * When you call this method, it first immediately evaluates the feature flag. It then uses + * {@link #addFlagChangeListener(FlagChangeListener)} to start listening for feature flag configuration + * changes, and whenever the specified feature flag changes, it re-evaluates the flag for the same user. + * It then calls your {@link FlagValueChangeListener} if and only if the resulting value has changed. + *

    + * All feature flag evaluations require an instance of {@link LDUser}. If the feature flag you are + * tracking does not have any user targeting rules, you must still pass a dummy user such as + * {@code new LDUser("for-global-flags")}. If you do not want the user to appear on your dashboard, use + * the {@code anonymous} property: {@code new LDUserBuilder("for-global-flags").anonymous(true).build()}. + *

    + * The returned {@link FlagChangeListener} represents the subscription that was created by this method + * call; to unsubscribe, pass that object (not your {@code FlagValueChangeListener}) to + * {@link #removeFlagChangeListener(FlagChangeListener)}. + * + * @param flagKey the flag key to be evaluated + * @param user the user properties for evaluation + * @param listener an object that you provide which will be notified of changes + * @return a {@link FlagChangeListener} that can be used to unregister the listener + */ + public FlagChangeListener addFlagValueChangeListener(String flagKey, LDUser user, FlagValueChangeListener listener); +} diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeEvent.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeEvent.java index 81767de46..1f4e4dda7 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeEvent.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeEvent.java @@ -9,9 +9,7 @@ * * @since 5.0.0 * @see FlagValueChangeListener - * @see com.launchdarkly.sdk.server.LDClientInterface#registerFlagChangeListener(FlagChangeListener) - * @see com.launchdarkly.sdk.server.LDClientInterface#unregisterFlagChangeListener(FlagChangeListener) - * @see com.launchdarkly.sdk.server.Components#flagValueMonitoringListener(com.launchdarkly.sdk.server.LDClientInterface, String, com.launchdarkly.sdk.LDUser, FlagValueChangeListener) + * @see FlagTracker#addFlagValueChangeListener(String, com.launchdarkly.sdk.LDUser, FlagValueChangeListener) */ public class FlagValueChangeEvent extends FlagChangeEvent { private final LDValue oldValue; @@ -34,7 +32,7 @@ public FlagValueChangeEvent(String key, LDValue oldValue, LDValue newValue) { * Returns the last known value of the flag for the specified user prior to the update. *

    * Since flag values can be of any JSON data type, this is represented as {@link LDValue}. That class - * has methods for converting to a primitive Java type such {@link LDValue#booleanValue()}. + * has methods for converting to a primitive Java type such as {@link LDValue#booleanValue()}. *

    * If the flag did not exist before or could not be evaluated, this will be {@link LDValue#ofNull()}. * Note that there is no application default value parameter as there is for the {@code variation} diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeListener.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeListener.java index d5390828c..854cb9455 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeListener.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeListener.java @@ -3,8 +3,7 @@ /** * An event listener that is notified when a feature flag's value has changed for a specific user. *

    - * Use this in conjunction with - * {@link com.launchdarkly.sdk.server.Components#flagValueMonitoringListener(com.launchdarkly.sdk.server.LDClientInterface, String, com.launchdarkly.sdk.LDUser, FlagValueChangeListener)} + * Use this in conjunction with {@link FlagTracker#addFlagValueChangeListener(String, com.launchdarkly.sdk.LDUser, FlagValueChangeListener)} * if you want the client to re-evaluate a flag for a specific set of user properties whenever * the flag's configuration has changed, and notify you only if the new value is different from the old * value. The listener will not be notified if the flag's configuration is changed in some way that does @@ -18,8 +17,8 @@ * doSomethingWithNewValue(event.getNewValue().booleanValue()); * } * }; - * client.registerFlagChangeListener(Components.flagValueMonitoringListener( - * client, flagKey, userForFlagEvaluation, listenForNewValue)); + * client.getFlagTracker().addFlagValueChangeListener(flagKey, + * userForEvaluation, listenForNewValue)); * * * In the above example, the value provided in {@code event.getNewValue()} is the result of calling @@ -28,9 +27,7 @@ * * @since 5.0.0 * @see FlagChangeListener - * @see com.launchdarkly.sdk.server.LDClientInterface#registerFlagChangeListener(FlagChangeListener) - * @see com.launchdarkly.sdk.server.LDClientInterface#unregisterFlagChangeListener(FlagChangeListener) - * @see com.launchdarkly.sdk.server.Components#flagValueMonitoringListener(com.launchdarkly.sdk.server.LDClientInterface, String, com.launchdarkly.sdk.LDUser, FlagValueChangeListener) + * @see FlagTracker#addFlagValueChangeListener(String, com.launchdarkly.sdk.LDUser, FlagValueChangeListener) */ public interface FlagValueChangeListener { /** diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/package-info.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/package-info.java index b47a42367..834b67c50 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/package-info.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/package-info.java @@ -5,6 +5,6 @@ * Most applications will not need to refer to these types. You will use them if you are creating a * plug-in component, such as a database integration, or if you use advanced features such as * {@link com.launchdarkly.sdk.server.LDClientInterface#getDataStoreStatusProvider()} or - * {@link com.launchdarkly.sdk.server.LDClientInterface#registerFlagChangeListener(FlagChangeListener)}. + * {@link com.launchdarkly.sdk.server.LDClientInterface#getFlagTracker()}. */ package com.launchdarkly.sdk.server.interfaces; diff --git a/src/test/java/com/launchdarkly/sdk/server/DataSourceUpdatesImplTest.java b/src/test/java/com/launchdarkly/sdk/server/DataSourceUpdatesImplTest.java index cc6602dad..3474e112d 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataSourceUpdatesImplTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataSourceUpdatesImplTest.java @@ -6,7 +6,6 @@ import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.server.DataModel.Operator; import com.launchdarkly.sdk.server.DataStoreTestTypes.DataBuilder; -import com.launchdarkly.sdk.server.TestUtil.FlagChangeEventSink; import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; @@ -21,6 +20,8 @@ import java.util.List; import java.util.Map; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; import static com.google.common.collect.Iterables.transform; import static com.launchdarkly.sdk.server.DataModel.FEATURES; @@ -32,6 +33,7 @@ import static com.launchdarkly.sdk.server.ModelBuilders.segmentBuilder; import static com.launchdarkly.sdk.server.TestComponents.inMemoryDataStore; import static com.launchdarkly.sdk.server.TestComponents.sharedExecutor; +import static com.launchdarkly.sdk.server.TestUtil.expectEvents; import static org.easymock.EasyMock.replay; import static org.junit.Assert.assertEquals; import static org.junit.Assert.fail; @@ -61,8 +63,8 @@ public void sendsEventsOnInitForNewlyAddedFlags() throws Exception { storeUpdates.init(builder.build()); - FlagChangeEventSink eventSink = new FlagChangeEventSink(); - flagChangeBroadcaster.register(eventSink); + BlockingQueue eventSink = new LinkedBlockingQueue<>(); + flagChangeBroadcaster.register(eventSink::add); builder.addAny(FEATURES, flagBuilder("flag2").version(1).build()) .addAny(SEGMENTS, segmentBuilder("segment2").version(1).build()); @@ -70,7 +72,7 @@ public void sendsEventsOnInitForNewlyAddedFlags() throws Exception { storeUpdates.init(builder.build()); - eventSink.expectEvents("flag2"); + expectEvents(eventSink, "flag2"); } @Test @@ -86,12 +88,12 @@ public void sendsEventOnUpdateForNewlyAddedFlag() throws Exception { storeUpdates.init(builder.build()); - FlagChangeEventSink eventSink = new FlagChangeEventSink(); - flagChangeBroadcaster.register(eventSink); + BlockingQueue eventSink = new LinkedBlockingQueue<>(); + flagChangeBroadcaster.register(eventSink::add); storeUpdates.upsert(FEATURES, "flag2", new ItemDescriptor(1, flagBuilder("flag2").version(1).build())); - eventSink.expectEvents("flag2"); + expectEvents(eventSink, "flag2"); } @Test @@ -109,14 +111,14 @@ public void sendsEventsOnInitForUpdatedFlags() throws Exception { storeUpdates.init(builder.build()); - FlagChangeEventSink eventSink = new FlagChangeEventSink(); - flagChangeBroadcaster.register(eventSink); + BlockingQueue eventSink = new LinkedBlockingQueue<>(); + flagChangeBroadcaster.register(eventSink::add); builder.addAny(FEATURES, flagBuilder("flag2").version(2).build()) // modified flag .addAny(SEGMENTS, segmentBuilder("segment2").version(2).build()); // modified segment, but it's irrelevant storeUpdates.init(builder.build()); - eventSink.expectEvents("flag2"); + expectEvents(eventSink, "flag2"); } @Test @@ -133,12 +135,12 @@ public void sendsEventOnUpdateForUpdatedFlag() throws Exception { storeUpdates.init(builder.build()); - FlagChangeEventSink eventSink = new FlagChangeEventSink(); - flagChangeBroadcaster.register(eventSink); + BlockingQueue eventSink = new LinkedBlockingQueue<>(); + flagChangeBroadcaster.register(eventSink::add); storeUpdates.upsert(FEATURES, "flag2", new ItemDescriptor(2, flagBuilder("flag2").version(2).build())); - eventSink.expectEvents("flag2"); + expectEvents(eventSink, "flag2"); } @Test @@ -155,8 +157,8 @@ public void sendsEventsOnInitForDeletedFlags() throws Exception { storeUpdates.init(builder.build()); - FlagChangeEventSink eventSink = new FlagChangeEventSink(); - flagChangeBroadcaster.register(eventSink); + BlockingQueue eventSink = new LinkedBlockingQueue<>(); + flagChangeBroadcaster.register(eventSink::add); builder.remove(FEATURES, "flag2"); builder.remove(SEGMENTS, "segment1"); // deleted segment isn't being used so it's irrelevant @@ -164,7 +166,7 @@ public void sendsEventsOnInitForDeletedFlags() throws Exception { storeUpdates.init(builder.build()); - eventSink.expectEvents("flag2"); + expectEvents(eventSink, "flag2"); } @Test @@ -181,12 +183,12 @@ public void sendsEventOnUpdateForDeletedFlag() throws Exception { storeUpdates.init(builder.build()); - FlagChangeEventSink eventSink = new FlagChangeEventSink(); - flagChangeBroadcaster.register(eventSink); + BlockingQueue events = new LinkedBlockingQueue<>(); + flagChangeBroadcaster.register(events::add); storeUpdates.upsert(FEATURES, "flag2", ItemDescriptor.deletedItem(2)); - eventSink.expectEvents("flag2"); + expectEvents(events, "flag2"); } @Test @@ -205,13 +207,13 @@ public void sendsEventsOnInitForFlagsWhosePrerequisitesChanged() throws Exceptio storeUpdates.init(builder.build()); - FlagChangeEventSink eventSink = new FlagChangeEventSink(); - flagChangeBroadcaster.register(eventSink); + BlockingQueue eventSink = new LinkedBlockingQueue<>(); + flagChangeBroadcaster.register(eventSink::add); builder.addAny(FEATURES, flagBuilder("flag1").version(2).build()); storeUpdates.init(builder.build()); - eventSink.expectEvents("flag1", "flag2", "flag4", "flag5"); + expectEvents(eventSink, "flag1", "flag2", "flag4", "flag5"); } @Test @@ -230,12 +232,12 @@ public void sendsEventsOnUpdateForFlagsWhosePrerequisitesChanged() throws Except storeUpdates.init(builder.build()); - FlagChangeEventSink eventSink = new FlagChangeEventSink(); - flagChangeBroadcaster.register(eventSink); + BlockingQueue eventSink = new LinkedBlockingQueue<>(); + flagChangeBroadcaster.register(eventSink::add); storeUpdates.upsert(FEATURES, "flag1", new ItemDescriptor(2, flagBuilder("flag1").version(2).build())); - eventSink.expectEvents("flag1", "flag2", "flag4", "flag5"); + expectEvents(eventSink, "flag1", "flag2", "flag4", "flag5"); } @Test @@ -259,12 +261,12 @@ public void sendsEventsOnInitForFlagsWhoseSegmentsChanged() throws Exception { storeUpdates.init(builder.build()); - FlagChangeEventSink eventSink = new FlagChangeEventSink(); - flagChangeBroadcaster.register(eventSink); + BlockingQueue eventSink = new LinkedBlockingQueue<>(); + flagChangeBroadcaster.register(eventSink::add); storeUpdates.upsert(SEGMENTS, "segment1", new ItemDescriptor(2, segmentBuilder("segment1").version(2).build())); - eventSink.expectEvents("flag2", "flag4"); + expectEvents(eventSink, "flag2", "flag4"); } @Test @@ -288,13 +290,13 @@ public void sendsEventsOnUpdateForFlagsWhoseSegmentsChanged() throws Exception { storeUpdates.init(builder.build()); - FlagChangeEventSink eventSink = new FlagChangeEventSink(); - flagChangeBroadcaster.register(eventSink); + BlockingQueue eventSink = new LinkedBlockingQueue<>(); + flagChangeBroadcaster.register(eventSink::add); builder.addAny(SEGMENTS, segmentBuilder("segment1").version(2).build()); storeUpdates.init(builder.build()); - eventSink.expectEvents("flag2", "flag4"); + expectEvents(eventSink, "flag2", "flag4"); } @Test diff --git a/src/test/java/com/launchdarkly/sdk/server/FlagTrackerImplTest.java b/src/test/java/com/launchdarkly/sdk/server/FlagTrackerImplTest.java new file mode 100644 index 000000000..dc77232bd --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/FlagTrackerImplTest.java @@ -0,0 +1,107 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; +import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; +import com.launchdarkly.sdk.server.interfaces.FlagValueChangeEvent; + +import org.junit.Test; + +import java.time.Duration; +import java.util.AbstractMap; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; + +import static com.launchdarkly.sdk.server.TestUtil.awaitValue; +import static com.launchdarkly.sdk.server.TestUtil.expectNoMoreValues; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +@SuppressWarnings("javadoc") +public class FlagTrackerImplTest { + + @Test + public void flagChangeListeners() throws Exception { + String flagKey = "flagkey"; + EventBroadcasterImpl broadcaster = + EventBroadcasterImpl.forFlagChangeEvents(TestComponents.sharedExecutor); + + FlagTrackerImpl tracker = new FlagTrackerImpl(broadcaster, null); + + BlockingQueue eventSink1 = new LinkedBlockingQueue<>(); + BlockingQueue eventSink2 = new LinkedBlockingQueue<>(); + FlagChangeListener listener1 = eventSink1::add; + FlagChangeListener listener2 = eventSink2::add; // need to capture the method reference in a variable so it's the same instance when we unregister it + tracker.addFlagChangeListener(listener1); + tracker.addFlagChangeListener(listener2); + + expectNoMoreValues(eventSink1, Duration.ofMillis(100)); + expectNoMoreValues(eventSink2, Duration.ofMillis(100)); + + broadcaster.broadcast(new FlagChangeEvent(flagKey)); + + FlagChangeEvent event1 = awaitValue(eventSink1, Duration.ofSeconds(1)); + FlagChangeEvent event2 = awaitValue(eventSink2, Duration.ofSeconds(1)); + assertThat(event1.getKey(), equalTo("flagkey")); + assertThat(event2.getKey(), equalTo("flagkey")); + expectNoMoreValues(eventSink1, Duration.ofMillis(100)); + expectNoMoreValues(eventSink2, Duration.ofMillis(100)); + + tracker.removeFlagChangeListener(listener1); + + broadcaster.broadcast(new FlagChangeEvent(flagKey)); + + FlagChangeEvent event3 = awaitValue(eventSink2, Duration.ofSeconds(1)); + assertThat(event3.getKey(), equalTo(flagKey)); + expectNoMoreValues(eventSink1, Duration.ofMillis(100)); + expectNoMoreValues(eventSink2, Duration.ofMillis(100)); + } + + @Test + public void flagValueChangeListener() throws Exception { + String flagKey = "important-flag"; + LDUser user = new LDUser("important-user"); + LDUser otherUser = new LDUser("unimportant-user"); + EventBroadcasterImpl broadcaster = + EventBroadcasterImpl.forFlagChangeEvents(TestComponents.sharedExecutor); + Map, LDValue> resultMap = new HashMap<>(); + + FlagTrackerImpl tracker = new FlagTrackerImpl(broadcaster, + (k, u) -> LDValue.normalize(resultMap.get(new AbstractMap.SimpleEntry<>(k, u)))); + + resultMap.put(new AbstractMap.SimpleEntry<>(flagKey, user), LDValue.of(false)); + resultMap.put(new AbstractMap.SimpleEntry<>(flagKey, otherUser), LDValue.of(false)); + + BlockingQueue eventSink1 = new LinkedBlockingQueue<>(); + BlockingQueue eventSink2 = new LinkedBlockingQueue<>(); + BlockingQueue eventSink3 = new LinkedBlockingQueue<>(); + tracker.addFlagValueChangeListener(flagKey, user, eventSink1::add); + FlagChangeListener listener2 = tracker.addFlagValueChangeListener(flagKey, user, eventSink2::add); + tracker.removeFlagChangeListener(listener2); // just verifying that the remove method works + tracker.addFlagValueChangeListener(flagKey, otherUser, eventSink3::add); + + expectNoMoreValues(eventSink1, Duration.ofMillis(100)); + expectNoMoreValues(eventSink2, Duration.ofMillis(100)); + expectNoMoreValues(eventSink3, Duration.ofMillis(100)); + + // make the flag true for the first user only, and broadcast a flag change event + resultMap.put(new AbstractMap.SimpleEntry<>(flagKey, user), LDValue.of(true)); + broadcaster.broadcast(new FlagChangeEvent(flagKey)); + + // eventSink1 receives a value change event + FlagValueChangeEvent event1 = awaitValue(eventSink1, Duration.ofSeconds(1)); + assertThat(event1.getKey(), equalTo(flagKey)); + assertThat(event1.getOldValue(), equalTo(LDValue.of(false))); + assertThat(event1.getNewValue(), equalTo(LDValue.of(true))); + expectNoMoreValues(eventSink1, Duration.ofMillis(100)); + + // eventSink2 doesn't receive one, because it was unregistered + expectNoMoreValues(eventSink2, Duration.ofMillis(100)); + + // eventSink3 doesn't receive one, because the flag's value hasn't changed for otherUser + expectNoMoreValues(eventSink2, Duration.ofMillis(100)); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java index ae5368ed7..e2be0f7ea 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java @@ -6,19 +6,19 @@ import com.launchdarkly.sdk.server.DataStoreTestTypes.DataBuilder; import com.launchdarkly.sdk.server.TestComponents.DataSourceFactoryThatExposesUpdater; import com.launchdarkly.sdk.server.TestComponents.DataStoreFactoryThatExposesUpdater; -import com.launchdarkly.sdk.server.TestUtil.FlagChangeEventSink; -import com.launchdarkly.sdk.server.TestUtil.FlagValueChangeEventSink; import com.launchdarkly.sdk.server.integrations.MockPersistentDataStore; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; +import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; import com.launchdarkly.sdk.server.interfaces.FlagValueChangeEvent; import org.easymock.EasyMockSupport; import org.junit.Test; +import java.time.Duration; import java.time.Instant; import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; @@ -27,6 +27,8 @@ import static com.launchdarkly.sdk.server.TestComponents.initedDataStore; import static com.launchdarkly.sdk.server.TestComponents.specificDataStore; import static com.launchdarkly.sdk.server.TestComponents.specificPersistentDataStore; +import static com.launchdarkly.sdk.server.TestUtil.awaitValue; +import static com.launchdarkly.sdk.server.TestUtil.expectNoMoreValues; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; @@ -50,9 +52,10 @@ public class LDClientListenersTest extends EasyMockSupport { @Test public void clientSendsFlagChangeEvents() throws Exception { + String flagKey = "flagkey"; DataStore testDataStore = initedDataStore(); DataBuilder initialData = new DataBuilder().addAny(DataModel.FEATURES, - flagBuilder("flagkey").version(1).build()); + flagBuilder(flagKey).version(1).build()); DataSourceFactoryThatExposesUpdater updatableSource = new DataSourceFactoryThatExposesUpdater(initialData.build()); LDConfig config = new LDConfig.Builder() .dataStore(specificDataStore(testDataStore)) @@ -61,31 +64,33 @@ public void clientSendsFlagChangeEvents() throws Exception { .build(); try (LDClient client = new LDClient(SDK_KEY, config)) { - FlagChangeEventSink eventSink1 = new FlagChangeEventSink(); - FlagChangeEventSink eventSink2 = new FlagChangeEventSink(); - client.registerFlagChangeListener(eventSink1); - client.registerFlagChangeListener(eventSink2); + BlockingQueue eventSink1 = new LinkedBlockingQueue<>(); + BlockingQueue eventSink2 = new LinkedBlockingQueue<>(); + FlagChangeListener listener1 = eventSink1::add; + FlagChangeListener listener2 = eventSink2::add; // need to capture the method reference in a variable so it's the same instance when we unregister it + client.getFlagTracker().addFlagChangeListener(listener1); + client.getFlagTracker().addFlagChangeListener(listener2); - eventSink1.expectNoEvents(); - eventSink2.expectNoEvents(); + expectNoMoreValues(eventSink1, Duration.ofMillis(100)); + expectNoMoreValues(eventSink2, Duration.ofMillis(100)); - updatableSource.updateFlag(flagBuilder("flagkey").version(2).build()); + updatableSource.updateFlag(flagBuilder(flagKey).version(2).build()); - FlagChangeEvent event1 = eventSink1.awaitEvent(); - FlagChangeEvent event2 = eventSink2.awaitEvent(); - assertThat(event1.getKey(), equalTo("flagkey")); - assertThat(event2.getKey(), equalTo("flagkey")); - eventSink1.expectNoEvents(); - eventSink2.expectNoEvents(); + FlagChangeEvent event1 = awaitValue(eventSink1, Duration.ofSeconds(1)); + FlagChangeEvent event2 = awaitValue(eventSink2, Duration.ofSeconds(1)); + assertThat(event1.getKey(), equalTo(flagKey)); + assertThat(event2.getKey(), equalTo(flagKey)); + expectNoMoreValues(eventSink1, Duration.ofMillis(100)); + expectNoMoreValues(eventSink2, Duration.ofMillis(100)); - client.unregisterFlagChangeListener(eventSink1); + client.getFlagTracker().removeFlagChangeListener(listener1); - updatableSource.updateFlag(flagBuilder("flagkey").version(3).build()); + updatableSource.updateFlag(flagBuilder(flagKey).version(3).build()); - FlagChangeEvent event3 = eventSink2.awaitEvent(); - assertThat(event3.getKey(), equalTo("flagkey")); - eventSink1.expectNoEvents(); - eventSink2.expectNoEvents(); + FlagChangeEvent event3 = awaitValue(eventSink2, Duration.ofSeconds(1)); + assertThat(event3.getKey(), equalTo(flagKey)); + expectNoMoreValues(eventSink1, Duration.ofMillis(100)); + expectNoMoreValues(eventSink2, Duration.ofMillis(100)); } } @@ -108,26 +113,35 @@ public void clientSendsFlagValueChangeEvents() throws Exception { .build(); try (LDClient client = new LDClient(SDK_KEY, config)) { - FlagValueChangeEventSink eventSink1 = new FlagValueChangeEventSink(); - FlagValueChangeEventSink eventSink2 = new FlagValueChangeEventSink(); - client.registerFlagChangeListener(Components.flagValueMonitoringListener(client, flagKey, user, eventSink1)); - client.registerFlagChangeListener(Components.flagValueMonitoringListener(client, flagKey, otherUser, eventSink2)); + BlockingQueue eventSink1 = new LinkedBlockingQueue<>(); + BlockingQueue eventSink2 = new LinkedBlockingQueue<>(); + BlockingQueue eventSink3 = new LinkedBlockingQueue<>(); + client.getFlagTracker().addFlagValueChangeListener(flagKey, user, eventSink1::add); + FlagChangeListener listener2 = client.getFlagTracker().addFlagValueChangeListener(flagKey, user, eventSink2::add); + client.getFlagTracker().removeFlagChangeListener(listener2); // just verifying that the remove method works + client.getFlagTracker().addFlagValueChangeListener(flagKey, otherUser, eventSink3::add); - eventSink1.expectNoEvents(); - eventSink2.expectNoEvents(); + expectNoMoreValues(eventSink1, Duration.ofMillis(100)); + expectNoMoreValues(eventSink2, Duration.ofMillis(100)); + expectNoMoreValues(eventSink3, Duration.ofMillis(100)); + // make the flag true for the first user only, and broadcast a flag change event FeatureFlag flagIsTrueForMyUserOnly = flagBuilder(flagKey).version(2).on(true).variations(false, true) .targets(ModelBuilders.target(1, user.getKey())).fallthroughVariation(0).build(); updatableSource.updateFlag(flagIsTrueForMyUserOnly); - // eventSink1 receives a value change event; eventSink2 doesn't because the flag's value hasn't changed for otherUser - FlagValueChangeEvent event1 = eventSink1.awaitEvent(); + // eventSink1 receives a value change event + FlagValueChangeEvent event1 = awaitValue(eventSink1, Duration.ofSeconds(1)); assertThat(event1.getKey(), equalTo(flagKey)); assertThat(event1.getOldValue(), equalTo(LDValue.of(false))); assertThat(event1.getNewValue(), equalTo(LDValue.of(true))); - eventSink1.expectNoEvents(); + expectNoMoreValues(eventSink1, Duration.ofMillis(100)); + + // eventSink2 doesn't receive one, because it was unregistered + expectNoMoreValues(eventSink2, Duration.ofMillis(100)); - eventSink2.expectNoEvents(); + // eventSink3 doesn't receive one, because the flag's value hasn't changed for otherUser + expectNoMoreValues(eventSink2, Duration.ofMillis(100)); } } @@ -262,7 +276,7 @@ public void eventsAreDispatchedOnTaskThread() throws Exception { .build(); try (LDClient client = new LDClient(SDK_KEY, config)) { - client.registerFlagChangeListener(params -> { + client.getFlagTracker().addFlagChangeListener(params -> { capturedThreads.add(Thread.currentThread()); }); diff --git a/src/test/java/com/launchdarkly/sdk/server/TestUtil.java b/src/test/java/com/launchdarkly/sdk/server/TestUtil.java index 95789c978..c01a18712 100644 --- a/src/test/java/com/launchdarkly/sdk/server/TestUtil.java +++ b/src/test/java/com/launchdarkly/sdk/server/TestUtil.java @@ -11,9 +11,6 @@ import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; -import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; -import com.launchdarkly.sdk.server.interfaces.FlagValueChangeEvent; -import com.launchdarkly.sdk.server.interfaces.FlagValueChangeListener; import org.hamcrest.Description; import org.hamcrest.Matcher; @@ -23,7 +20,6 @@ import java.time.Instant; import java.util.HashSet; import java.util.Set; -import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; @@ -122,57 +118,32 @@ public static DataSourceStatusProvider.Status requireDataSourceStatusEventually( }); } - public static class FlagChangeEventSink extends FlagChangeEventSinkBase implements FlagChangeListener { - @Override - public void onFlagChange(FlagChangeEvent event) { - events.add(event); + public static T awaitValue(BlockingQueue values, Duration timeout) { + try { + T value = values.poll(timeout.toMillis(), TimeUnit.MILLISECONDS); + assertNotNull("did not receive expected value within " + timeout, value); + return value; + } catch (InterruptedException e) { + throw new RuntimeException(e); } } - - public static class FlagValueChangeEventSink extends FlagChangeEventSinkBase implements FlagValueChangeListener { - @Override - public void onFlagValueChange(FlagValueChangeEvent event) { - events.add(event); - } + + public static void expectNoMoreValues(BlockingQueue values, Duration timeout) { + try { + T value = values.poll(timeout.toMillis(), TimeUnit.MILLISECONDS); + assertNull("expected no more values", value); + } catch (InterruptedException e) {} } - - private static class FlagChangeEventSinkBase { - protected final BlockingQueue events = new ArrayBlockingQueue<>(100); - - public T awaitEvent() { - try { - T event = events.poll(1, TimeUnit.SECONDS); - assertNotNull("expected flag change event", event); - return event; - } catch (InterruptedException e) { - throw new RuntimeException(e); - } - } - - public void expectEvents(String... flagKeys) { - Set expectedChangedFlagKeys = ImmutableSet.copyOf(flagKeys); - Set actualChangedFlagKeys = new HashSet<>(); - for (int i = 0; i < expectedChangedFlagKeys.size(); i++) { - try { - T e = events.poll(1, TimeUnit.SECONDS); - if (e == null) { - fail("expected change events for " + expectedChangedFlagKeys + " but got " + actualChangedFlagKeys); - } - actualChangedFlagKeys.add(e.getKey()); - } catch (InterruptedException e) { - throw new RuntimeException(e); - } - } - assertThat(actualChangedFlagKeys, equalTo(expectedChangedFlagKeys)); - expectNoEvents(); - } - - public void expectNoEvents() { - try { - T event = events.poll(100, TimeUnit.MILLISECONDS); - assertNull("expected no more flag change events", event); - } catch (InterruptedException e) {} + + public static void expectEvents(BlockingQueue events, String... flagKeys) { + Set expectedChangedFlagKeys = ImmutableSet.copyOf(flagKeys); + Set actualChangedFlagKeys = new HashSet<>(); + for (int i = 0; i < expectedChangedFlagKeys.size(); i++) { + T e = awaitValue(events, Duration.ofSeconds(1)); + actualChangedFlagKeys.add(e.getKey()); } + assertThat(actualChangedFlagKeys, equalTo(expectedChangedFlagKeys)); + expectNoMoreValues(events, Duration.ofMillis(100)); } public static Evaluator.EvalResult simpleEvaluation(int variation, LDValue value) { From cbd309a853688c8d044a7682029b78bfa1baf170 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 19 May 2020 15:32:55 -0700 Subject: [PATCH 445/673] move LDClientInterface into interfaces package and rename initialized() to isInitialized() --- .../com/launchdarkly/sdk/server/Components.java | 1 + .../sdk/server/FeatureFlagsState.java | 1 + .../sdk/server/FlagValueMonitoringListener.java | 1 + .../sdk/server/FlagsStateOption.java | 1 + .../com/launchdarkly/sdk/server/LDClient.java | 13 +++++++------ .../interfaces/DataSourceStatusProvider.java | 2 +- .../interfaces/DataStoreStatusProvider.java | 2 +- .../sdk/server/interfaces/Event.java | 1 - .../sdk/server/interfaces/FlagChangeEvent.java | 4 ++-- .../server/interfaces/FlagChangeListener.java | 6 +++--- .../server/interfaces/FlagValueChangeEvent.java | 6 +++--- .../interfaces/FlagValueChangeListener.java | 8 ++++---- .../{ => interfaces}/LDClientInterface.java | 15 ++++++++++----- .../sdk/server/interfaces/package-info.java | 4 ++-- .../sdk/server/LDClientEndToEndTest.java | 16 ++++++++-------- .../sdk/server/LDClientEvaluationTest.java | 1 + .../sdk/server/LDClientEventTest.java | 1 + .../server/LDClientExternalUpdatesOnlyTest.java | 2 +- .../sdk/server/LDClientOfflineTest.java | 3 ++- .../launchdarkly/sdk/server/LDClientTest.java | 9 +++++---- 20 files changed, 55 insertions(+), 42 deletions(-) rename src/main/java/com/launchdarkly/sdk/server/{ => interfaces}/LDClientInterface.java (95%) diff --git a/src/main/java/com/launchdarkly/sdk/server/Components.java b/src/main/java/com/launchdarkly/sdk/server/Components.java index 43ca745f0..e5d131530 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Components.java +++ b/src/main/java/com/launchdarkly/sdk/server/Components.java @@ -26,6 +26,7 @@ import com.launchdarkly.sdk.server.interfaces.FlagValueChangeListener; import com.launchdarkly.sdk.server.interfaces.HttpAuthentication; import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; +import com.launchdarkly.sdk.server.interfaces.LDClientInterface; import com.launchdarkly.sdk.server.interfaces.LoggingConfiguration; import com.launchdarkly.sdk.server.interfaces.PersistentDataStore; import com.launchdarkly.sdk.server.interfaces.PersistentDataStoreFactory; diff --git a/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java b/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java index af9c4ccb5..2d0d17d4d 100644 --- a/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java +++ b/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java @@ -7,6 +7,7 @@ import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.json.JsonSerializable; +import com.launchdarkly.sdk.server.interfaces.LDClientInterface; import java.io.IOException; import java.util.Collections; diff --git a/src/main/java/com/launchdarkly/sdk/server/FlagValueMonitoringListener.java b/src/main/java/com/launchdarkly/sdk/server/FlagValueMonitoringListener.java index 0d756bdae..ae41e8ee3 100644 --- a/src/main/java/com/launchdarkly/sdk/server/FlagValueMonitoringListener.java +++ b/src/main/java/com/launchdarkly/sdk/server/FlagValueMonitoringListener.java @@ -6,6 +6,7 @@ import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; import com.launchdarkly.sdk.server.interfaces.FlagValueChangeEvent; import com.launchdarkly.sdk.server.interfaces.FlagValueChangeListener; +import com.launchdarkly.sdk.server.interfaces.LDClientInterface; import java.util.concurrent.atomic.AtomicReference; diff --git a/src/main/java/com/launchdarkly/sdk/server/FlagsStateOption.java b/src/main/java/com/launchdarkly/sdk/server/FlagsStateOption.java index 79359fefd..bbb9f1d51 100644 --- a/src/main/java/com/launchdarkly/sdk/server/FlagsStateOption.java +++ b/src/main/java/com/launchdarkly/sdk/server/FlagsStateOption.java @@ -1,6 +1,7 @@ package com.launchdarkly.sdk.server; import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.server.interfaces.LDClientInterface; /** * Optional parameters that can be passed to {@link LDClientInterface#allFlagsState(com.launchdarkly.sdk.LDUser, FlagsStateOption...)}. diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClient.java b/src/main/java/com/launchdarkly/sdk/server/LDClient.java index 3ec38a4bc..32c1160f4 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClient.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClient.java @@ -20,6 +20,7 @@ import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; +import com.launchdarkly.sdk.server.interfaces.LDClientInterface; import org.apache.commons.codec.binary.Hex; import org.slf4j.Logger; @@ -87,7 +88,7 @@ public final class LDClient implements LDClientInterface { * 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()}. If you prefer to customize + * initialization has succeeded by calling {@link #isInitialized()}. If you prefer to customize * this behavior, use {@link LDClient#LDClient(String, LDConfig)} instead. * * @param sdkKey the SDK key for your LaunchDarkly environment @@ -124,7 +125,7 @@ private static final DataModel.Segment getSegment(DataStore store, String key) { * 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()}. + * whether initialization has succeeded by calling {@link #isInitialized()}. *

    * If you prefer to have the constructor return immediately, and then wait for initialization to finish * at some other point, you can use {@link #getDataSourceStatusProvider()} as follows: @@ -233,7 +234,7 @@ public DataModel.Segment getSegment(String key) { } @Override - public boolean initialized() { + public boolean isInitialized() { return dataSource.isInitialized(); } @@ -281,7 +282,7 @@ public FeatureFlagsState allFlagsState(LDUser user, FlagsStateOption... options) logger.debug("allFlagsState() was called when client is in offline mode."); } - if (!initialized()) { + if (!isInitialized()) { if (dataStore.isInitialized()) { logger.warn("allFlagsState() was called before client initialized; using last known values from data store"); } else { @@ -382,7 +383,7 @@ public EvaluationDetail jsonValueVariationDetail(String featureKey, LDU @Override public boolean isFlagKnown(String featureKey) { - if (!initialized()) { + if (!isInitialized()) { if (dataStore.isInitialized()) { logger.warn("isFlagKnown called before client initialized for feature flag \"{}\"; using last known values from data store", featureKey); } else { @@ -413,7 +414,7 @@ private Evaluator.EvalResult errorResult(EvaluationReason.ErrorKind errorKind, f private Evaluator.EvalResult evaluateInternal(String featureKey, LDUser user, LDValue defaultValue, boolean checkType, EventFactory eventFactory) { - if (!initialized()) { + if (!isInitialized()) { if (dataStore.isInitialized()) { logger.warn("Evaluation called before client initialized for feature flag \"{}\"; using last known values from data store", featureKey); } else { diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProvider.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProvider.java index b6392ef8d..819efeb51 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProvider.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProvider.java @@ -11,7 +11,7 @@ * that receives updates to feature flag data; normally this is a streaming connection, but it could * be polling or file data depending on your configuration. *

    - * An implementation of this interface is returned by {@link com.launchdarkly.sdk.server.LDClientInterface#getDataSourceStatusProvider}. + * An implementation of this interface is returned by {@link com.launchdarkly.sdk.server.interfaces.LDClientInterface#getDataSourceStatusProvider}. * Application code never needs to implement this interface. * * @since 5.0.0 diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProvider.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProvider.java index 35d602e52..fcb6b3230 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProvider.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProvider.java @@ -7,7 +7,7 @@ /** * An interface for querying the status of a persistent data store. *

    - * An implementation of this interface is returned by {@link com.launchdarkly.sdk.server.LDClientInterface#getDataStoreStatusProvider}. + * An implementation of this interface is returned by {@link com.launchdarkly.sdk.server.interfaces.LDClientInterface#getDataStoreStatusProvider}. * Application code should not implement this interface. * * @since 5.0.0 diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/Event.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/Event.java index 0cba86ac8..cb558f305 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/Event.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/Event.java @@ -3,7 +3,6 @@ import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; -import com.launchdarkly.sdk.server.LDClientInterface; /** * Base class for all analytics events that are generated by the client. Also defines all of its own subclasses. diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagChangeEvent.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagChangeEvent.java index b72a49c4f..5e4b8351a 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagChangeEvent.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagChangeEvent.java @@ -8,8 +8,8 @@ * @since 5.0.0 * @see FlagChangeListener * @see FlagValueChangeEvent - * @see com.launchdarkly.sdk.server.LDClientInterface#registerFlagChangeListener(FlagChangeListener) - * @see com.launchdarkly.sdk.server.LDClientInterface#unregisterFlagChangeListener(FlagChangeListener) + * @see com.launchdarkly.sdk.server.interfaces.LDClientInterface#registerFlagChangeListener(FlagChangeListener) + * @see com.launchdarkly.sdk.server.interfaces.LDClientInterface#unregisterFlagChangeListener(FlagChangeListener) */ public class FlagChangeEvent { private final String key; diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagChangeListener.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagChangeListener.java index 42f8093cd..3315195c1 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagChangeListener.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagChangeListener.java @@ -3,7 +3,7 @@ /** * An event listener that is notified when a feature flag's configuration has changed. *

    - * As described in {@link com.launchdarkly.sdk.server.LDClientInterface#registerFlagChangeListener(FlagChangeListener)}, + * As described in {@link com.launchdarkly.sdk.server.interfaces.LDClientInterface#registerFlagChangeListener(FlagChangeListener)}, * this notification does not mean that the flag now returns a different value for any particular user, * only that it may do so. LaunchDarkly feature flags can be configured to return a single value * for all users, or to have complex targeting behavior. To know what effect the change would have for @@ -16,8 +16,8 @@ * * @since 5.0.0 * @see FlagValueChangeListener - * @see com.launchdarkly.sdk.server.LDClientInterface#registerFlagChangeListener(FlagChangeListener) - * @see com.launchdarkly.sdk.server.LDClientInterface#unregisterFlagChangeListener(FlagChangeListener) + * @see com.launchdarkly.sdk.server.interfaces.LDClientInterface#registerFlagChangeListener(FlagChangeListener) + * @see com.launchdarkly.sdk.server.interfaces.LDClientInterface#unregisterFlagChangeListener(FlagChangeListener) */ public interface FlagChangeListener { /** diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeEvent.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeEvent.java index 81767de46..56ce0fd67 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeEvent.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeEvent.java @@ -9,9 +9,9 @@ * * @since 5.0.0 * @see FlagValueChangeListener - * @see com.launchdarkly.sdk.server.LDClientInterface#registerFlagChangeListener(FlagChangeListener) - * @see com.launchdarkly.sdk.server.LDClientInterface#unregisterFlagChangeListener(FlagChangeListener) - * @see com.launchdarkly.sdk.server.Components#flagValueMonitoringListener(com.launchdarkly.sdk.server.LDClientInterface, String, com.launchdarkly.sdk.LDUser, FlagValueChangeListener) + * @see com.launchdarkly.sdk.server.interfaces.LDClientInterface#registerFlagChangeListener(FlagChangeListener) + * @see com.launchdarkly.sdk.server.interfaces.LDClientInterface#unregisterFlagChangeListener(FlagChangeListener) + * @see com.launchdarkly.sdk.server.Components#flagValueMonitoringListener(com.launchdarkly.sdk.server.interfaces.LDClientInterface, String, com.launchdarkly.sdk.LDUser, FlagValueChangeListener) */ public class FlagValueChangeEvent extends FlagChangeEvent { private final LDValue oldValue; diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeListener.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeListener.java index d5390828c..409ac8849 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeListener.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeListener.java @@ -4,7 +4,7 @@ * An event listener that is notified when a feature flag's value has changed for a specific user. *

    * Use this in conjunction with - * {@link com.launchdarkly.sdk.server.Components#flagValueMonitoringListener(com.launchdarkly.sdk.server.LDClientInterface, String, com.launchdarkly.sdk.LDUser, FlagValueChangeListener)} + * {@link com.launchdarkly.sdk.server.Components#flagValueMonitoringListener(com.launchdarkly.sdk.server.interfaces.LDClientInterface, String, com.launchdarkly.sdk.LDUser, FlagValueChangeListener)} * if you want the client to re-evaluate a flag for a specific set of user properties whenever * the flag's configuration has changed, and notify you only if the new value is different from the old * value. The listener will not be notified if the flag's configuration is changed in some way that does @@ -28,9 +28,9 @@ * * @since 5.0.0 * @see FlagChangeListener - * @see com.launchdarkly.sdk.server.LDClientInterface#registerFlagChangeListener(FlagChangeListener) - * @see com.launchdarkly.sdk.server.LDClientInterface#unregisterFlagChangeListener(FlagChangeListener) - * @see com.launchdarkly.sdk.server.Components#flagValueMonitoringListener(com.launchdarkly.sdk.server.LDClientInterface, String, com.launchdarkly.sdk.LDUser, FlagValueChangeListener) + * @see com.launchdarkly.sdk.server.interfaces.LDClientInterface#registerFlagChangeListener(FlagChangeListener) + * @see com.launchdarkly.sdk.server.interfaces.LDClientInterface#unregisterFlagChangeListener(FlagChangeListener) + * @see com.launchdarkly.sdk.server.Components#flagValueMonitoringListener(com.launchdarkly.sdk.server.interfaces.LDClientInterface, String, com.launchdarkly.sdk.LDUser, FlagValueChangeListener) */ public interface FlagValueChangeListener { /** diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClientInterface.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/LDClientInterface.java similarity index 95% rename from src/main/java/com/launchdarkly/sdk/server/LDClientInterface.java rename to src/main/java/com/launchdarkly/sdk/server/interfaces/LDClientInterface.java index 440ef3571..c5f2be4b1 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClientInterface.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/LDClientInterface.java @@ -1,24 +1,29 @@ -package com.launchdarkly.sdk.server; +package com.launchdarkly.sdk.server.interfaces; import com.launchdarkly.sdk.EvaluationDetail; import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; -import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; -import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; -import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.FeatureFlagsState; +import com.launchdarkly.sdk.server.FlagsStateOption; +import com.launchdarkly.sdk.server.LDClient; import java.io.Closeable; import java.io.IOException; /** * This interface defines the public methods of {@link LDClient}. + *

    + * Applications will normally interact directly with {@link LDClient}, and must use its constructor to + * initialize the SDK, but being able to refer to it indirectly via an interface may be helpul in test + * scenarios (mocking) or for some dependency injection frameworks. */ 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(); + boolean isInitialized(); /** * Tracks that a user performed an event. diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/package-info.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/package-info.java index b47a42367..f1d711e6d 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/package-info.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/package-info.java @@ -4,7 +4,7 @@ *

    * Most applications will not need to refer to these types. You will use them if you are creating a * plug-in component, such as a database integration, or if you use advanced features such as - * {@link com.launchdarkly.sdk.server.LDClientInterface#getDataStoreStatusProvider()} or - * {@link com.launchdarkly.sdk.server.LDClientInterface#registerFlagChangeListener(FlagChangeListener)}. + * {@link com.launchdarkly.sdk.server.interfaces.LDClientInterface#getDataStoreStatusProvider()} or + * {@link com.launchdarkly.sdk.server.interfaces.LDClientInterface#registerFlagChangeListener(FlagChangeListener)}. */ package com.launchdarkly.sdk.server.interfaces; diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientEndToEndTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientEndToEndTest.java index 9ca1e90be..c7f19e76d 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientEndToEndTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientEndToEndTest.java @@ -44,7 +44,7 @@ public void clientStartsInPollingMode() throws Exception { .build(); try (LDClient client = new LDClient(sdkKey, config)) { - assertTrue(client.initialized()); + assertTrue(client.isInitialized()); assertTrue(client.boolVariation(flagKey, user, false)); } } @@ -61,7 +61,7 @@ public void clientFailsInPollingModeWith401Error() throws Exception { .build(); try (LDClient client = new LDClient(sdkKey, config)) { - assertFalse(client.initialized()); + assertFalse(client.isInitialized()); assertFalse(client.boolVariation(flagKey, user, false)); } } @@ -80,7 +80,7 @@ public void clientStartsInPollingModeWithSelfSignedCert() throws Exception { .build(); try (LDClient client = new LDClient(sdkKey, config)) { - assertTrue(client.initialized()); + assertTrue(client.isInitialized()); assertTrue(client.boolVariation(flagKey, user, false)); } } @@ -99,7 +99,7 @@ public void clientStartsInStreamingMode() throws Exception { .build(); try (LDClient client = new LDClient(sdkKey, config)) { - assertTrue(client.initialized()); + assertTrue(client.isInitialized()); assertTrue(client.boolVariation(flagKey, user, false)); } } @@ -116,7 +116,7 @@ public void clientFailsInStreamingModeWith401Error() throws Exception { .build(); try (LDClient client = new LDClient(sdkKey, config)) { - assertFalse(client.initialized()); + assertFalse(client.isInitialized()); assertFalse(client.boolVariation(flagKey, user, false)); } } @@ -137,7 +137,7 @@ public void clientStartsInStreamingModeWithSelfSignedCert() throws Exception { .build(); try (LDClient client = new LDClient(sdkKey, config)) { - assertTrue(client.initialized()); + assertTrue(client.isInitialized()); assertTrue(client.boolVariation(flagKey, user, false)); } } @@ -155,7 +155,7 @@ public void clientSendsAnalyticsEvent() throws Exception { .build(); try (LDClient client = new LDClient(sdkKey, config)) { - assertTrue(client.initialized()); + assertTrue(client.isInitialized()); client.identify(new LDUser("userkey")); } @@ -175,7 +175,7 @@ public void clientSendsDiagnosticEvent() throws Exception { .build(); try (LDClient client = new LDClient(sdkKey, config)) { - assertTrue(client.initialized()); + assertTrue(client.isInitialized()); RecordedRequest req = server.takeRequest(); assertEquals("/diagnostic", req.getPath()); diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java index ed745ad25..989dda8e9 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java @@ -8,6 +8,7 @@ import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.LDClientInterface; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import org.junit.Test; diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientEventTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientEventTest.java index c0cd0c61b..44ab39243 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientEventTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientEventTest.java @@ -6,6 +6,7 @@ import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.Event; +import com.launchdarkly.sdk.server.interfaces.LDClientInterface; import org.junit.Test; diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientExternalUpdatesOnlyTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientExternalUpdatesOnlyTest.java index 0da231105..5aaf34980 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientExternalUpdatesOnlyTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientExternalUpdatesOnlyTest.java @@ -44,7 +44,7 @@ public void externalUpdatesOnlyClientIsInitialized() throws Exception { .dataSource(Components.externalUpdatesOnly()) .build(); try (LDClient client = new LDClient("SDK_KEY", config)) { - assertTrue(client.initialized()); + assertTrue(client.isInitialized()); assertEquals(DataSourceStatusProvider.State.VALID, client.getDataSourceStatusProvider().getStatus().getState()); } diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientOfflineTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientOfflineTest.java index 3fd0b79e4..ceb7d1a62 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientOfflineTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientOfflineTest.java @@ -5,6 +5,7 @@ import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.LDClientInterface; import org.junit.Test; @@ -47,7 +48,7 @@ public void offlineClientIsInitialized() throws IOException { .offline(true) .build(); try (LDClient client = new LDClient("SDK_KEY", config)) { - assertTrue(client.initialized()); + assertTrue(client.isInitialized()); assertEquals(DataSourceStatusProvider.State.VALID, client.getDataSourceStatusProvider().getStatus().getState()); } diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java index 32cf32690..e00ec126e 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java @@ -10,6 +10,7 @@ import com.launchdarkly.sdk.server.interfaces.Event; import com.launchdarkly.sdk.server.interfaces.EventProcessor; import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; +import com.launchdarkly.sdk.server.interfaces.LDClientInterface; import org.easymock.Capture; import org.easymock.EasyMock; @@ -233,7 +234,7 @@ public void noWaitForDataSourceIfWaitMillisIsZero() throws Exception { replayAll(); client = createMockClient(config); - assertFalse(client.initialized()); + assertFalse(client.isInitialized()); verifyAll(); } @@ -249,7 +250,7 @@ public void willWaitForDataSourceIfWaitMillisIsNonZero() throws Exception { replayAll(); client = createMockClient(config); - assertFalse(client.initialized()); + assertFalse(client.isInitialized()); verifyAll(); } @@ -265,7 +266,7 @@ public void dataSourceCanTimeOut() throws Exception { replayAll(); client = createMockClient(config); - assertFalse(client.initialized()); + assertFalse(client.isInitialized()); verifyAll(); } @@ -281,7 +282,7 @@ public void clientCatchesRuntimeExceptionFromDataSource() throws Exception { replayAll(); client = createMockClient(config); - assertFalse(client.initialized()); + assertFalse(client.isInitialized()); verifyAll(); } From ab99c8240f0a7040c76e473b2574c486cc36b3d9 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 19 May 2020 16:35:36 -0700 Subject: [PATCH 446/673] 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 223e0385d3b8f8e56e80d593cc568ea534557246 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 20 May 2020 14:35:49 -0700 Subject: [PATCH 447/673] fix merge conflict --- .../launchdarkly/sdk/server/interfaces/package-info.java | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/package-info.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/package-info.java index ed7601002..6206d78d7 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/package-info.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/package-info.java @@ -4,12 +4,7 @@ *

    * Most applications will not need to refer to these types. You will use them if you are creating a * plug-in component, such as a database integration, or if you use advanced features such as -<<<<<<< HEAD - * {@link com.launchdarkly.sdk.server.LDClientInterface#getDataStoreStatusProvider()} or - * {@link com.launchdarkly.sdk.server.LDClientInterface#getFlagTracker()}. -======= * {@link com.launchdarkly.sdk.server.interfaces.LDClientInterface#getDataStoreStatusProvider()} or - * {@link com.launchdarkly.sdk.server.interfaces.LDClientInterface#registerFlagChangeListener(FlagChangeListener)}. ->>>>>>> 5.x + * {@link com.launchdarkly.sdk.server.interfaces.LDClientInterface#getFlagTracker()}. */ package com.launchdarkly.sdk.server.interfaces; From 4cdb376022086de4491f47d4c541793fd0fbef2c Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 22 May 2020 12:57:04 -0700 Subject: [PATCH 448/673] preprocess various things to speed up evaluations --- .../launchdarkly/sdk/server/DataModel.java | 38 +-- .../launchdarkly/sdk/server/Evaluator.java | 22 +- .../sdk/server/EvaluatorOperators.java | 93 +++---- .../sdk/server/EvaluatorPreprocessing.java | 156 ++++++++++++ .../sdk/server/EvaluatorTypeConversion.java | 52 ++++ .../server/DataModelSerializationTest.java | 20 +- .../EvaluatorOperatorsParameterizedTest.java | 217 ++++++++++------- .../sdk/server/EvaluatorOperatorsTest.java | 21 -- .../server/EvaluatorPreprocessingTest.java | 227 ++++++++++++++++++ .../sdk/server/ModelBuilders.java | 5 +- 10 files changed, 672 insertions(+), 179 deletions(-) create mode 100644 src/main/java/com/launchdarkly/sdk/server/EvaluatorPreprocessing.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/EvaluatorTypeConversion.java delete mode 100644 src/test/java/com/launchdarkly/sdk/server/EvaluatorOperatorsTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/EvaluatorPreprocessingTest.java diff --git a/src/main/java/com/launchdarkly/sdk/server/DataModel.java b/src/main/java/com/launchdarkly/sdk/server/DataModel.java index f2a907ee3..64c4563ab 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DataModel.java +++ b/src/main/java/com/launchdarkly/sdk/server/DataModel.java @@ -191,17 +191,7 @@ boolean isClientSide() { // 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())); - } - } + EvaluatorPreprocessing.preprocessFlag(this); } } @@ -303,12 +293,16 @@ void setRuleMatchReason(EvaluationReason ruleMatchReason) { } } - static class Clause { + static final class Clause { private UserAttribute attribute; private Operator op; private List values; //interpreted as an OR of values private boolean negate; - + + // The following property is marked transient because it is not to be serialized or deserialized; + // it is (if necessary) precomputed in FeatureFlag.afterDeserialized() to speed up evaluations. + transient EvaluatorPreprocessing.ClauseExtra preprocessed; + Clause() { } @@ -327,13 +321,21 @@ Operator getOp() { return op; } - Iterable getValues() { + List getValues() { return values; } boolean isNegate() { return negate; } + + EvaluatorPreprocessing.ClauseExtra getPreprocessed() { + return preprocessed; + } + + void setPreprocessed(EvaluatorPreprocessing.ClauseExtra preprocessed) { + this.preprocessed = preprocessed; + } } static final class Rollout { @@ -400,7 +402,8 @@ int getWeight() { } } - static final class Segment implements VersionedData { + @JsonAdapter(JsonHelpers.PostProcessingDeserializableTypeAdapterFactory.class) + static final class Segment implements VersionedData, JsonHelpers.PostProcessingDeserializable { private String key; private Set included; private Set excluded; @@ -451,6 +454,11 @@ public int getVersion() { public boolean isDeleted() { return deleted; } + + // Precompute some invariant values for improved efficiency during evaluations - called from JsonHelpers.PostProcessingDeserializableTypeAdapter + public void afterDeserialized() { + EvaluatorPreprocessing.preprocessSegment(this); + } } static final class SegmentRule { diff --git a/src/main/java/com/launchdarkly/sdk/server/Evaluator.java b/src/main/java/com/launchdarkly/sdk/server/Evaluator.java index 4e3136dd4..f956c37c3 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Evaluator.java +++ b/src/main/java/com/launchdarkly/sdk/server/Evaluator.java @@ -13,6 +13,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.Set; import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; @@ -271,11 +272,26 @@ private boolean clauseMatchesUserNoSegments(DataModel.Clause clause, LDUser user return false; } - private boolean clauseMatchAny(DataModel.Clause clause, LDValue userValue) { + static boolean clauseMatchAny(DataModel.Clause clause, LDValue userValue) { DataModel.Operator op = clause.getOp(); if (op != null) { - for (LDValue v : clause.getValues()) { - if (EvaluatorOperators.apply(op, userValue, v)) { + EvaluatorPreprocessing.ClauseExtra preprocessed = clause.getPreprocessed(); + if (op == DataModel.Operator.in) { + // see if we have precomputed a Set for fast equality matching + Set vs = preprocessed == null ? null : preprocessed.valuesSet; + if (vs != null) { + return vs.contains(userValue); + } + } + List values = clause.getValues(); + List preprocessedValues = + preprocessed == null ? null : preprocessed.valuesExtra; + int n = values.size(); + for (int i = 0; i < n; i++) { + // the preprocessed list, if present, will always have the same size as the values list + EvaluatorPreprocessing.ClauseExtra.ValueExtra p = preprocessedValues == null ? null : preprocessedValues.get(i); + LDValue v = values.get(i); + if (EvaluatorOperators.apply(op, userValue, v, p)) { return true; } } diff --git a/src/main/java/com/launchdarkly/sdk/server/EvaluatorOperators.java b/src/main/java/com/launchdarkly/sdk/server/EvaluatorOperators.java index 75d02ae52..b9338dff5 100644 --- a/src/main/java/com/launchdarkly/sdk/server/EvaluatorOperators.java +++ b/src/main/java/com/launchdarkly/sdk/server/EvaluatorOperators.java @@ -2,11 +2,13 @@ import com.launchdarkly.sdk.LDValue; -import java.time.Instant; -import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.util.regex.Pattern; +import static com.launchdarkly.sdk.server.EvaluatorTypeConversion.valueToDateTime; +import static com.launchdarkly.sdk.server.EvaluatorTypeConversion.valueToRegex; +import static com.launchdarkly.sdk.server.EvaluatorTypeConversion.valueToSemVer; + /** * Defines the behavior of all operators that can be used in feature flag rules and segment rules. */ @@ -35,7 +37,12 @@ boolean test(int delta) { } } - static boolean apply(DataModel.Operator op, LDValue userValue, LDValue clauseValue) { + static boolean apply( + DataModel.Operator op, + LDValue userValue, + LDValue clauseValue, + EvaluatorPreprocessing.ClauseExtra.ValueExtra preprocessed + ) { switch (op) { case in: return userValue.equals(clauseValue); @@ -47,8 +54,11 @@ static boolean apply(DataModel.Operator op, LDValue userValue, LDValue clauseVal return userValue.isString() && clauseValue.isString() && userValue.stringValue().startsWith(clauseValue.stringValue()); case matches: - return userValue.isString() && clauseValue.isString() && - Pattern.compile(clauseValue.stringValue()).matcher(userValue.stringValue()).find(); + // If preprocessed is non-null, it means we've already tried to parse the clause value as a regex, + // in which case if preprocessed.parsedRegex is null it was not a valid regex. + Pattern clausePattern = preprocessed == null ? valueToRegex(clauseValue) : preprocessed.parsedRegex; + return clausePattern != null && userValue.isString() && + clausePattern.matcher(userValue.stringValue()).find(); case contains: return userValue.isString() && clauseValue.isString() && userValue.stringValue().contains(clauseValue.stringValue()); @@ -66,19 +76,19 @@ static boolean apply(DataModel.Operator op, LDValue userValue, LDValue clauseVal return compareNumeric(ComparisonOp.GTE, userValue, clauseValue); case before: - return compareDate(ComparisonOp.LT, userValue, clauseValue); + return compareDate(ComparisonOp.LT, userValue, clauseValue, preprocessed); case after: - return compareDate(ComparisonOp.GT, userValue, clauseValue); + return compareDate(ComparisonOp.GT, userValue, clauseValue, preprocessed); case semVerEqual: - return compareSemVer(ComparisonOp.EQ, userValue, clauseValue); + return compareSemVer(ComparisonOp.EQ, userValue, clauseValue, preprocessed); case semVerLessThan: - return compareSemVer(ComparisonOp.LT, userValue, clauseValue); + return compareSemVer(ComparisonOp.LT, userValue, clauseValue, preprocessed); case semVerGreaterThan: - return compareSemVer(ComparisonOp.GT, userValue, clauseValue); + return compareSemVer(ComparisonOp.GT, userValue, clauseValue, preprocessed); case segmentMatch: // We shouldn't call apply() for this operator, because it is really implemented in @@ -98,46 +108,41 @@ private static boolean compareNumeric(ComparisonOp op, LDValue userValue, LDValu return op.test(compare); } - private static boolean compareDate(ComparisonOp op, LDValue userValue, LDValue clauseValue) { - ZonedDateTime dt1 = valueToDateTime(userValue); - ZonedDateTime dt2 = valueToDateTime(clauseValue); - if (dt1 == null || dt2 == null) { + private static boolean compareDate( + ComparisonOp op, + LDValue userValue, + LDValue clauseValue, + EvaluatorPreprocessing.ClauseExtra.ValueExtra preprocessed + ) { + // If preprocessed is non-null, it means we've already tried to parse the clause value as a date/time, + // in which case if preprocessed.parsedDate is null it was not a valid date/time. + ZonedDateTime clauseDate = preprocessed == null ? valueToDateTime(clauseValue) : preprocessed.parsedDate; + if (clauseDate == null) { return false; } - return op.test(dt1.compareTo(dt2)); - } - - private static boolean compareSemVer(ComparisonOp op, LDValue userValue, LDValue clauseValue) { - SemanticVersion sv1 = valueToSemVer(userValue); - SemanticVersion sv2 = valueToSemVer(clauseValue); - if (sv1 == null || sv2 == null) { + ZonedDateTime userDate = valueToDateTime(userValue); + if (userDate == null) { return false; } - return op.test(sv1.compareTo(sv2)); + return op.test(userDate.compareTo(clauseDate)); } - - private static ZonedDateTime valueToDateTime(LDValue value) { - if (value.isNumber()) { - return ZonedDateTime.ofInstant(Instant.ofEpochMilli(value.longValue()), ZoneOffset.UTC); - } else if (value.isString()) { - try { - return ZonedDateTime.parse(value.stringValue()); - } catch (Throwable t) { - return null; - } - } else { - return null; - } - } - - private static SemanticVersion valueToSemVer(LDValue value) { - if (!value.isString()) { - return null; + + private static boolean compareSemVer( + ComparisonOp op, + LDValue userValue, + LDValue clauseValue, + EvaluatorPreprocessing.ClauseExtra.ValueExtra preprocessed + ) { + // If preprocessed is non-null, it means we've already tried to parse the clause value as a version, + // in which case if preprocessed.parsedSemVer is null it was not a valid version. + SemanticVersion clauseVer = preprocessed == null ? valueToSemVer(clauseValue) : preprocessed.parsedSemVer; + if (clauseVer == null) { + return false; } - try { - return SemanticVersion.parse(value.stringValue(), true); - } catch (SemanticVersion.InvalidVersionException e) { - return null; + SemanticVersion userVer = valueToSemVer(userValue); + if (userVer == null) { + return false; } + return op.test(userVer.compareTo(clauseVer)); } } diff --git a/src/main/java/com/launchdarkly/sdk/server/EvaluatorPreprocessing.java b/src/main/java/com/launchdarkly/sdk/server/EvaluatorPreprocessing.java new file mode 100644 index 000000000..b94646ce6 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/EvaluatorPreprocessing.java @@ -0,0 +1,156 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.collect.ImmutableSet; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.DataModel.Clause; +import com.launchdarkly.sdk.server.DataModel.FeatureFlag; +import com.launchdarkly.sdk.server.DataModel.Operator; +import com.launchdarkly.sdk.server.DataModel.Prerequisite; +import com.launchdarkly.sdk.server.DataModel.Rule; +import com.launchdarkly.sdk.server.DataModel.Segment; +import com.launchdarkly.sdk.server.DataModel.SegmentRule; + +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.function.Function; +import java.util.regex.Pattern; + +/** + * These methods precompute data that may help to reduce the overhead of feature flag evaluations. They + * are called from the afterDeserialized() methods of FeatureFlag and Segment, after those objects have + * been deserialized from JSON but before they have been made available to any other code (so these + * methods do not need to be thread-safe). + *

    + * If for some reason these methods have not been called before an evaluation happens, the evaluation + * logic must still be able to work without the precomputed data. + */ +abstract class EvaluatorPreprocessing { + private EvaluatorPreprocessing() {} + + static final class ClauseExtra { + final Set valuesSet; + final List valuesExtra; + + ClauseExtra(Set valuesSet, List valuesExtra) { + this.valuesSet = valuesSet; + this.valuesExtra = valuesExtra; + } + + static final class ValueExtra { + final ZonedDateTime parsedDate; + final Pattern parsedRegex; + final SemanticVersion parsedSemVer; + + ValueExtra(ZonedDateTime parsedDate, Pattern parsedRegex, SemanticVersion parsedSemVer) { + this.parsedDate = parsedDate; + this.parsedRegex = parsedRegex; + this.parsedSemVer = parsedSemVer; + } + } + } + + static void preprocessFlag(FeatureFlag f) { + if (f.getPrerequisites() != null) { + for (Prerequisite p: f.getPrerequisites()) { + EvaluatorPreprocessing.preprocessPrerequisite(p); + } + } + List rules = f.getRules(); + if (rules != null) { + int n = rules.size(); + for (int i = 0; i < n; i++) { + preprocessFlagRule(rules.get(i), i); + } + } + } + + static void preprocessSegment(Segment s) { + List rules = s.getRules(); + if (rules != null) { + int n = rules.size(); + for (int i = 0; i < n; i++) { + preprocessSegmentRule(rules.get(i), i); + } + } + } + + static void preprocessPrerequisite(Prerequisite p) { + // Precompute an immutable EvaluationReason instance that will be used if the prerequisite fails. + p.setPrerequisiteFailedReason(EvaluationReason.prerequisiteFailed(p.getKey())); + } + + static void preprocessFlagRule(Rule r, int ruleIndex) { + // Precompute an immutable EvaluationReason instance that will be used if a user matches this rule. + r.setRuleMatchReason(EvaluationReason.ruleMatch(ruleIndex, r.getId())); + + if (r.getClauses() != null) { + for (Clause c: r.getClauses()) { + preprocessClause(c); + } + } + } + + static void preprocessSegmentRule(SegmentRule r, int ruleIndex) { + if (r.getClauses() != null) { + for (Clause c: r.getClauses()) { + preprocessClause(c); + } + } + } + + static void preprocessClause(Clause c) { + Operator op = c.getOp(); + if (op == null) { + return; + } + switch (op) { + case in: + // This is a special case where the clause is testing for an exact match against any of the + // clause values. Converting the value list to a Set allows us to do a fast lookup instead of + // a linear search. We do not do this for other operators (or if there are fewer than two + // values) because the slight extra overhead of a Set is not worthwhile in those case. + List values = c.getValues(); + if (values != null && values.size() > 1) { + c.setPreprocessed(new ClauseExtra(ImmutableSet.copyOf(values), null)); + } + break; + case matches: + c.setPreprocessed(preprocessClauseValues(c.getValues(), v -> + new ClauseExtra.ValueExtra(null, EvaluatorTypeConversion.valueToRegex(v), null) + )); + break; + case after: + case before: + c.setPreprocessed(preprocessClauseValues(c.getValues(), v -> + new ClauseExtra.ValueExtra(EvaluatorTypeConversion.valueToDateTime(v), null, null) + )); + break; + case semVerEqual: + case semVerGreaterThan: + case semVerLessThan: + c.setPreprocessed(preprocessClauseValues(c.getValues(), v -> + new ClauseExtra.ValueExtra(null, null, EvaluatorTypeConversion.valueToSemVer(v)) + )); + break; + default: + break; + } + } + + private static ClauseExtra preprocessClauseValues( + List values, + Function f + ) { + if (values == null) { + return null; + } + List valuesExtra = new ArrayList<>(values.size()); + for (LDValue v: values) { + valuesExtra.add(f.apply(v)); + } + return new ClauseExtra(null, valuesExtra); + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/EvaluatorTypeConversion.java b/src/main/java/com/launchdarkly/sdk/server/EvaluatorTypeConversion.java new file mode 100644 index 000000000..75e440a13 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/EvaluatorTypeConversion.java @@ -0,0 +1,52 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.LDValue; + +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +abstract class EvaluatorTypeConversion { + private EvaluatorTypeConversion() {} + + static ZonedDateTime valueToDateTime(LDValue value) { + if (value == null) { + return null; + } + if (value.isNumber()) { + return ZonedDateTime.ofInstant(Instant.ofEpochMilli(value.longValue()), ZoneOffset.UTC); + } else if (value.isString()) { + try { + return ZonedDateTime.parse(value.stringValue()); + } catch (Throwable t) { + return null; + } + } else { + return null; + } + } + + static Pattern valueToRegex(LDValue value) { + if (value == null || !value.isString()) { + return null; + } + try { + return Pattern.compile(value.stringValue()); + } catch (PatternSyntaxException e) { + return null; + } + } + + static SemanticVersion valueToSemVer(LDValue value) { + if (value == null || !value.isString()) { + return null; + } + try { + return SemanticVersion.parse(value.stringValue(), true); + } catch (SemanticVersion.InvalidVersionException e) { + return null; + } + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java b/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java index 731427f9e..7d455c2d4 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java @@ -106,7 +106,7 @@ private LDValue flagWithAllPropertiesJson() { .add(LDValue.buildObject() .put("attribute", "name") .put("op", "in") - .put("values", LDValue.buildArray().add("Lucy").build()) + .put("values", LDValue.buildArray().add("Lucy").add("Mina").build()) .put("negate", true) .build()) .build()) @@ -160,9 +160,14 @@ private void assertFlagHasAllProperties(FeatureFlag flag) { Clause c0 = r0.getClauses().get(0); assertEquals(UserAttribute.NAME, c0.getAttribute()); assertEquals(Operator.in, c0.getOp()); - assertEquals(ImmutableList.of(LDValue.of("Lucy")), c0.getValues()); + assertEquals(ImmutableList.of(LDValue.of("Lucy"), LDValue.of("Mina")), c0.getValues()); assertTrue(c0.isNegate()); + // Check for just one example of preprocessing, to verify that preprocessing has happened in + // general for this flag - the details are covered in EvaluatorPreprocessingTest. + assertNotNull(c0.preprocessed); + assertEquals(ImmutableSet.of(LDValue.of("Lucy"), LDValue.of("Mina")), c0.preprocessed.valuesSet); + Rule r1 = flag.getRules().get(1); assertEquals("id1", r1.getId()); assertFalse(r1.isTrackEvents()); @@ -200,7 +205,7 @@ private LDValue segmentWithAllPropertiesJson() { .add(LDValue.buildObject() .put("attribute", "name") .put("op", "in") - .put("values", LDValue.buildArray().add("Lucy").build()) + .put("values", LDValue.buildArray().add("Lucy").add("Mina").build()) .put("negate", true) .build()) .build()) @@ -223,12 +228,19 @@ private void assertSegmentHasAllProperties(Segment segment) { SegmentRule r0 = segment.getRules().get(0); assertEquals(new Integer(50000), r0.getWeight()); assertNotNull(r0.getClauses()); + assertEquals(1, r0.getClauses().size()); Clause c0 = r0.getClauses().get(0); assertEquals(UserAttribute.NAME, c0.getAttribute()); assertEquals(Operator.in, c0.getOp()); - assertEquals(ImmutableList.of(LDValue.of("Lucy")), c0.getValues()); + assertEquals(ImmutableList.of(LDValue.of("Lucy"), LDValue.of("Mina")), c0.getValues()); assertTrue(c0.isNegate()); + + // Check for just one example of preprocessing, to verify that preprocessing has happened in + // general for this segment - the details are covered in EvaluatorPreprocessingTest. + assertNotNull(c0.preprocessed); + assertEquals(ImmutableSet.of(LDValue.of("Lucy"), LDValue.of("Mina")), c0.preprocessed.valuesSet); + SegmentRule r1 = segment.getRules().get(1); assertNull(r1.getWeight()); assertNull(r1.getBucketBy()); diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorOperatorsParameterizedTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorOperatorsParameterizedTest.java index 240f70c27..51f091e1b 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EvaluatorOperatorsParameterizedTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorOperatorsParameterizedTest.java @@ -1,38 +1,50 @@ package com.launchdarkly.sdk.server; import com.launchdarkly.sdk.LDValue; -import com.launchdarkly.sdk.server.DataModel; -import com.launchdarkly.sdk.server.EvaluatorOperators; +import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.server.DataModel.Clause; +import com.launchdarkly.sdk.server.DataModel.Operator; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.Parameterized; +import java.util.ArrayList; import java.util.Arrays; +import java.util.List; import static org.junit.Assert.assertEquals; @SuppressWarnings("javadoc") @RunWith(Parameterized.class) public class EvaluatorOperatorsParameterizedTest { - 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 dateStrUtc1 = LDValue.of("2017-12-06T00:00:00.000Z"); - private static LDValue dateStrUtc2 = LDValue.of("2017-12-06T00:01:01.000Z"); - 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 static final LDValue dateStr1 = LDValue.of("2017-12-06T00:00:00.000-07:00"); + private static final LDValue dateStr2 = LDValue.of("2017-12-06T00:01:01.000-07:00"); + private static final LDValue dateStrUtc1 = LDValue.of("2017-12-06T00:00:00.000Z"); + private static final LDValue dateStrUtc2 = LDValue.of("2017-12-06T00:01:01.000Z"); + private static final LDValue dateMs1 = LDValue.of(10000000); + private static final LDValue dateMs2 = LDValue.of(10000001); + private static final LDValue invalidDate = LDValue.of("hey what's this?"); + private static final LDValue invalidVer = LDValue.of("xbad%ver"); + private static final UserAttribute userAttr = UserAttribute.forName("attr"); - private final DataModel.Operator op; - private final LDValue aValue; - private final LDValue bValue; + private final Operator op; + private final LDValue userValue; + private final LDValue clauseValue; + private final LDValue[] extraClauseValues; private final boolean shouldBe; - public EvaluatorOperatorsParameterizedTest(DataModel.Operator op, LDValue aValue, LDValue bValue, boolean shouldBe) { + public EvaluatorOperatorsParameterizedTest( + Operator op, + LDValue userValue, + LDValue clauseValue, + LDValue[] extraClauseValues, + boolean shouldBe + ) { this.op = op; - this.aValue = aValue; - this.bValue = bValue; + this.userValue = userValue; + this.clauseValue = clauseValue; + this.extraClauseValues = extraClauseValues; this.shouldBe = shouldBe; } @@ -40,95 +52,118 @@ public EvaluatorOperatorsParameterizedTest(DataModel.Operator op, LDValue aValue public static Iterable data() { return Arrays.asList(new Object[][] { // numeric comparisons - { DataModel.Operator.in, LDValue.of(99), LDValue.of(99), true }, - { DataModel.Operator.in, LDValue.of(99.0001), LDValue.of(99.0001), true }, - { DataModel.Operator.in, LDValue.of(99), LDValue.of(99.0001), false }, - { DataModel.Operator.in, LDValue.of(99.0001), LDValue.of(99), false }, - { DataModel.Operator.lessThan, LDValue.of(99), LDValue.of(99.0001), true }, - { DataModel.Operator.lessThan, LDValue.of(99.0001), LDValue.of(99), false }, - { DataModel.Operator.lessThan, LDValue.of(99), LDValue.of(99), false }, - { DataModel.Operator.lessThanOrEqual, LDValue.of(99), LDValue.of(99.0001), true }, - { DataModel.Operator.lessThanOrEqual, LDValue.of(99.0001), LDValue.of(99), false }, - { DataModel.Operator.lessThanOrEqual, LDValue.of(99), LDValue.of(99), true }, - { DataModel.Operator.greaterThan, LDValue.of(99.0001), LDValue.of(99), true }, - { DataModel.Operator.greaterThan, LDValue.of(99), LDValue.of(99.0001), false }, - { DataModel.Operator.greaterThan, LDValue.of(99), LDValue.of(99), false }, - { DataModel.Operator.greaterThanOrEqual, LDValue.of(99.0001), LDValue.of(99), true }, - { DataModel.Operator.greaterThanOrEqual, LDValue.of(99), LDValue.of(99.0001), false }, - { DataModel.Operator.greaterThanOrEqual, LDValue.of(99), LDValue.of(99), true }, + { Operator.in, LDValue.of(99), LDValue.of(99), null, true }, + { Operator.in, LDValue.of(99), LDValue.of(99), new LDValue[] { LDValue.of(98), LDValue.of(97), LDValue.of(96) }, true }, + { Operator.in, LDValue.of(99.0001), LDValue.of(99.0001), new LDValue[] { LDValue.of(98), LDValue.of(97), LDValue.of(96) }, true }, + { Operator.in, LDValue.of(99.0001), LDValue.of(99.0001), null, true }, + { Operator.in, LDValue.of(99), LDValue.of(99.0001), null, false }, + { Operator.in, LDValue.of(99.0001), LDValue.of(99), null, false }, + { Operator.lessThan, LDValue.of(99), LDValue.of(99.0001), null, true }, + { Operator.lessThan, LDValue.of(99.0001), LDValue.of(99), null, false }, + { Operator.lessThan, LDValue.of(99), LDValue.of(99), null, false }, + { Operator.lessThanOrEqual, LDValue.of(99), LDValue.of(99.0001), null, true }, + { Operator.lessThanOrEqual, LDValue.of(99.0001), LDValue.of(99), null, false }, + { Operator.lessThanOrEqual, LDValue.of(99), LDValue.of(99), null, true }, + { Operator.greaterThan, LDValue.of(99.0001), LDValue.of(99), null, true }, + { Operator.greaterThan, LDValue.of(99), LDValue.of(99.0001), null, false }, + { Operator.greaterThan, LDValue.of(99), LDValue.of(99), null, false }, + { Operator.greaterThanOrEqual, LDValue.of(99.0001), LDValue.of(99), null, true }, + { Operator.greaterThanOrEqual, LDValue.of(99), LDValue.of(99.0001), null, false }, + { Operator.greaterThanOrEqual, LDValue.of(99), LDValue.of(99), null, true }, // string comparisons - { DataModel.Operator.in, LDValue.of("x"), LDValue.of("x"), true }, - { DataModel.Operator.in, LDValue.of("x"), LDValue.of("xyz"), false }, - { DataModel.Operator.startsWith, LDValue.of("xyz"), LDValue.of("x"), true }, - { DataModel.Operator.startsWith, LDValue.of("x"), LDValue.of("xyz"), false }, - { DataModel.Operator.endsWith, LDValue.of("xyz"), LDValue.of("z"), true }, - { DataModel.Operator.endsWith, LDValue.of("z"), LDValue.of("xyz"), false }, - { DataModel.Operator.contains, LDValue.of("xyz"), LDValue.of("y"), true }, - { DataModel.Operator.contains, LDValue.of("y"), LDValue.of("xyz"), false }, + { Operator.in, LDValue.of("x"), LDValue.of("x"), null, true }, + { Operator.in, LDValue.of("x"), LDValue.of("xyz"), null, false }, + { Operator.in, LDValue.of("x"), LDValue.of("x"), new LDValue[] { LDValue.of("a"), LDValue.of("b"), LDValue.of("c") }, true }, + { Operator.startsWith, LDValue.of("xyz"), LDValue.of("x"), null, true }, + { Operator.startsWith, LDValue.of("x"), LDValue.of("xyz"), null, false }, + { Operator.endsWith, LDValue.of("xyz"), LDValue.of("z"), null, true }, + { Operator.endsWith, LDValue.of("z"), LDValue.of("xyz"), null, false }, + { Operator.contains, LDValue.of("xyz"), LDValue.of("y"), null, true }, + { Operator.contains, LDValue.of("y"), LDValue.of("xyz"), null, false }, // mixed strings and numbers - { DataModel.Operator.in, LDValue.of("99"), LDValue.of(99), false }, - { DataModel.Operator.in, LDValue.of(99), LDValue.of("99"), false }, - { DataModel.Operator.contains, LDValue.of("99"), LDValue.of(99), false }, - { DataModel.Operator.startsWith, LDValue.of("99"), LDValue.of(99), false }, - { DataModel.Operator.endsWith, LDValue.of("99"), LDValue.of(99), false }, - { DataModel.Operator.lessThanOrEqual, LDValue.of("99"), LDValue.of(99), false }, - { DataModel.Operator.lessThanOrEqual, LDValue.of(99), LDValue.of("99"), false }, - { DataModel.Operator.greaterThanOrEqual, LDValue.of("99"), LDValue.of(99), false }, - { DataModel.Operator.greaterThanOrEqual, LDValue.of(99), LDValue.of("99"), false }, + { Operator.in, LDValue.of("99"), LDValue.of(99), null, false }, + { Operator.in, LDValue.of(99), LDValue.of("99"), null, false }, + { Operator.contains, LDValue.of("99"), LDValue.of(99), null, false }, + { Operator.startsWith, LDValue.of("99"), LDValue.of(99), null, false }, + { Operator.endsWith, LDValue.of("99"), LDValue.of(99), null, false }, + { Operator.lessThanOrEqual, LDValue.of("99"), LDValue.of(99), null, false }, + { Operator.lessThanOrEqual, LDValue.of(99), LDValue.of("99"), null, false }, + { Operator.greaterThanOrEqual, LDValue.of("99"), LDValue.of(99), null, false }, + { Operator.greaterThanOrEqual, LDValue.of(99), LDValue.of("99"), null, false }, + + // boolean values + { Operator.in, LDValue.of(true), LDValue.of(true), null, true }, + { Operator.in, LDValue.of(false), LDValue.of(false), null, true }, + { Operator.in, LDValue.of(true), LDValue.of(false), null, false }, + { Operator.in, LDValue.of(false), LDValue.of(true), null, false }, + { Operator.in, LDValue.of(true), LDValue.of(false), new LDValue[] { LDValue.of(true) }, true }, // regex - { DataModel.Operator.matches, LDValue.of("hello world"), LDValue.of("hello.*rld"), true }, - { DataModel.Operator.matches, LDValue.of("hello world"), LDValue.of("hello.*orl"), true }, - { DataModel.Operator.matches, LDValue.of("hello world"), LDValue.of("l+"), true }, - { DataModel.Operator.matches, LDValue.of("hello world"), LDValue.of("(world|planet)"), true }, - { DataModel.Operator.matches, LDValue.of("hello world"), LDValue.of("aloha"), false }, + { Operator.matches, LDValue.of("hello world"), LDValue.of("hello.*rld"), null, true }, + { Operator.matches, LDValue.of("hello world"), LDValue.of("hello.*orl"), null, true }, + { Operator.matches, LDValue.of("hello world"), LDValue.of("l+"), null, true }, + { Operator.matches, LDValue.of("hello world"), LDValue.of("(world|planet)"), null, true }, + { Operator.matches, LDValue.of("hello world"), LDValue.of("aloha"), null, false }, + // note that an invalid regex in a clause should *not* cause an exception, just a non-match + { Operator.matches, LDValue.of("hello world"), LDValue.of("***not a regex"), null, false }, // dates - { DataModel.Operator.before, dateStr1, dateStr2, true }, - { DataModel.Operator.before, dateStrUtc1, dateStrUtc2, true }, - { DataModel.Operator.before, dateMs1, dateMs2, true }, - { DataModel.Operator.before, dateStr2, dateStr1, false }, - { DataModel.Operator.before, dateStrUtc2, dateStrUtc1, false }, - { DataModel.Operator.before, dateMs2, dateMs1, false }, - { DataModel.Operator.before, dateStr1, dateStr1, false }, - { DataModel.Operator.before, dateMs1, dateMs1, false }, - { DataModel.Operator.before, dateStr1, invalidDate, false }, - { DataModel.Operator.after, dateStr1, dateStr2, false }, - { DataModel.Operator.after, dateStrUtc1, dateStrUtc2, false }, - { DataModel.Operator.after, dateMs1, dateMs2, false }, - { DataModel.Operator.after, dateStr2, dateStr1, true }, - { DataModel.Operator.after, dateStrUtc2, dateStrUtc1, true }, - { DataModel.Operator.after, dateMs2, dateMs1, true }, - { DataModel.Operator.after, dateStr1, dateStr1, false }, - { DataModel.Operator.after, dateMs1, dateMs1, false }, - { DataModel.Operator.after, dateStr1, invalidDate, false }, + { Operator.before, dateStr1, dateStr2, null, true }, + { Operator.before, dateStrUtc1, dateStrUtc2, null, true }, + { Operator.before, dateMs1, dateMs2, null, true }, + { Operator.before, dateStr2, dateStr1, null, false }, + { Operator.before, dateStrUtc2, dateStrUtc1, null, false }, + { Operator.before, dateMs2, dateMs1, null, false }, + { Operator.before, dateStr1, dateStr1, null, false }, + { Operator.before, dateMs1, dateMs1, null, false }, + { Operator.before, dateStr1, invalidDate, null, false }, + { Operator.after, dateStr1, dateStr2, null, false }, + { Operator.after, dateStrUtc1, dateStrUtc2, null, false }, + { Operator.after, dateMs1, dateMs2, null, false }, + { Operator.after, dateStr2, dateStr1, null, true }, + { Operator.after, dateStrUtc2, dateStrUtc1, null, true }, + { Operator.after, dateMs2, dateMs1, null, true }, + { Operator.after, dateStr1, dateStr1, null, false }, + { Operator.after, dateMs1, dateMs1, null, false }, + { Operator.after, dateStr1, invalidDate, null, false }, // semver - { DataModel.Operator.semVerEqual, LDValue.of("2.0.1"), LDValue.of("2.0.1"), true }, - { DataModel.Operator.semVerEqual, LDValue.of("2.0"), LDValue.of("2.0.0"), true }, - { DataModel.Operator.semVerEqual, LDValue.of("2"), LDValue.of("2.0.0"), true }, - { DataModel.Operator.semVerEqual, LDValue.of("2-rc1"), LDValue.of("2.0.0-rc1"), true }, - { DataModel.Operator.semVerEqual, LDValue.of("2+build2"), LDValue.of("2.0.0+build2"), true }, - { DataModel.Operator.semVerLessThan, LDValue.of("2.0.0"), LDValue.of("2.0.1"), true }, - { DataModel.Operator.semVerLessThan, LDValue.of("2.0"), LDValue.of("2.0.1"), true }, - { DataModel.Operator.semVerLessThan, LDValue.of("2.0.1"), LDValue.of("2.0.0"), false }, - { DataModel.Operator.semVerLessThan, LDValue.of("2.0.1"), LDValue.of("2.0"), false }, - { DataModel.Operator.semVerLessThan, LDValue.of("2.0.0-rc"), LDValue.of("2.0.0"), true }, - { DataModel.Operator.semVerLessThan, LDValue.of("2.0.0-rc"), LDValue.of("2.0.0-rc.beta"), true }, - { DataModel.Operator.semVerGreaterThan, LDValue.of("2.0.1"), LDValue.of("2.0.0"), true }, - { DataModel.Operator.semVerGreaterThan, LDValue.of("2.0.1"), LDValue.of("2.0"), true }, - { DataModel.Operator.semVerGreaterThan, LDValue.of("2.0.0"), LDValue.of("2.0.1"), false }, - { DataModel.Operator.semVerGreaterThan, LDValue.of("2.0"), LDValue.of("2.0.1"), false }, - { DataModel.Operator.semVerGreaterThan, LDValue.of("2.0.0-rc.1"), LDValue.of("2.0.0-rc.0"), true }, - { DataModel.Operator.semVerLessThan, LDValue.of("2.0.1"), invalidVer, false }, - { DataModel.Operator.semVerGreaterThan, LDValue.of("2.0.1"), invalidVer, false } + { Operator.semVerEqual, LDValue.of("2.0.1"), LDValue.of("2.0.1"), null, true }, + { Operator.semVerEqual, LDValue.of("2.0"), LDValue.of("2.0.0"), null, true }, + { Operator.semVerEqual, LDValue.of("2"), LDValue.of("2.0.0"), null, true }, + { Operator.semVerEqual, LDValue.of("2-rc1"), LDValue.of("2.0.0-rc1"), null, true }, + { Operator.semVerEqual, LDValue.of("2+build2"), LDValue.of("2.0.0+build2"), null, true }, + { Operator.semVerLessThan, LDValue.of("2.0.0"), LDValue.of("2.0.1"), null, true }, + { Operator.semVerLessThan, LDValue.of("2.0"), LDValue.of("2.0.1"), null, true }, + { Operator.semVerLessThan, LDValue.of("2.0.1"), LDValue.of("2.0.0"), null, false }, + { Operator.semVerLessThan, LDValue.of("2.0.1"), LDValue.of("2.0"), null, false }, + { Operator.semVerLessThan, LDValue.of("2.0.0-rc"), LDValue.of("2.0.0"), null, true }, + { Operator.semVerLessThan, LDValue.of("2.0.0-rc"), LDValue.of("2.0.0-rc.beta"), null, true }, + { Operator.semVerGreaterThan, LDValue.of("2.0.1"), LDValue.of("2.0.0"), null, true }, + { Operator.semVerGreaterThan, LDValue.of("2.0.1"), LDValue.of("2.0"), null, true }, + { Operator.semVerGreaterThan, LDValue.of("2.0.0"), LDValue.of("2.0.1"), null, false }, + { Operator.semVerGreaterThan, LDValue.of("2.0"), LDValue.of("2.0.1"), null, false }, + { Operator.semVerGreaterThan, LDValue.of("2.0.0-rc.1"), LDValue.of("2.0.0-rc.0"), null, true }, + { Operator.semVerLessThan, LDValue.of("2.0.1"), invalidVer, null, false }, + { Operator.semVerGreaterThan, LDValue.of("2.0.1"), invalidVer, null, false } }); } @Test public void parameterizedTestComparison() { - assertEquals(shouldBe, EvaluatorOperators.apply(op, aValue, bValue)); + List values = new ArrayList<>(5); + if (extraClauseValues != null) { + values.addAll(Arrays.asList(extraClauseValues)); + } + values.add(clauseValue); + + Clause clause1 = new Clause(userAttr, op, values, false); + assertEquals("without preprocessing", shouldBe, Evaluator.clauseMatchAny(clause1, userValue)); + + Clause clause2 = new Clause(userAttr, op, values, false); + EvaluatorPreprocessing.preprocessClause(clause2); + assertEquals("without preprocessing", shouldBe, Evaluator.clauseMatchAny(clause2, userValue)); } } diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorOperatorsTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorOperatorsTest.java deleted file mode 100644 index 5a94e7b74..000000000 --- a/src/test/java/com/launchdarkly/sdk/server/EvaluatorOperatorsTest.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.launchdarkly.sdk.server; - -import com.launchdarkly.sdk.LDValue; -import com.launchdarkly.sdk.server.DataModel; -import com.launchdarkly.sdk.server.EvaluatorOperators; - -import org.junit.Test; - -import java.util.regex.PatternSyntaxException; - -import static org.junit.Assert.assertFalse; - -// Any special-case tests that can't be handled by EvaluatorOperatorsParameterizedTest. -@SuppressWarnings("javadoc") -public class EvaluatorOperatorsTest { - // This is probably not desired behavior, but it is the current behavior - @Test(expected = PatternSyntaxException.class) - public void testInvalidRegexThrowsException() { - assertFalse(EvaluatorOperators.apply(DataModel.Operator.matches, LDValue.of("hello world"), LDValue.of("***not a regex"))); - } -} diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorPreprocessingTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorPreprocessingTest.java new file mode 100644 index 000000000..d93fc58b0 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorPreprocessingTest.java @@ -0,0 +1,227 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.server.DataModel.Clause; +import com.launchdarkly.sdk.server.DataModel.FeatureFlag; +import com.launchdarkly.sdk.server.DataModel.Operator; +import com.launchdarkly.sdk.server.DataModel.Rule; +import com.launchdarkly.sdk.server.DataModel.Segment; +import com.launchdarkly.sdk.server.DataModel.SegmentRule; + +import org.junit.Test; + +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +@SuppressWarnings("javadoc") +public class EvaluatorPreprocessingTest { + // We deliberately use the data model constructors here instead of the more convenient ModelBuilders + // equivalents, to make sure we're testing the afterDeserialization() behavior and not just the builder. + + private FeatureFlag flagFromClause(Clause c) { + return new FeatureFlag("key", 0, false, null, null, null, rulesFromClause(c), + null, null, null, false, false, false, null, false); + } + + private List rulesFromClause(Clause c) { + return ImmutableList.of(new Rule("", ImmutableList.of(c), null, null, false)); + } + + @Test + public void preprocessFlagCreatesClauseValuesMapForMultiValueEqualityTest() { + Clause c = new Clause( + UserAttribute.forName("x"), + Operator.in, + ImmutableList.of(LDValue.of("a"), LDValue.of(0)), + false + ); + + FeatureFlag f = flagFromClause(c); + assertNull(f.getRules().get(0).getClauses().get(0).getPreprocessed()); + + f.afterDeserialized(); + + EvaluatorPreprocessing.ClauseExtra ce = f.getRules().get(0).getClauses().get(0).getPreprocessed(); + assertNotNull(ce); + assertEquals(ImmutableSet.of(LDValue.of("a"), LDValue.of(0)), ce.valuesSet); + } + + @Test + public void preprocessFlagDoesNotCreateClauseValuesMapForSingleValueEqualityTest() { + Clause c = new Clause( + UserAttribute.forName("x"), + Operator.in, + ImmutableList.of(LDValue.of("a")), + false + ); + + FeatureFlag f = flagFromClause(c); + assertNull(f.getRules().get(0).getClauses().get(0).getPreprocessed()); + + f.afterDeserialized(); + + assertNull(f.getRules().get(0).getClauses().get(0).getPreprocessed()); + } + + @Test + public void preprocessFlagDoesNotCreateClauseValuesMapForEmptyEqualityTest() { + Clause c = new Clause( + UserAttribute.forName("x"), + Operator.in, + ImmutableList.of(), + false + ); + + FeatureFlag f = flagFromClause(c); + assertNull(f.getRules().get(0).getClauses().get(0).getPreprocessed()); + + f.afterDeserialized(); + + assertNull(f.getRules().get(0).getClauses().get(0).getPreprocessed()); + } + + @Test + public void preprocessFlagDoesNotCreateClauseValuesMapForNonEqualityOperators() { + for (Operator op: Operator.values()) { + if (op == Operator.in) { + continue; + } + Clause c = new Clause( + UserAttribute.forName("x"), + op, + ImmutableList.of(LDValue.of("a"), LDValue.of("b")), + false + ); + // The values & types aren't very important here because we won't actually evaluate the clause; all that + // matters is that there's more than one of them, so that it *would* build a map if the operator were "in" + + FeatureFlag f = flagFromClause(c); + assertNull(op.name(), f.getRules().get(0).getClauses().get(0).getPreprocessed()); + + f.afterDeserialized(); + + EvaluatorPreprocessing.ClauseExtra ce = f.getRules().get(0).getClauses().get(0).getPreprocessed(); + // this might be non-null if we preprocessed the values list, but there should still not be a valuesSet + if (ce != null) { + assertNull(ce.valuesSet); + } + } + } + + @Test + public void preprocessFlagParsesClauseDate() { + String time1Str = "2016-04-16T17:09:12-07:00"; + ZonedDateTime time1 = ZonedDateTime.parse(time1Str); + int time2Num = 1000000; + ZonedDateTime time2 = ZonedDateTime.ofInstant(Instant.ofEpochMilli(time2Num), ZoneOffset.UTC); + + for (Operator op: new Operator[] { Operator.after, Operator.before }) { + Clause c = new Clause( + UserAttribute.forName("x"), + op, + ImmutableList.of(LDValue.of(time1Str), LDValue.of(time2Num), LDValue.of("x"), LDValue.of(false)), + false + ); + + FeatureFlag f = flagFromClause(c); + assertNull(f.getRules().get(0).getClauses().get(0).getPreprocessed()); + + f.afterDeserialized(); + + EvaluatorPreprocessing.ClauseExtra ce = f.getRules().get(0).getClauses().get(0).getPreprocessed(); + assertNotNull(op.name(), ce); + assertNotNull(op.name(), ce.valuesExtra); + assertEquals(op.name(), 4, ce.valuesExtra.size()); + assertEquals(op.name(), time1, ce.valuesExtra.get(0).parsedDate); + assertEquals(op.name(), time2, ce.valuesExtra.get(1).parsedDate); + assertNull(op.name(), ce.valuesExtra.get(2).parsedDate); + assertNull(op.name(), ce.valuesExtra.get(3).parsedDate); + } + } + + @Test + public void preprocessFlagParsesClauseRegex() { + Clause c = new Clause( + UserAttribute.forName("x"), + Operator.matches, + ImmutableList.of(LDValue.of("x*"), LDValue.of("***not a regex"), LDValue.of(3)), + false + ); + + FeatureFlag f = flagFromClause(c); + assertNull(f.getRules().get(0).getClauses().get(0).getPreprocessed()); + + f.afterDeserialized(); + + EvaluatorPreprocessing.ClauseExtra ce = f.getRules().get(0).getClauses().get(0).getPreprocessed(); + assertNotNull(ce); + assertNotNull(ce.valuesExtra); + assertEquals(3, ce.valuesExtra.size()); + assertNotNull(ce.valuesExtra.get(0).parsedRegex); + assertEquals("x*", ce.valuesExtra.get(0).parsedRegex.toString()); // Pattern doesn't support equals() + assertNull(ce.valuesExtra.get(1).parsedRegex); + assertNull(ce.valuesExtra.get(2).parsedRegex); + } + + + @Test + public void preprocessFlagParsesClauseSemVer() { + SemanticVersion expected = EvaluatorTypeConversion.valueToSemVer(LDValue.of("1.2.3")); + assertNotNull(expected); + + for (Operator op: new Operator[] { Operator.semVerEqual, Operator.semVerGreaterThan, Operator.semVerLessThan }) { + Clause c = new Clause( + UserAttribute.forName("x"), + op, + ImmutableList.of(LDValue.of("1.2.3"), LDValue.of("x"), LDValue.of(false)), + false + ); + + FeatureFlag f = flagFromClause(c); + assertNull(f.getRules().get(0).getClauses().get(0).getPreprocessed()); + + f.afterDeserialized(); + + EvaluatorPreprocessing.ClauseExtra ce = f.getRules().get(0).getClauses().get(0).getPreprocessed(); + assertNotNull(op.name(), ce); + assertNotNull(op.name(), ce.valuesExtra); + assertEquals(op.name(), 3, ce.valuesExtra.size()); + assertNotNull(op.name(), ce.valuesExtra.get(0).parsedSemVer); + assertEquals(op.name(), 0, ce.valuesExtra.get(0).parsedSemVer.compareTo(expected)); // SemanticVersion doesn't support equals() + assertNull(op.name(), ce.valuesExtra.get(1).parsedSemVer); + assertNull(op.name(), ce.valuesExtra.get(2).parsedSemVer); + } + } + + @Test + public void preprocessSegmentPreprocessesClausesInRules() { + // We'll just check one kind of clause, and assume that the preprocessing works the same as in flag rules + Clause c = new Clause( + UserAttribute.forName("x"), + Operator.matches, + ImmutableList.of(LDValue.of("x*")), + false + ); + SegmentRule rule = new SegmentRule(ImmutableList.of(c), null, null); + Segment s = new Segment("key", null, null, null, ImmutableList.of(rule), 0, false); + + assertNull(s.getRules().get(0).getClauses().get(0).getPreprocessed()); + + s.afterDeserialized(); + + EvaluatorPreprocessing.ClauseExtra ce = s.getRules().get(0).getClauses().get(0).getPreprocessed(); + assertNotNull(ce.valuesExtra); + assertEquals(1, ce.valuesExtra.size()); + assertNotNull(ce.valuesExtra.get(0).parsedRegex); + assertEquals("x*", ce.valuesExtra.get(0).parsedRegex.toString()); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/ModelBuilders.java b/src/test/java/com/launchdarkly/sdk/server/ModelBuilders.java index 33f6ed218..37dca814e 100644 --- a/src/test/java/com/launchdarkly/sdk/server/ModelBuilders.java +++ b/src/test/java/com/launchdarkly/sdk/server/ModelBuilders.java @@ -6,6 +6,7 @@ import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.UserAttribute; import com.launchdarkly.sdk.server.DataModel.FeatureFlag; +import com.launchdarkly.sdk.server.DataModel.Segment; import java.util.ArrayList; import java.util.Arrays; @@ -283,7 +284,9 @@ private SegmentBuilder(DataModel.Segment from) { } public DataModel.Segment build() { - return new DataModel.Segment(key, included, excluded, salt, rules, version, deleted); + Segment s = new DataModel.Segment(key, included, excluded, salt, rules, version, deleted); + s.afterDeserialized(); + return s; } public SegmentBuilder included(String... included) { From d97a5bc262774e4f76c7e15be613bb67ac42cda2 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 22 May 2020 13:32:33 -0700 Subject: [PATCH 449/673] (5.0) don't bother creating event objects if they won't be sent --- .../launchdarkly/sdk/server/Components.java | 6 ++ .../launchdarkly/sdk/server/EventFactory.java | 56 +++++++++++++++---- .../com/launchdarkly/sdk/server/LDClient.java | 38 ++++++++----- .../sdk/server/EventOutputTest.java | 6 +- .../sdk/server/EventSummarizerTest.java | 10 +--- 5 files changed, 79 insertions(+), 37 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/Components.java b/src/main/java/com/launchdarkly/sdk/server/Components.java index b61203327..4561ee4f9 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Components.java +++ b/src/main/java/com/launchdarkly/sdk/server/Components.java @@ -141,6 +141,12 @@ public static EventProcessorFactory noEvents() { return NULL_EVENT_PROCESSOR_FACTORY; } + // package-private method for verifying that the given EventProcessorFactory is the same kind that is + // returned by noEvents() - we can use reference equality here because we know we're using a static instance + static boolean isNullImplementation(EventProcessorFactory f) { + return f == NULL_EVENT_PROCESSOR_FACTORY; + } + /** * Returns a configurable factory for using streaming mode to get feature flag data. *

    diff --git a/src/main/java/com/launchdarkly/sdk/server/EventFactory.java b/src/main/java/com/launchdarkly/sdk/server/EventFactory.java index 39a9e1346..d37e461c2 100644 --- a/src/main/java/com/launchdarkly/sdk/server/EventFactory.java +++ b/src/main/java/com/launchdarkly/sdk/server/EventFactory.java @@ -9,11 +9,20 @@ abstract class EventFactory { public static final EventFactory DEFAULT = new DefaultEventFactory(false); public static final EventFactory DEFAULT_WITH_REASONS = new DefaultEventFactory(true); + protected final boolean disabled; + protected final boolean includeReasons; protected abstract long getTimestamp(); - protected abstract boolean isIncludeReasons(); + + protected EventFactory(boolean disabled, boolean includeReasons) { + this.disabled = disabled; + this.includeReasons = includeReasons; + } public Event.FeatureRequest newFeatureRequestEvent(DataModel.FeatureFlag flag, LDUser user, LDValue value, int variationIndex, EvaluationReason reason, LDValue defaultValue, String prereqOf) { + if (disabled) { + return null; + } boolean requireExperimentData = isExperiment(flag, reason); return new Event.FeatureRequest( getTimestamp(), @@ -23,7 +32,7 @@ public Event.FeatureRequest newFeatureRequestEvent(DataModel.FeatureFlag flag, L variationIndex, value, defaultValue, - (requireExperimentData || isIncludeReasons()) ? reason : null, + (requireExperimentData || includeReasons) ? reason : null, prereqOf, requireExperimentData || flag.isTrackEvents(), flag.getDebugEventsUntilDate() == null ? 0 : flag.getDebugEventsUntilDate().longValue(), @@ -32,6 +41,9 @@ public Event.FeatureRequest newFeatureRequestEvent(DataModel.FeatureFlag flag, L } public Event.FeatureRequest newFeatureRequestEvent(DataModel.FeatureFlag flag, LDUser user, Evaluator.EvalResult result, LDValue defaultVal) { + if (disabled) { + return null; + } return newFeatureRequestEvent(flag, user, result == null ? null : result.getValue(), result == null ? -1 : result.getVariationIndex(), result == null ? null : result.getReason(), defaultVal, null); @@ -39,6 +51,9 @@ public Event.FeatureRequest newFeatureRequestEvent(DataModel.FeatureFlag flag, L public Event.FeatureRequest newDefaultFeatureRequestEvent(DataModel.FeatureFlag flag, LDUser user, LDValue defaultValue, EvaluationReason.ErrorKind errorKind) { + if (disabled) { + return null; + } return new Event.FeatureRequest( getTimestamp(), flag.getKey(), @@ -47,7 +62,7 @@ public Event.FeatureRequest newDefaultFeatureRequestEvent(DataModel.FeatureFlag -1, defaultValue, defaultValue, - isIncludeReasons() ? EvaluationReason.error(errorKind) : null, + includeReasons ? EvaluationReason.error(errorKind) : null, null, flag.isTrackEvents(), flag.getDebugEventsUntilDate() == null ? 0 : flag.getDebugEventsUntilDate().longValue(), @@ -57,6 +72,9 @@ public Event.FeatureRequest newDefaultFeatureRequestEvent(DataModel.FeatureFlag public Event.FeatureRequest newUnknownFeatureRequestEvent(String key, LDUser user, LDValue defaultValue, EvaluationReason.ErrorKind errorKind) { + if (disabled) { + return null; + } return new Event.FeatureRequest( getTimestamp(), key, @@ -65,7 +83,7 @@ public Event.FeatureRequest newUnknownFeatureRequestEvent(String key, LDUser use -1, defaultValue, defaultValue, - isIncludeReasons() ? EvaluationReason.error(errorKind) : null, + includeReasons ? EvaluationReason.error(errorKind) : null, null, false, 0, @@ -75,6 +93,9 @@ public Event.FeatureRequest newUnknownFeatureRequestEvent(String key, LDUser use public Event.FeatureRequest newPrerequisiteFeatureRequestEvent(DataModel.FeatureFlag prereqFlag, LDUser user, Evaluator.EvalResult details, DataModel.FeatureFlag prereqOf) { + if (disabled) { + return null; + } return newFeatureRequestEvent( prereqFlag, user, @@ -87,16 +108,25 @@ public Event.FeatureRequest newPrerequisiteFeatureRequestEvent(DataModel.Feature } public Event.FeatureRequest newDebugEvent(Event.FeatureRequest from) { + if (disabled) { + return null; + } return new Event.FeatureRequest( from.getCreationDate(), from.getKey(), from.getUser(), from.getVersion(), from.getVariation(), from.getValue(), from.getDefaultVal(), from.getReason(), from.getPrereqOf(), from.isTrackEvents(), from.getDebugEventsUntilDate(), true); } public Event.Custom newCustomEvent(String key, LDUser user, LDValue data, Double metricValue) { + if (disabled) { + return null; + } return new Event.Custom(getTimestamp(), key, user, data, metricValue); } public Event.Identify newIdentifyEvent(LDUser user) { + if (disabled) { + return null; + } return new Event.Identify(getTimestamp(), user); } @@ -123,21 +153,27 @@ private boolean isExperiment(DataModel.FeatureFlag flag, EvaluationReason reason } } - public static class DefaultEventFactory extends EventFactory { - private final boolean includeReasons; - + static final class DefaultEventFactory extends EventFactory { public DefaultEventFactory(boolean includeReasons) { - this.includeReasons = includeReasons; + super(false, includeReasons); } @Override protected long getTimestamp() { return System.currentTimeMillis(); } + } + + static final class DisabledEventFactory extends EventFactory { + static final DisabledEventFactory INSTANCE = new DisabledEventFactory(); + + private DisabledEventFactory() { + super(true, false); + } @Override - protected boolean isIncludeReasons() { - return includeReasons; + protected long getTimestamp() { + return 0; } } } diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClient.java b/src/main/java/com/launchdarkly/sdk/server/LDClient.java index d797ddfd2..a4a2f9146 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClient.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClient.java @@ -73,6 +73,8 @@ public final class LDClient implements LDClientInterface { private final FlagTrackerImpl flagTracker; private final EventBroadcasterImpl flagChangeBroadcaster; private final ScheduledExecutorService sharedExecutor; + private final EventFactory eventFactoryDefault; + private final EventFactory eventFactoryWithReasons; /** * Creates a new client instance that connects to LaunchDarkly with the default configuration. @@ -158,7 +160,15 @@ public LDClient(String sdkKey, LDConfig config) { final EventProcessorFactory epFactory = config.eventProcessorFactory == null ? Components.sendEvents() : config.eventProcessorFactory; - + boolean eventsDisabled = Components.isNullImplementation(epFactory); + if (eventsDisabled) { + this.eventFactoryDefault = EventFactory.DisabledEventFactory.INSTANCE; + this.eventFactoryWithReasons = EventFactory.DisabledEventFactory.INSTANCE; + } else { + this.eventFactoryDefault = EventFactory.DEFAULT; + this.eventFactoryWithReasons = EventFactory.DEFAULT_WITH_REASONS; + } + if (config.httpConfig.getProxy() != null) { if (config.httpConfig.getProxyAuthentication() != null) { logger.info("Using proxy: {} with authentication.", config.httpConfig.getProxy()); @@ -252,7 +262,7 @@ public void trackData(String eventName, LDUser user, LDValue 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, null)); + eventProcessor.sendEvent(eventFactoryDefault.newCustomEvent(eventName, user, data, null)); } } @@ -261,7 +271,7 @@ public void trackMetric(String eventName, LDUser user, LDValue data, double metr 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)); + eventProcessor.sendEvent(eventFactoryDefault.newCustomEvent(eventName, user, data, metricValue)); } } @@ -270,14 +280,16 @@ 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(eventFactoryDefault.newIdentifyEvent(user)); } } private void sendFlagRequestEvent(Event.FeatureRequest event) { - eventProcessor.sendEvent(event); + if (event != null) { + eventProcessor.sendEvent(event); + } } - + @Override public FeatureFlagsState allFlagsState(LDUser user, FlagsStateOption... options) { FeatureFlagsState.Builder builder = new FeatureFlagsState.Builder(options); @@ -311,7 +323,7 @@ public FeatureFlagsState allFlagsState(LDUser user, FlagsStateOption... options) continue; } try { - Evaluator.EvalResult result = evaluator.evaluate(flag, user, EventFactory.DEFAULT); + Evaluator.EvalResult result = evaluator.evaluate(flag, user, eventFactoryDefault); builder.addFlag(flag, result); } catch (Exception e) { logger.error("Exception caught for feature flag \"{}\" when evaluating all flags: {}", entry.getKey(), e.toString()); @@ -350,7 +362,7 @@ public LDValue jsonValueVariation(String featureKey, LDUser user, LDValue defaul @Override public EvaluationDetail boolVariationDetail(String featureKey, LDUser user, boolean defaultValue) { Evaluator.EvalResult result = evaluateInternal(featureKey, user, LDValue.of(defaultValue), true, - EventFactory.DEFAULT_WITH_REASONS); + eventFactoryWithReasons); return EvaluationDetail.fromValue(result.getValue().booleanValue(), result.getVariationIndex(), result.getReason()); } @@ -358,7 +370,7 @@ public EvaluationDetail boolVariationDetail(String featureKey, LDUser u @Override public EvaluationDetail intVariationDetail(String featureKey, LDUser user, int defaultValue) { Evaluator.EvalResult result = evaluateInternal(featureKey, user, LDValue.of(defaultValue), true, - EventFactory.DEFAULT_WITH_REASONS); + eventFactoryWithReasons); return EvaluationDetail.fromValue(result.getValue().intValue(), result.getVariationIndex(), result.getReason()); } @@ -366,7 +378,7 @@ public EvaluationDetail intVariationDetail(String featureKey, LDUser us @Override public EvaluationDetail doubleVariationDetail(String featureKey, LDUser user, double defaultValue) { Evaluator.EvalResult result = evaluateInternal(featureKey, user, LDValue.of(defaultValue), true, - EventFactory.DEFAULT_WITH_REASONS); + eventFactoryWithReasons); return EvaluationDetail.fromValue(result.getValue().doubleValue(), result.getVariationIndex(), result.getReason()); } @@ -374,14 +386,14 @@ public EvaluationDetail doubleVariationDetail(String featureKey, LDUser @Override public EvaluationDetail stringVariationDetail(String featureKey, LDUser user, String defaultValue) { Evaluator.EvalResult result = evaluateInternal(featureKey, user, LDValue.of(defaultValue), true, - EventFactory.DEFAULT_WITH_REASONS); + eventFactoryWithReasons); return EvaluationDetail.fromValue(result.getValue().stringValue(), result.getVariationIndex(), result.getReason()); } @Override public EvaluationDetail jsonValueVariationDetail(String featureKey, LDUser user, LDValue defaultValue) { - Evaluator.EvalResult result = evaluateInternal(featureKey, user, LDValue.normalize(defaultValue), false, EventFactory.DEFAULT_WITH_REASONS); + Evaluator.EvalResult result = evaluateInternal(featureKey, user, LDValue.normalize(defaultValue), false, eventFactoryWithReasons); return EvaluationDetail.fromValue(result.getValue(), result.getVariationIndex(), result.getReason()); } @@ -409,7 +421,7 @@ public boolean isFlagKnown(String featureKey) { } private LDValue evaluate(String featureKey, LDUser user, LDValue defaultValue, boolean checkType) { - return evaluateInternal(featureKey, user, defaultValue, checkType, EventFactory.DEFAULT).getValue(); + return evaluateInternal(featureKey, user, defaultValue, checkType, eventFactoryDefault).getValue(); } private Evaluator.EvalResult errorResult(EvaluationReason.ErrorKind errorKind, final LDValue defaultValue) { diff --git a/src/test/java/com/launchdarkly/sdk/server/EventOutputTest.java b/src/test/java/com/launchdarkly/sdk/server/EventOutputTest.java index c254855e2..952eb814e 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EventOutputTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EventOutputTest.java @@ -371,14 +371,10 @@ private static LDValue parseValue(String json) { } private EventFactory eventFactoryWithTimestamp(final long timestamp, final boolean includeReasons) { - return new EventFactory() { + return new EventFactory(false, includeReasons) { protected long getTimestamp() { return timestamp; } - - protected boolean isIncludeReasons() { - return includeReasons; - } }; } diff --git a/src/test/java/com/launchdarkly/sdk/server/EventSummarizerTest.java b/src/test/java/com/launchdarkly/sdk/server/EventSummarizerTest.java index c8294de9d..3587851ac 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EventSummarizerTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EventSummarizerTest.java @@ -3,9 +3,6 @@ import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; -import com.launchdarkly.sdk.server.DataModel; -import com.launchdarkly.sdk.server.EventFactory; -import com.launchdarkly.sdk.server.EventSummarizer; import com.launchdarkly.sdk.server.interfaces.Event; import org.junit.Test; @@ -24,16 +21,11 @@ public class EventSummarizerTest { private static final LDUser user = new LDUser.Builder("key").build(); private long eventTimestamp; - private EventFactory eventFactory = new EventFactory() { + private EventFactory eventFactory = new EventFactory(false, false) { @Override protected long getTimestamp() { return eventTimestamp; } - - @Override - protected boolean isIncludeReasons() { - return false; - } }; @Test From b56d5418bab21e4bef3e5f8cd030bdc690bdd5aa Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 22 May 2020 15:25:32 -0700 Subject: [PATCH 450/673] cleaner EventFactory design --- .../sdk/server/DefaultEventProcessor.java | 2 +- .../launchdarkly/sdk/server/EventFactory.java | 277 ++++++++++-------- .../com/launchdarkly/sdk/server/LDClient.java | 4 +- .../sdk/server/EventOutputTest.java | 8 +- .../sdk/server/EventSummarizerTest.java | 7 +- 5 files changed, 164 insertions(+), 134 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java index acaa2571f..153ce9b34 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java @@ -379,7 +379,7 @@ private void processEvent(Event e, SimpleLRUCache userKeys, Even Event.FeatureRequest fe = (Event.FeatureRequest)e; addFullEvent = fe.isTrackEvents(); if (shouldDebugEvent(fe)) { - debugEvent = EventFactory.DEFAULT.newDebugEvent(fe); + debugEvent = EventFactory.newDebugEvent(fe); } } else { addFullEvent = true; diff --git a/src/main/java/com/launchdarkly/sdk/server/EventFactory.java b/src/main/java/com/launchdarkly/sdk/server/EventFactory.java index d37e461c2..0b5b1aa7c 100644 --- a/src/main/java/com/launchdarkly/sdk/server/EventFactory.java +++ b/src/main/java/com/launchdarkly/sdk/server/EventFactory.java @@ -1,101 +1,82 @@ package com.launchdarkly.sdk.server; import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.EvaluationReason.ErrorKind; import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.DataModel.FeatureFlag; import com.launchdarkly.sdk.server.interfaces.Event; +import com.launchdarkly.sdk.server.interfaces.Event.Custom; +import com.launchdarkly.sdk.server.interfaces.Event.FeatureRequest; +import com.launchdarkly.sdk.server.interfaces.Event.Identify; + +import java.util.function.Supplier; abstract class EventFactory { - public static final EventFactory DEFAULT = new DefaultEventFactory(false); - public static final EventFactory DEFAULT_WITH_REASONS = new DefaultEventFactory(true); + public static final EventFactory DEFAULT = new Default(false, null); + public static final EventFactory DEFAULT_WITH_REASONS = new Default(true, null); - protected final boolean disabled; - protected final boolean includeReasons; - protected abstract long getTimestamp(); + abstract Event.FeatureRequest newFeatureRequestEvent( + DataModel.FeatureFlag flag, + LDUser user, + LDValue value, + int variationIndex, + EvaluationReason reason, + LDValue defaultValue, + String prereqOf + ); + + abstract Event.FeatureRequest newUnknownFeatureRequestEvent( + String key, + LDUser user, + LDValue defaultValue, + EvaluationReason.ErrorKind errorKind + ); - protected EventFactory(boolean disabled, boolean includeReasons) { - this.disabled = disabled; - this.includeReasons = includeReasons; - } + abstract Event.Custom newCustomEvent(String key, LDUser user, LDValue data, Double metricValue); - public Event.FeatureRequest newFeatureRequestEvent(DataModel.FeatureFlag flag, LDUser user, LDValue value, - int variationIndex, EvaluationReason reason, LDValue defaultValue, String prereqOf) { - if (disabled) { - return null; - } - boolean requireExperimentData = isExperiment(flag, reason); - return new Event.FeatureRequest( - getTimestamp(), - flag.getKey(), - user, - flag.getVersion(), - variationIndex, - value, - defaultValue, - (requireExperimentData || includeReasons) ? reason : null, - prereqOf, - requireExperimentData || flag.isTrackEvents(), - flag.getDebugEventsUntilDate() == null ? 0 : flag.getDebugEventsUntilDate().longValue(), - false - ); - } + abstract Event.Identify newIdentifyEvent(LDUser user); - public Event.FeatureRequest newFeatureRequestEvent(DataModel.FeatureFlag flag, LDUser user, Evaluator.EvalResult result, LDValue defaultVal) { - if (disabled) { - return null; - } - return newFeatureRequestEvent(flag, user, result == null ? null : result.getValue(), - result == null ? -1 : result.getVariationIndex(), result == null ? null : result.getReason(), - defaultVal, null); - } - - public Event.FeatureRequest newDefaultFeatureRequestEvent(DataModel.FeatureFlag flag, LDUser user, LDValue defaultValue, - EvaluationReason.ErrorKind errorKind) { - if (disabled) { - return null; - } - return new Event.FeatureRequest( - getTimestamp(), - flag.getKey(), + final Event.FeatureRequest newFeatureRequestEvent( + DataModel.FeatureFlag flag, + LDUser user, + Evaluator.EvalResult details, + LDValue defaultValue + ) { + return newFeatureRequestEvent( + flag, user, - flag.getVersion(), - -1, - defaultValue, + details == null ? null : details.getValue(), + details == null ? -1 : details.getVariationIndex(), + details == null ? null : details.getReason(), defaultValue, - includeReasons ? EvaluationReason.error(errorKind) : null, - null, - flag.isTrackEvents(), - flag.getDebugEventsUntilDate() == null ? 0 : flag.getDebugEventsUntilDate().longValue(), - false - ); + null + ); } - - public Event.FeatureRequest newUnknownFeatureRequestEvent(String key, LDUser user, LDValue defaultValue, - EvaluationReason.ErrorKind errorKind) { - if (disabled) { - return null; - } - return new Event.FeatureRequest( - getTimestamp(), - key, + + final Event.FeatureRequest newDefaultFeatureRequestEvent( + DataModel.FeatureFlag flag, + LDUser user, + LDValue defaultVal, + EvaluationReason.ErrorKind errorKind + ) { + return newFeatureRequestEvent( + flag, user, + defaultVal, -1, - -1, - defaultValue, - defaultValue, - includeReasons ? EvaluationReason.error(errorKind) : null, - null, - false, - 0, - false - ); + EvaluationReason.error(errorKind), + defaultVal, + null + ); } - public Event.FeatureRequest newPrerequisiteFeatureRequestEvent(DataModel.FeatureFlag prereqFlag, LDUser user, - Evaluator.EvalResult details, DataModel.FeatureFlag prereqOf) { - if (disabled) { - return null; - } + final Event.FeatureRequest newPrerequisiteFeatureRequestEvent( + DataModel.FeatureFlag prereqFlag, + LDUser user, + Evaluator.EvalResult details, + DataModel.FeatureFlag prereqOf + ) { return newFeatureRequestEvent( prereqFlag, user, @@ -106,31 +87,113 @@ public Event.FeatureRequest newPrerequisiteFeatureRequestEvent(DataModel.Feature prereqOf.getKey() ); } - - public Event.FeatureRequest newDebugEvent(Event.FeatureRequest from) { - if (disabled) { - return null; - } + + static final Event.FeatureRequest newDebugEvent(Event.FeatureRequest from) { return new Event.FeatureRequest( - from.getCreationDate(), from.getKey(), from.getUser(), from.getVersion(), from.getVariation(), from.getValue(), - from.getDefaultVal(), from.getReason(), from.getPrereqOf(), from.isTrackEvents(), from.getDebugEventsUntilDate(), true); + from.getCreationDate(), + from.getKey(), + from.getUser(), + from.getVersion(), + from.getVariation(), + from.getValue(), + from.getDefaultVal(), + from.getReason(), + from.getPrereqOf(), + from.isTrackEvents(), + from.getDebugEventsUntilDate(), + true + ); } - public Event.Custom newCustomEvent(String key, LDUser user, LDValue data, Double metricValue) { - if (disabled) { - return null; + static class Default extends EventFactory { + private final boolean includeReasons; + private final Supplier timestampFn; + + Default(boolean includeReasons, Supplier timestampFn) { + this.includeReasons = includeReasons; + this.timestampFn = timestampFn != null ? timestampFn : (() -> System.currentTimeMillis()); } - return new Event.Custom(getTimestamp(), key, user, data, metricValue); - } - public Event.Identify newIdentifyEvent(LDUser user) { - if (disabled) { + @Override + final Event.FeatureRequest newFeatureRequestEvent(DataModel.FeatureFlag flag, LDUser user, LDValue value, + int variationIndex, EvaluationReason reason, LDValue defaultValue, String prereqOf){ + boolean requireExperimentData = isExperiment(flag, reason); + return new Event.FeatureRequest( + timestampFn.get(), + flag.getKey(), + user, + flag.getVersion(), + variationIndex, + value, + defaultValue, + (requireExperimentData || includeReasons) ? reason : null, + prereqOf, + requireExperimentData || flag.isTrackEvents(), + flag.getDebugEventsUntilDate() == null ? 0 : flag.getDebugEventsUntilDate().longValue(), + false + ); + } + + @Override + final Event.FeatureRequest newUnknownFeatureRequestEvent( + String key, + LDUser user, + LDValue defaultValue, + EvaluationReason.ErrorKind errorKind + ) { + return new Event.FeatureRequest( + timestampFn.get(), + key, + user, + -1, + -1, + defaultValue, + defaultValue, + includeReasons ? EvaluationReason.error(errorKind) : null, + null, + false, + 0, + false + ); + } + + @Override + Event.Custom newCustomEvent(String key, LDUser user, LDValue data, Double metricValue) { + return new Event.Custom(timestampFn.get(), key, user, data, metricValue); + } + + @Override + Event.Identify newIdentifyEvent(LDUser user) { + return new Event.Identify(timestampFn.get(), user); + } + } + + static final class Disabled extends EventFactory { + static final Disabled INSTANCE = new Disabled(); + + @Override + final FeatureRequest newFeatureRequestEvent(FeatureFlag flag, LDUser user, LDValue value, int variationIndex, + EvaluationReason reason, LDValue defaultValue, String prereqOf) { + return null; + } + + @Override + final FeatureRequest newUnknownFeatureRequestEvent(String key, LDUser user, LDValue defaultValue, ErrorKind errorKind) { + return null; + } + + @Override + final Custom newCustomEvent(String key, LDUser user, LDValue data, Double metricValue) { + return null; + } + + @Override + final Identify newIdentifyEvent(LDUser user) { return null; } - return new Event.Identify(getTimestamp(), user); } - private boolean isExperiment(DataModel.FeatureFlag flag, EvaluationReason reason) { + private static boolean isExperiment(DataModel.FeatureFlag flag, EvaluationReason reason) { if (reason == null) { // doesn't happen in real life, but possible in testing return false; @@ -152,28 +215,4 @@ private boolean isExperiment(DataModel.FeatureFlag flag, EvaluationReason reason return false; } } - - static final class DefaultEventFactory extends EventFactory { - public DefaultEventFactory(boolean includeReasons) { - super(false, includeReasons); - } - - @Override - protected long getTimestamp() { - return System.currentTimeMillis(); - } - } - - static final class DisabledEventFactory extends EventFactory { - static final DisabledEventFactory INSTANCE = new DisabledEventFactory(); - - private DisabledEventFactory() { - super(true, false); - } - - @Override - protected long getTimestamp() { - return 0; - } - } } diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClient.java b/src/main/java/com/launchdarkly/sdk/server/LDClient.java index a4a2f9146..5ec07ee16 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClient.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClient.java @@ -162,8 +162,8 @@ public LDClient(String sdkKey, LDConfig config) { Components.sendEvents() : config.eventProcessorFactory; boolean eventsDisabled = Components.isNullImplementation(epFactory); if (eventsDisabled) { - this.eventFactoryDefault = EventFactory.DisabledEventFactory.INSTANCE; - this.eventFactoryWithReasons = EventFactory.DisabledEventFactory.INSTANCE; + this.eventFactoryDefault = EventFactory.Disabled.INSTANCE; + this.eventFactoryWithReasons = EventFactory.Disabled.INSTANCE; } else { this.eventFactoryDefault = EventFactory.DEFAULT; this.eventFactoryWithReasons = EventFactory.DEFAULT_WITH_REASONS; diff --git a/src/test/java/com/launchdarkly/sdk/server/EventOutputTest.java b/src/test/java/com/launchdarkly/sdk/server/EventOutputTest.java index 952eb814e..18b0041d1 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EventOutputTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EventOutputTest.java @@ -231,7 +231,7 @@ public void featureEventIsSerialized() throws Exception { "}"); assertEquals(feJson4, getSingleOutputEvent(f, feUnknownFlag)); - Event.FeatureRequest debugEvent = factory.newDebugEvent(feWithVariation); + Event.FeatureRequest debugEvent = EventFactory.newDebugEvent(feWithVariation); LDValue feJson5 = parseValue("{" + "\"kind\":\"debug\"," + "\"creationDate\":100000," + @@ -371,11 +371,7 @@ private static LDValue parseValue(String json) { } private EventFactory eventFactoryWithTimestamp(final long timestamp, final boolean includeReasons) { - return new EventFactory(false, includeReasons) { - protected long getTimestamp() { - return timestamp; - } - }; + return new EventFactory.Default(includeReasons, () -> timestamp); } private LDValue getSingleOutputEvent(EventOutputFormatter f, Event event) throws IOException { diff --git a/src/test/java/com/launchdarkly/sdk/server/EventSummarizerTest.java b/src/test/java/com/launchdarkly/sdk/server/EventSummarizerTest.java index 3587851ac..b423c2929 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EventSummarizerTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EventSummarizerTest.java @@ -21,12 +21,7 @@ public class EventSummarizerTest { private static final LDUser user = new LDUser.Builder("key").build(); private long eventTimestamp; - private EventFactory eventFactory = new EventFactory(false, false) { - @Override - protected long getTimestamp() { - return eventTimestamp; - } - }; + private EventFactory eventFactory = new EventFactory.Default(false, () -> eventTimestamp); @Test public void summarizeEventDoesNothingForIdentifyEvent() { From 7515cf840121b69bb7f3b89cd91dcd42c11430ef Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 22 May 2020 16:24:42 -0700 Subject: [PATCH 451/673] add test coverage step to CI --- .circleci/config.yml | 15 +++++++++++++++ build.gradle | 9 +++++++++ 2 files changed, 24 insertions(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index 449edfca5..c13a5879b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -25,6 +25,7 @@ workflows: - test-linux: name: Java 11 - Linux - OpenJDK docker-image: circleci/openjdk:11 + with-coverage: true requires: - build-linux - packaging: @@ -52,6 +53,9 @@ jobs: parameters: docker-image: type: string + with-coverage: + type: boolean + default: false docker: - image: <> steps: @@ -61,6 +65,12 @@ jobs: at: build - run: java -version - run: ./gradlew test + - when: + condition: <> + steps: + - run: + name: Generate test coverage report + command: ./gradlew jacocoTestReport - run: name: Save test results command: | @@ -71,6 +81,11 @@ jobs: path: ~/junit - store_artifacts: path: ~/junit + - when: + condition: <> + steps: + - store_artifacts: + path: build/reports/jacoco build-test-windows: executor: diff --git a/build.gradle b/build.gradle index ca071191e..92bd5e8c8 100644 --- a/build.gradle +++ b/build.gradle @@ -17,6 +17,7 @@ plugins { id "java" id "java-library" id "checkstyle" + id "jacoco" id "signing" id "com.github.johnrengelman.shadow" version "5.2.0" id "maven-publish" @@ -415,6 +416,14 @@ test { } } +jacocoTestReport { // code coverage report + reports { + xml.enabled + csv.enabled true + html.enabled true + } +} + idea { module { downloadJavadoc = true From 61323748fe5101d3f3fd3d973e71dd63676ecd1d Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 22 May 2020 16:39:06 -0700 Subject: [PATCH 452/673] add test coverage reporting + improve DefaultEventProcessor tests --- CONTRIBUTING.md | 10 + .../sdk/server/DefaultEventProcessor.java | 25 +- .../DefaultEventProcessorDiagnosticsTest.java | 171 ++++ .../DefaultEventProcessorOutputTest.java | 410 +++++++++ .../sdk/server/DefaultEventProcessorTest.java | 788 ++++-------------- .../server/DefaultEventProcessorTestBase.java | 208 +++++ .../sdk/server/LDClientListenersTest.java | 6 +- 7 files changed, 964 insertions(+), 654 deletions(-) create mode 100644 src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorDiagnosticsTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorOutputTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTestBase.java diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 229d7cad7..68fb3fc21 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,3 +42,13 @@ To build the SDK and run all unit tests: ``` ./gradlew test ``` + +## Code coverage + +It is important to keep unit test coverage as close to 100% as possible in this project. + +Sometimes a gap in coverage is unavoidable, usually because the compiler requires us to provide a code path for some condition that in practice can't happen and can't be tested, or because of a known issue with the code coverage tool. Please handle all such cases as follows: + +* Mark the code with an explanatory comment beginning with "COVERAGE:". + +The current coverage report can be observed by running `./gradlew jacocoTestReport` and viewing `build/reports/jacoco/test/html/index.html`. This file is also produced as an artifact of the CircleCI build for the most recent Java version. diff --git a/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java index 153ce9b34..68ad3c521 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java @@ -22,6 +22,7 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.Semaphore; +import java.util.concurrent.SynchronousQueue; import java.util.concurrent.ThreadFactory; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -178,11 +179,12 @@ void waitForCompletion() { } } - @Override - public String toString() { // for debugging only - return ((event == null) ? type.toString() : (type + ": " + event.getClass().getSimpleName())) + - (reply == null ? "" : " (sync)"); - } +// intentionally commented out so this doesn't affect coverage reports when we're not debugging +// @Override +// public String toString() { // for debugging only +// return ((event == null) ? type.toString() : (type + ": " + event.getClass().getSimpleName())) + +// (reply == null ? "" : " (sync)"); +// } } /** @@ -224,10 +226,11 @@ private EventDispatcher( .setPriority(threadPriority) .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. - final BlockingQueue payloadQueue = new ArrayBlockingQueue<>(1); + // A SynchronousQueue is not exactly a queue - it represents an opportunity to transfer a + // single value from the producer to whichever consumer has asked for it first. We will + // never allow the producer (EventDispatcher) to block on this: if we want to push a + // payload and all of the workers are busy so no one is waiting, we'll cancel the flush. + final SynchronousQueue payloadQueue = new SynchronousQueue<>(); final EventBuffer outbox = new EventBuffer(eventsConfig.capacity); final SimpleLRUCache userKeys = new SimpleLRUCache(eventsConfig.userKeysCapacity); @@ -286,7 +289,7 @@ public void uncaughtException(Thread t, Throwable e) { */ private void runMainLoop(BlockingQueue inbox, EventBuffer outbox, SimpleLRUCache userKeys, - BlockingQueue payloadQueue) { + SynchronousQueue payloadQueue) { List batch = new ArrayList(MESSAGE_BATCH_SIZE); while (true) { try { @@ -436,7 +439,7 @@ private boolean shouldDebugEvent(Event.FeatureRequest fe) { return false; } - private void triggerFlush(EventBuffer outbox, BlockingQueue payloadQueue) { + private void triggerFlush(EventBuffer outbox, SynchronousQueue payloadQueue) { if (disabled.get() || outbox.isEmpty()) { return; } diff --git a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorDiagnosticsTest.java b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorDiagnosticsTest.java new file mode 100644 index 000000000..358562d97 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorDiagnosticsTest.java @@ -0,0 +1,171 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.collect.ImmutableSet; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.interfaces.Event; +import com.launchdarkly.sdk.server.interfaces.EventSender; + +import org.junit.Test; + +import java.net.URI; +import java.time.Duration; + +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.TestComponents.sharedExecutor; +import static com.launchdarkly.sdk.server.TestUtil.simpleEvaluation; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.samePropertyValuesAs; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +/** + * These DefaultEventProcessor tests cover diagnostic event behavior. + */ +@SuppressWarnings("javadoc") +public class DefaultEventProcessorDiagnosticsTest extends DefaultEventProcessorTestBase { + @Test + public void diagnosticEventsSentToDiagnosticEndpoint() throws Exception { + 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 { + 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 { + 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(); + DataModel.FeatureFlag flag1 = flagBuilder("flagkey1").version(11).trackEvents(true).build(); + DataModel.FeatureFlag flag2 = flagBuilder("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()); + + 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(); + + ep.sendEvent(fe1); + ep.sendEvent(fe2); + ep.flush(); + // Ignore normal events + 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.deduplicatedUsers, equalTo(1L)); + assertThat(statsEvent.eventsInLastBatch, equalTo(3L)); + assertThat(statsEvent.droppedEvents, equalTo(0L)); + } + } + + @Test + public void periodicDiagnosticEventsAreSentAutomatically() throws Exception { + // This test overrides the diagnostic recording interval to a small value and verifies that we see + // at least one periodic event without having to force a send via ep.postDiagnostic(). + MockEventSender es = new MockEventSender(); + DiagnosticId diagnosticId = new DiagnosticId(SDK_KEY); + DiagnosticEvent.Init initEvent = new DiagnosticEvent.Init(0, diagnosticId, LDConfig.DEFAULT); + DiagnosticAccumulator diagnosticAccumulator = new DiagnosticAccumulator(diagnosticId); + Duration briefPeriodicInterval = Duration.ofMillis(50); + + // Can't use the regular config builder for this, because it will enforce a minimum flush interval + EventsConfiguration eventsConfig = new EventsConfiguration( + false, + 100, + es, + FAKE_URI, + Duration.ofSeconds(5), + true, + ImmutableSet.of(), + 100, + Duration.ofSeconds(5), + briefPeriodicInterval + ); + try (DefaultEventProcessor ep = new DefaultEventProcessor(eventsConfig, sharedExecutor, Thread.MAX_PRIORITY, + diagnosticAccumulator, initEvent)) { + // Ignore the initial diagnostic event + es.awaitRequest(); + + MockEventSender.Params periodicReq = es.awaitRequest(); + + assertNotNull(periodicReq); + DiagnosticEvent.Statistics statsEvent = gson.fromJson(periodicReq.data, DiagnosticEvent.Statistics.class); + assertEquals("diagnostic", statsEvent.kind); + } + } + + @Test + 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); + + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es).baseURI(uri), diagnosticAccumulator)) { + } + + MockEventSender.Params p = es.awaitRequest(); + assertThat(p.eventsBaseUri, equalTo(uri)); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorOutputTest.java b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorOutputTest.java new file mode 100644 index 000000000..6cfd4f4fa --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorOutputTest.java @@ -0,0 +1,410 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.interfaces.Event; +import com.launchdarkly.sdk.server.interfaces.EventSender; + +import org.hamcrest.Matchers; +import org.junit.Test; + +import java.util.Date; + +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.TestUtil.simpleEvaluation; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.contains; + +/** + * These DefaultEventProcessor tests cover the specific content that should appear in event payloads. + */ +@SuppressWarnings("javadoc") +public class DefaultEventProcessorOutputTest extends DefaultEventProcessorTestBase { + @Test + public void identifyEventIsQueued() throws Exception { + MockEventSender es = new MockEventSender(); + Event e = EventFactory.DEFAULT.newIdentifyEvent(user); + + 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 (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(); + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); + Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, + simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); + + 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(); + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); + Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, + simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); + + 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(); + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); + Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, + simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); + + 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(); + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); + Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, + simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); + + 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(); + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); + EvaluationReason reason = EvaluationReason.ruleMatch(1, null); + Event.FeatureRequest fe = EventFactory.DEFAULT_WITH_REASONS.newFeatureRequestEvent(flag, user, + new Evaluator.EvalResult(LDValue.of("value"), 1, reason), LDValue.ofNull()); + + 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(); + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(false).build(); + Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, + simpleEvaluation(1, LDValue.of("value")), null); + + 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; + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).debugEventsUntilDate(futureTime).build(); + Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, + simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); + + 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; + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true) + .debugEventsUntilDate(futureTime).build(); + Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, + simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); + + 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; + es.result = new EventSender.Result(true, false, new Date(serverTime)); + + long debugUntil = serverTime + 1000; + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).debugEventsUntilDate(debugUntil).build(); + Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, + simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); + + 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.getCreationDate(), fe.getCreationDate()) + )); + } + + @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; + es.result = new EventSender.Result(true, false, new Date(serverTime)); + + long debugUntil = serverTime - 1000; + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).debugEventsUntilDate(debugUntil).build(); + Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, + simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); + + 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 + + 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.getCreationDate(), fe.getCreationDate()) + )); + } + + @SuppressWarnings("unchecked") + @Test + public void twoFeatureEventsForSameUserGenerateOnlyOneIndexEvent() throws Exception { + MockEventSender es = new MockEventSender(); + DataModel.FeatureFlag flag1 = flagBuilder("flagkey1").version(11).trackEvents(true).build(); + DataModel.FeatureFlag flag2 = flagBuilder("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 (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.getCreationDate(), fe2.getCreationDate()) + )); + } + + @SuppressWarnings("unchecked") + @Test + public void identifyEventMakesIndexEventUnnecessary() throws Exception { + MockEventSender es = new MockEventSender(); + Event ie = EventFactory.DEFAULT.newIdentifyEvent(user); + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); + Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, + simpleEvaluation(1, LDValue.of("value")), null); + + 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(); + DataModel.FeatureFlag flag1 = flagBuilder("flagkey1").version(11).build(); + DataModel.FeatureFlag flag2 = flagBuilder("flagkey2").version(22).build(); + LDValue value1 = LDValue.of("value1"); + LDValue value2 = LDValue.of("value2"); + LDValue default1 = LDValue.of("default1"); + LDValue default2 = LDValue.of("default2"); + 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, value2), default2); + + 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.getCreationDate(), fe2.getCreationDate()), + 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 (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 (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 (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es) + .inlineUsersInEvents(true).allAttributesPrivate(true))) { + ep.sendEvent(ce); + } + + assertThat(es.getEventsFromLastRequest(), contains(isCustomEvent(ce, filteredUserJson))); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java index 01beea18d..600e6110a 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java @@ -1,79 +1,38 @@ package com.launchdarkly.sdk.server; import com.google.common.collect.ImmutableSet; -import com.google.gson.Gson; -import com.launchdarkly.sdk.EvaluationReason; -import com.launchdarkly.sdk.LDUser; +import com.google.common.collect.Iterables; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.UserAttribute; import com.launchdarkly.sdk.server.integrations.EventProcessorBuilder; import com.launchdarkly.sdk.server.interfaces.Event; import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; import com.launchdarkly.sdk.server.interfaces.EventSender; -import com.launchdarkly.sdk.server.interfaces.EventSenderFactory; -import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; -import org.hamcrest.Matcher; import org.hamcrest.Matchers; import org.junit.Test; -import java.io.IOException; import java.net.URI; import java.time.Duration; -import java.util.Date; -import java.util.concurrent.BlockingQueue; -import java.util.concurrent.LinkedBlockingQueue; -import java.util.concurrent.TimeUnit; -import static com.launchdarkly.sdk.server.Components.sendEvents; import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; import static com.launchdarkly.sdk.server.TestComponents.clientContext; -import static com.launchdarkly.sdk.server.TestUtil.hasJsonProperty; -import static com.launchdarkly.sdk.server.TestUtil.isJsonArray; +import static com.launchdarkly.sdk.server.TestComponents.sharedExecutor; import static com.launchdarkly.sdk.server.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.instanceOf; import static org.hamcrest.Matchers.is; 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.fail; +/** + * These tests cover all of the basic DefaultEventProcessor behavior that is not covered by + * DefaultEventProcessorOutputTest or DefaultEventProcessorDiagnosticTest. + */ @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(); - private static final Gson gson = new Gson(); - private static final LDValue userJson = LDValue.buildObject().put("key", "userkey").put("name", "Red").build(); - private static final LDValue filteredUserJson = LDValue.buildObject().put("key", "userkey") - .put("privateAttrs", LDValue.buildArray().add("name").build()).build(); - 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(MockEventSender es) { - return sendEvents().eventSender(senderFactory(es)); - } - - private DefaultEventProcessor makeEventProcessor(EventProcessorBuilder ec) { - return makeEventProcessor(ec, baseLDConfig); - } - - private DefaultEventProcessor makeEventProcessor(EventProcessorBuilder ec, LDConfig config) { - return (DefaultEventProcessor)ec.createEventProcessor(clientContext(SDK_KEY, config)); - } - - private DefaultEventProcessor makeEventProcessor(EventProcessorBuilder ec, DiagnosticAccumulator diagnosticAccumulator) { - return (DefaultEventProcessor)ec.createEventProcessor( - clientContext(SDK_KEY, diagLDConfig, diagnosticAccumulator)); - } - +public class DefaultEventProcessorTest extends DefaultEventProcessorTestBase { @Test public void builderHasDefaultConfiguration() throws Exception { EventProcessorFactory epf = Components.sendEvents(); @@ -94,11 +53,10 @@ 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) + .baseURI(FAKE_URI) .capacity(3333) .diagnosticRecordingInterval(Duration.ofSeconds(480)) .eventSender(senderFactory(es)) @@ -111,8 +69,8 @@ public void builderCanSpecifyConfiguration() throws Exception { assertThat(ec.allAttributesPrivate, is(true)); assertThat(ec.capacity, equalTo(3333)); assertThat(ec.diagnosticRecordingInterval, equalTo(Duration.ofSeconds(480))); -assertThat(ec.eventSender, sameInstance((EventSender)es)); - assertThat(ec.eventsUri, equalTo(uri)); + assertThat(ec.eventSender, sameInstance((EventSender)es)); + assertThat(ec.eventsUri, equalTo(FAKE_URI)); assertThat(ec.flushInterval, equalTo(Duration.ofSeconds(99))); assertThat(ec.inlineUsersInEvents, is(false)); // will test this separately below assertThat(ec.privateAttributes, equalTo(ImmutableSet.of(UserAttribute.NAME, UserAttribute.forName("dogs")))); @@ -129,351 +87,47 @@ public void builderCanSpecifyConfiguration() throws Exception { } } - @Test - public void identifyEventIsQueued() throws Exception { - MockEventSender es = new MockEventSender(); - Event e = EventFactory.DEFAULT.newIdentifyEvent(user); - - 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 (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(); - DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); - Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); - - 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(); - DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); - Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); - - 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(); - DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); - Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); - - 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(); - DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); - Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); - - 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(); - DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); - EvaluationReason reason = EvaluationReason.ruleMatch(1, null); - Event.FeatureRequest fe = EventFactory.DEFAULT_WITH_REASONS.newFeatureRequestEvent(flag, user, - new Evaluator.EvalResult(LDValue.of("value"), 1, reason), LDValue.ofNull()); - - 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(); - DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(false).build(); - Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - simpleEvaluation(1, LDValue.of("value")), null); - - 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; - DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).debugEventsUntilDate(futureTime).build(); - Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); - - 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; - DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true) - .debugEventsUntilDate(futureTime).build(); - Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); - - 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 { + public void eventsAreFlushedAutomatically() throws Exception { MockEventSender es = new MockEventSender(); + Duration briefFlushInterval = Duration.ofMillis(50); - // Pick a server time that is somewhat behind the client time - long serverTime = System.currentTimeMillis() - 20000; - es.result = new EventSender.Result(true, false, new Date(serverTime)); - - long debugUntil = serverTime + 1000; - DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).debugEventsUntilDate(debugUntil).build(); - Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); - - 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 + // Can't use the regular config builder for this, because it will enforce a minimum flush interval + EventsConfiguration eventsConfig = new EventsConfiguration( + false, + 100, + es, + FAKE_URI, + briefFlushInterval, + true, + ImmutableSet.of(), + 100, + Duration.ofSeconds(5), + null + ); + try (DefaultEventProcessor ep = new DefaultEventProcessor(eventsConfig, sharedExecutor, Thread.MAX_PRIORITY, null, null)) { + Event.Custom event1 = EventFactory.DEFAULT.newCustomEvent("event1", user, null, null); + Event.Custom event2 = EventFactory.DEFAULT.newCustomEvent("event2", user, null, null); + ep.sendEvent(event1); + ep.sendEvent(event2); - 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.getCreationDate(), fe.getCreationDate()) - )); - } - - @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; - es.result = new EventSender.Result(true, false, new Date(serverTime)); - - long debugUntil = serverTime - 1000; - DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).debugEventsUntilDate(debugUntil).build(); - Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); - - 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 + // getEventsFromLastRequest will block until the MockEventSender receives a payload - we expect + // both events to be in one payload, but if some unusual delay happened in between the two + // sendEvent calls, they might be in two + Iterable payload1 = es.getEventsFromLastRequest(); + if (Iterables.size(payload1) == 1) { + assertThat(payload1, contains(isCustomEvent(event1, userJson))); + assertThat(es.getEventsFromLastRequest(), contains(isCustomEvent(event2, userJson))); + } else { + assertThat(payload1, contains(isCustomEvent(event1, userJson), isCustomEvent(event2, userJson))); + } - 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.getCreationDate(), fe.getCreationDate()) - )); - } - - @SuppressWarnings("unchecked") - @Test - public void twoFeatureEventsForSameUserGenerateOnlyOneIndexEvent() throws Exception { - MockEventSender es = new MockEventSender(); - DataModel.FeatureFlag flag1 = flagBuilder("flagkey1").version(11).trackEvents(true).build(); - DataModel.FeatureFlag flag2 = flagBuilder("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 (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { - ep.sendEvent(fe1); - ep.sendEvent(fe2); + Event.Custom event3 = EventFactory.DEFAULT.newCustomEvent("event3", user, null, null); + ep.sendEvent(event3); + assertThat(es.getEventsFromLastRequest(), contains(isCustomEvent(event3, userJson))); } - assertThat(es.getEventsFromLastRequest(), contains( - isIndexEvent(fe1, userJson), - isFeatureEvent(fe1, flag1, false, null), - isFeatureEvent(fe2, flag2, false, null), - isSummaryEvent(fe1.getCreationDate(), fe2.getCreationDate()) - )); - } - - @SuppressWarnings("unchecked") - @Test - public void identifyEventMakesIndexEventUnnecessary() throws Exception { - MockEventSender es = new MockEventSender(); - Event ie = EventFactory.DEFAULT.newIdentifyEvent(user); - DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); - Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - simpleEvaluation(1, LDValue.of("value")), null); - - 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(); - DataModel.FeatureFlag flag1 = flagBuilder("flagkey1").version(11).build(); - DataModel.FeatureFlag flag2 = flagBuilder("flagkey2").version(22).build(); - LDValue value1 = LDValue.of("value1"); - LDValue value2 = LDValue.of("value2"); - LDValue default1 = LDValue.of("default1"); - LDValue default2 = LDValue.of("default2"); - 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, value2), default2); - - 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.getCreationDate(), fe2.getCreationDate()), - 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); @@ -488,33 +142,6 @@ public void customEventIsQueuedWithUser() throws Exception { )); } - @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 (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 (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(); @@ -536,101 +163,57 @@ public void nothingIsSentIfThereAreNoEvents() throws Exception { assertEquals(0, es.receivedParams.size()); } + @SuppressWarnings("unchecked") @Test - public void diagnosticEventsSentToDiagnosticEndpoint() throws Exception { - 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 { - 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 { - 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 { + public void userKeysAreFlushedAutomatically() throws Exception { + // This test overrides the user key flush interval to a small value and verifies that a new + // index event is generated for a user after the user keys have been flushed. MockEventSender es = new MockEventSender(); - DataModel.FeatureFlag flag1 = flagBuilder("flagkey1").version(11).trackEvents(true).build(); - DataModel.FeatureFlag flag2 = flagBuilder("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()); - - 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(); - - ep.sendEvent(fe1); - ep.sendEvent(fe2); + Duration briefUserKeyFlushInterval = Duration.ofMillis(60); + + // Can't use the regular config builder for this, because it will enforce a minimum flush interval + EventsConfiguration eventsConfig = new EventsConfiguration( + false, + 100, + es, + FAKE_URI, + Duration.ofSeconds(5), + false, // do not inline users in events + ImmutableSet.of(), + 100, + briefUserKeyFlushInterval, + null + ); + try (DefaultEventProcessor ep = new DefaultEventProcessor(eventsConfig, sharedExecutor, Thread.MAX_PRIORITY, + null, null)) { + Event.Custom event1 = EventFactory.DEFAULT.newCustomEvent("event1", user, null, null); + Event.Custom event2 = EventFactory.DEFAULT.newCustomEvent("event2", user, null, null); + ep.sendEvent(event1); + ep.sendEvent(event2); + + // We're relying on the user key flush not happening in between event1 and event2, so we should get + // a single index event for the user. ep.flush(); - // Ignore normal events - 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.deduplicatedUsers, equalTo(1L)); - assertThat(statsEvent.eventsInLastBatch, equalTo(3L)); - assertThat(statsEvent.droppedEvents, equalTo(0L)); + assertThat(es.getEventsFromLastRequest(), contains( + isIndexEvent(event1, userJson), + isCustomEvent(event1, null), + isCustomEvent(event2, null) + )); + + // Now wait long enough for the user key cache to be flushed + Thread.sleep(briefUserKeyFlushInterval.toMillis() * 2); + + // Referencing the same user in a new even should produce a new index event + Event.Custom event3 = EventFactory.DEFAULT.newCustomEvent("event3", user, null, null); + ep.sendEvent(event3); + ep.flush(); + assertThat(es.getEventsFromLastRequest(), contains( + isIndexEvent(event3, userJson), + isCustomEvent(event3, null) + )); } } - + @Test public void eventSenderIsClosedWithEventProcessor() throws Exception { MockEventSender es = new MockEventSender(); @@ -655,150 +238,79 @@ public void customBaseUriIsPassedToEventSenderForAnalyticsEvents() throws Except } @Test - public void customBaseUriIsPassedToEventSenderForDiagnosticEvents() throws Exception { + public void eventCapacityIsEnforced() throws Exception { + int capacity = 10; MockEventSender es = new MockEventSender(); - URI uri = URI.create("fake-uri"); - DiagnosticId diagnosticId = new DiagnosticId(SDK_KEY); - DiagnosticAccumulator diagnosticAccumulator = new DiagnosticAccumulator(diagnosticId); - - try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es).baseURI(uri), diagnosticAccumulator)) { + EventProcessorBuilder config = baseConfig(es).capacity(capacity); + + try (DefaultEventProcessor ep = makeEventProcessor(config)) { + for (int i = 0; i < capacity + 2; i++) { + ep.sendEvent(EventFactory.DEFAULT.newIdentifyEvent(user)); + } + ep.flush(); + assertThat(es.getEventsFromLastRequest(), Matchers.iterableWithSize(capacity)); } - - MockEventSender.Params p = es.awaitRequest(); - assertThat(p.eventsBaseUri, equalTo(uri)); } - private static EventSenderFactory senderFactory(final MockEventSender es) { - return new EventSenderFactory() { - @Override - public EventSender createEventSender(String sdkKey, HttpConfiguration httpConfiguration) { - return es; + @Test + public void eventCapacityDoesNotPreventSummaryEventFromBeingSent() throws Exception { + int capacity = 10; + MockEventSender es = new MockEventSender(); + EventProcessorBuilder config = baseConfig(es).capacity(capacity).inlineUsersInEvents(true); + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); + + try (DefaultEventProcessor ep = makeEventProcessor(config)) { + for (int i = 0; i < capacity; i++) { + Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, + simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); + ep.sendEvent(fe); + + // Using such a tiny buffer means there's also a tiny inbox queue, so we'll add a slight + // delay to keep EventDispatcher from being overwhelmed + Thread.sleep(1); } - }; + + ep.flush(); + Iterable eventsReceived = es.getEventsFromLastRequest(); + + assertThat(eventsReceived, Matchers.iterableWithSize(capacity + 1)); + assertThat(Iterables.get(eventsReceived, capacity), isSummaryEvent()); + } } - private static final class MockEventSender implements EventSender { - volatile boolean closed; - volatile Result result = new Result(true, false, null); - final BlockingQueue receivedParams = new LinkedBlockingQueue<>(); + @Test + public void noMoreEventsAreProcessedAfterUnrecoverableError() throws Exception { + MockEventSender es = new MockEventSender(); + es.result = new EventSender.Result(false, true, null); // mustShutdown == true - static final class Params { - final EventDataKind kind; - final String data; - final int eventCount; - final URI eventsBaseUri; + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { + ep.sendEvent(EventFactory.DEFAULT.newIdentifyEvent(user)); + ep.flush(); + es.awaitRequest(); - Params(EventDataKind kind, String data, int eventCount, URI eventsBaseUri) { - this.kind = kind; - this.data = data; - this.eventCount = eventCount; - assertNotNull(eventsBaseUri); - this.eventsBaseUri = eventsBaseUri; - } - } - - @Override - public void close() throws IOException { - closed = true; + ep.sendEvent(EventFactory.DEFAULT.newIdentifyEvent(user)); + ep.flush(); + es.expectNoRequests(Duration.ofMillis(100)); } + } - @Override - public Result sendEventData(EventDataKind kind, String data, int eventCount, URI eventsBaseUri) { - receivedParams.add(new Params(kind, data, eventCount, eventsBaseUri)); - return result; - } - - Params awaitRequest() throws Exception { - Params p = receivedParams.poll(5, TimeUnit.SECONDS); - if (p == null) { - fail("did not receive event post within 5 seconds"); - } - return p; - } + @Test + public void uncheckedExceptionFromEventSenderDoesNotStopWorkerThread() throws Exception { + MockEventSender es = new MockEventSender(); - Iterable getEventsFromLastRequest() throws Exception { - Params p = awaitRequest(); - LDValue a = LDValue.parse(p.data); - assertEquals(p.eventCount, a.size()); - return a.values(); + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { + es.fakeError = new RuntimeException("sorry"); + + ep.sendEvent(EventFactory.DEFAULT.newIdentifyEvent(user)); + ep.flush(); + es.awaitRequest(); + // MockEventSender now throws an unchecked exception up to EventProcessor's flush worker - + // verify that a subsequent flush still works + + es.fakeError = null; + ep.sendEvent(EventFactory.DEFAULT.newIdentifyEvent(user)); + ep.flush(); + es.awaitRequest(); } } - - private Matcher isIdentifyEvent(Event sourceEvent, LDValue user) { - return allOf( - hasJsonProperty("kind", "identify"), - hasJsonProperty("creationDate", (double)sourceEvent.getCreationDate()), - hasJsonProperty("user", user) - ); - } - - private Matcher isIndexEvent(Event sourceEvent, LDValue user) { - return allOf( - hasJsonProperty("kind", "index"), - hasJsonProperty("creationDate", (double)sourceEvent.getCreationDate()), - hasJsonProperty("user", user) - ); - } - - private Matcher isFeatureEvent(Event.FeatureRequest sourceEvent, DataModel.FeatureFlag flag, boolean debug, LDValue inlineUser) { - return isFeatureEvent(sourceEvent, flag, debug, inlineUser, null); - } - - @SuppressWarnings("unchecked") - private Matcher isFeatureEvent(Event.FeatureRequest sourceEvent, DataModel.FeatureFlag flag, boolean debug, LDValue inlineUser, - EvaluationReason reason) { - return allOf( - hasJsonProperty("kind", debug ? "debug" : "feature"), - hasJsonProperty("creationDate", (double)sourceEvent.getCreationDate()), - hasJsonProperty("key", flag.getKey()), - hasJsonProperty("version", (double)flag.getVersion()), - hasJsonProperty("variation", sourceEvent.getVariation()), - hasJsonProperty("value", sourceEvent.getValue()), - hasJsonProperty("userKey", inlineUser == null ? LDValue.of(sourceEvent.getUser().getKey()) : LDValue.ofNull()), - hasJsonProperty("user", inlineUser == null ? LDValue.ofNull() : inlineUser), - hasJsonProperty("reason", reason == null ? LDValue.ofNull() : LDValue.parse(gson.toJson(reason))) - ); - } - - @SuppressWarnings("unchecked") - private Matcher isCustomEvent(Event.Custom sourceEvent, LDValue inlineUser) { - return allOf( - hasJsonProperty("kind", "custom"), - hasJsonProperty("creationDate", (double)sourceEvent.getCreationDate()), - hasJsonProperty("key", "eventkey"), - hasJsonProperty("userKey", inlineUser == null ? LDValue.of(sourceEvent.getUser().getKey()) : LDValue.ofNull()), - hasJsonProperty("user", inlineUser == null ? LDValue.ofNull() : inlineUser), - hasJsonProperty("data", sourceEvent.getData()), - hasJsonProperty("metricValue", sourceEvent.getMetricValue() == null ? LDValue.ofNull() : LDValue.of(sourceEvent.getMetricValue())) - ); - } - - private Matcher isSummaryEvent() { - return hasJsonProperty("kind", "summary"); - } - - private Matcher isSummaryEvent(long startDate, long endDate) { - return allOf( - hasJsonProperty("kind", "summary"), - hasJsonProperty("startDate", (double)startDate), - hasJsonProperty("endDate", (double)endDate) - ); - } - - private Matcher hasSummaryFlag(String key, LDValue defaultVal, Matcher> counters) { - return hasJsonProperty("features", - hasJsonProperty(key, allOf( - hasJsonProperty("default", defaultVal), - hasJsonProperty("counters", isJsonArray(counters)) - ))); - } - - private Matcher isSummaryEventCounter(DataModel.FeatureFlag flag, Integer variation, LDValue value, int count) { - return allOf( - hasJsonProperty("variation", variation), - hasJsonProperty("version", (double)flag.getVersion()), - hasJsonProperty("value", value), - hasJsonProperty("count", (double)count) - ); - } } diff --git a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTestBase.java b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTestBase.java new file mode 100644 index 000000000..a7eb03c71 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTestBase.java @@ -0,0 +1,208 @@ +package com.launchdarkly.sdk.server; + +import com.google.gson.Gson; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.integrations.EventProcessorBuilder; +import com.launchdarkly.sdk.server.interfaces.Event; +import com.launchdarkly.sdk.server.interfaces.EventSender; +import com.launchdarkly.sdk.server.interfaces.EventSenderFactory; +import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; + +import org.hamcrest.Matcher; + +import java.io.IOException; +import java.net.URI; +import java.time.Duration; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +import static com.launchdarkly.sdk.server.Components.sendEvents; +import static com.launchdarkly.sdk.server.TestComponents.clientContext; +import static com.launchdarkly.sdk.server.TestUtil.hasJsonProperty; +import static com.launchdarkly.sdk.server.TestUtil.isJsonArray; +import static org.hamcrest.Matchers.allOf; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.fail; + +@SuppressWarnings("javadoc") +public abstract class DefaultEventProcessorTestBase { + public static final String SDK_KEY = "SDK_KEY"; + public static final URI FAKE_URI = URI.create("http://fake"); + public static final LDUser user = new LDUser.Builder("userkey").name("Red").build(); + public static final Gson gson = new Gson(); + public static final LDValue userJson = LDValue.buildObject().put("key", "userkey").put("name", "Red").build(); + public static final LDValue filteredUserJson = LDValue.buildObject().put("key", "userkey") + .put("privateAttrs", LDValue.buildArray().add("name").build()).build(); + public static final LDConfig baseLDConfig = new LDConfig.Builder().diagnosticOptOut(true).build(); + public 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. + + public static EventProcessorBuilder baseConfig(MockEventSender es) { + return sendEvents().eventSender(senderFactory(es)); + } + + public static DefaultEventProcessor makeEventProcessor(EventProcessorBuilder ec) { + return makeEventProcessor(ec, baseLDConfig); + } + + public static DefaultEventProcessor makeEventProcessor(EventProcessorBuilder ec, LDConfig config) { + return (DefaultEventProcessor)ec.createEventProcessor(clientContext(SDK_KEY, config)); + } + + public static DefaultEventProcessor makeEventProcessor(EventProcessorBuilder ec, DiagnosticAccumulator diagnosticAccumulator) { + return (DefaultEventProcessor)ec.createEventProcessor( + clientContext(SDK_KEY, diagLDConfig, diagnosticAccumulator)); + } + + public static EventSenderFactory senderFactory(final MockEventSender es) { + return new EventSenderFactory() { + @Override + public EventSender createEventSender(String sdkKey, HttpConfiguration httpConfiguration) { + return es; + } + }; + } + + public static final class MockEventSender implements EventSender { + volatile boolean closed; + volatile Result result = new Result(true, false, null); + volatile RuntimeException fakeError = null; + + final BlockingQueue receivedParams = new LinkedBlockingQueue<>(); + + static final class Params { + final EventDataKind kind; + final String data; + final int eventCount; + final URI eventsBaseUri; + + Params(EventDataKind kind, String data, int eventCount, URI eventsBaseUri) { + this.kind = kind; + this.data = data; + this.eventCount = eventCount; + assertNotNull(eventsBaseUri); + this.eventsBaseUri = eventsBaseUri; + } + } + + @Override + public void close() throws IOException { + closed = true; + } + + @Override + public Result sendEventData(EventDataKind kind, String data, int eventCount, URI eventsBaseUri) { + receivedParams.add(new Params(kind, data, eventCount, eventsBaseUri)); + if (fakeError != null) { + throw fakeError; + } + return result; + } + + Params awaitRequest() throws InterruptedException { + Params p = receivedParams.poll(5, TimeUnit.SECONDS); + if (p == null) { + fail("did not receive event post within 5 seconds"); + } + return p; + } + + void expectNoRequests(Duration timeout) throws InterruptedException { + Params p = receivedParams.poll(timeout.toMillis(), TimeUnit.MILLISECONDS); + if (p != null) { + fail("received unexpected event payload"); + } + } + + Iterable getEventsFromLastRequest() throws InterruptedException { + Params p = awaitRequest(); + LDValue a = LDValue.parse(p.data); + assertEquals(p.eventCount, a.size()); + return a.values(); + } + } + + public static Matcher isIdentifyEvent(Event sourceEvent, LDValue user) { + return allOf( + hasJsonProperty("kind", "identify"), + hasJsonProperty("creationDate", (double)sourceEvent.getCreationDate()), + hasJsonProperty("user", user) + ); + } + + public static Matcher isIndexEvent(Event sourceEvent, LDValue user) { + return allOf( + hasJsonProperty("kind", "index"), + hasJsonProperty("creationDate", (double)sourceEvent.getCreationDate()), + hasJsonProperty("user", user) + ); + } + + public static Matcher isFeatureEvent(Event.FeatureRequest sourceEvent, DataModel.FeatureFlag flag, boolean debug, LDValue inlineUser) { + return isFeatureEvent(sourceEvent, flag, debug, inlineUser, null); + } + + @SuppressWarnings("unchecked") + public static Matcher isFeatureEvent(Event.FeatureRequest sourceEvent, DataModel.FeatureFlag flag, boolean debug, LDValue inlineUser, + EvaluationReason reason) { + return allOf( + hasJsonProperty("kind", debug ? "debug" : "feature"), + hasJsonProperty("creationDate", (double)sourceEvent.getCreationDate()), + hasJsonProperty("key", flag.getKey()), + hasJsonProperty("version", (double)flag.getVersion()), + hasJsonProperty("variation", sourceEvent.getVariation()), + hasJsonProperty("value", sourceEvent.getValue()), + hasJsonProperty("userKey", inlineUser == null ? LDValue.of(sourceEvent.getUser().getKey()) : LDValue.ofNull()), + hasJsonProperty("user", inlineUser == null ? LDValue.ofNull() : inlineUser), + hasJsonProperty("reason", reason == null ? LDValue.ofNull() : LDValue.parse(gson.toJson(reason))) + ); + } + + @SuppressWarnings("unchecked") + public static Matcher isCustomEvent(Event.Custom sourceEvent, LDValue inlineUser) { + return allOf( + hasJsonProperty("kind", "custom"), + hasJsonProperty("creationDate", (double)sourceEvent.getCreationDate()), + hasJsonProperty("key", sourceEvent.getKey()), + hasJsonProperty("userKey", inlineUser == null ? LDValue.of(sourceEvent.getUser().getKey()) : LDValue.ofNull()), + hasJsonProperty("user", inlineUser == null ? LDValue.ofNull() : inlineUser), + hasJsonProperty("data", sourceEvent.getData()), + hasJsonProperty("metricValue", sourceEvent.getMetricValue() == null ? LDValue.ofNull() : LDValue.of(sourceEvent.getMetricValue())) + ); + } + + public static Matcher isSummaryEvent() { + return hasJsonProperty("kind", "summary"); + } + + public static Matcher isSummaryEvent(long startDate, long endDate) { + return allOf( + hasJsonProperty("kind", "summary"), + hasJsonProperty("startDate", (double)startDate), + hasJsonProperty("endDate", (double)endDate) + ); + } + + public static Matcher hasSummaryFlag(String key, LDValue defaultVal, Matcher> counters) { + return hasJsonProperty("features", + hasJsonProperty(key, allOf( + hasJsonProperty("default", defaultVal), + hasJsonProperty("counters", isJsonArray(counters)) + ))); + } + + public static Matcher isSummaryEventCounter(DataModel.FeatureFlag flag, Integer variation, LDValue value, int count) { + return allOf( + hasJsonProperty("variation", variation), + hasJsonProperty("version", (double)flag.getVersion()), + hasJsonProperty("value", value), + hasJsonProperty("count", (double)count) + ); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java index 3d981e28b..2b9934f78 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java @@ -222,13 +222,9 @@ public void dataSourceStatusProviderWaitForStatusSucceeds() throws Exception { try (LDClient client = new LDClient(SDK_KEY, config)) { new Thread(() -> { - System.out.println("in thread"); try { Thread.sleep(100); - } catch (InterruptedException e) { - System.out.println("interrupted"); - } - System.out.println("updating"); + } catch (InterruptedException e) {} updatableSource.dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.VALID, null); }).start(); From 436ec07738025b5517b2abc559ab39eb65d807a4 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 22 May 2020 16:40:23 -0700 Subject: [PATCH 453/673] clarify meaning of "events" in javadoc comment --- .../com/launchdarkly/sdk/server/interfaces/FlagTracker.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagTracker.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagTracker.java index 63440903f..e3627ee7f 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagTracker.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagTracker.java @@ -17,8 +17,8 @@ public interface FlagTracker { *

    * The listener will be notified whenever the SDK receives any change to any feature flag's configuration, * or to a user segment that is referenced by a feature flag. If the updated flag is used as a prerequisite - * for other flags, the SDK assumes that those flags may now behave differently and sends events for them - * as well. + * for other flags, the SDK assumes that those flags may now behave differently and sends flag change events + * for them as well. *

    * Note that this does not necessarily mean the flag's value has changed for any particular user, only that * some part of the flag configuration was changed so that it may return a different value than it From 631d8729a52ec406ff4486d7861555ba5e1e7c17 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 22 May 2020 16:48:30 -0700 Subject: [PATCH 454/673] 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 455/673] 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 456/673] 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 457/673] 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 458/673] 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 035ff29b7f0cad22c02db74b633bd0637b3047ce Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 22 May 2020 17:24:54 -0700 Subject: [PATCH 459/673] fix import --- .../launchdarkly/sdk/server/LDClientEvaluationBenchmarks.java | 1 + 1 file changed, 1 insertion(+) 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 d7457ac75..645aa7667 100644 --- a/benchmarks/src/jmh/java/com/launchdarkly/sdk/server/LDClientEvaluationBenchmarks.java +++ b/benchmarks/src/jmh/java/com/launchdarkly/sdk/server/LDClientEvaluationBenchmarks.java @@ -4,6 +4,7 @@ import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.server.DataModel.FeatureFlag; import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.LDClientInterface; import org.openjdk.jmh.annotations.Benchmark; import org.openjdk.jmh.annotations.Scope; From 4f8273fcba782d5132aa9a8a179522420ca30fef Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 22 May 2020 17:55:04 -0700 Subject: [PATCH 460/673] 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 461/673] 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 462/673] 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 338947c90d1d65d2355f7e8397bdfdb21b546d00 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 22 May 2020 18:21:59 -0700 Subject: [PATCH 463/673] 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 db5dc8c93e2ce9a5979bd28b3b96e96dd369d1ae Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 22 May 2020 18:22:33 -0700 Subject: [PATCH 464/673] 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 a45bcd9d42b2a4ebc73cf12d4e0e57e846c89570 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 22 May 2020 18:26:06 -0700 Subject: [PATCH 465/673] fix event benchmark synchronization --- .../sdk/server/EventProcessorBenchmarks.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 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 33fcaa6e2..4cbdc37ff 100644 --- a/benchmarks/src/jmh/java/com/launchdarkly/sdk/server/EventProcessorBenchmarks.java +++ b/benchmarks/src/jmh/java/com/launchdarkly/sdk/server/EventProcessorBenchmarks.java @@ -39,7 +39,7 @@ public BenchmarkInputs() { eventProcessor = Components.sendEvents() .capacity(EVENT_BUFFER_SIZE) - .eventSender(new MockEventSenderFactory()) + .eventSender(new MockEventSenderFactory(eventSender)) .createEventProcessor(TestComponents.clientContext(TestValues.SDK_KEY, LDConfig.DEFAULT)); basicUser = new LDUser("userkey"); @@ -143,9 +143,15 @@ public void awaitEvents() throws InterruptedException { } private static final class MockEventSenderFactory implements EventSenderFactory { + private final MockEventSender instance; + + MockEventSenderFactory(MockEventSender instance) { + this.instance = instance; + } + @Override public EventSender createEventSender(String arg0, HttpConfiguration arg1) { - return new MockEventSender(); + return instance; } } } From 080c599a11b32f6a6151be63d39a87e71277e5f3 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 22 May 2020 18:32:29 -0700 Subject: [PATCH 466/673] 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 467/673] 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 4832b766fdf5fa66439d19743f99ba4ab47c7014 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 22 May 2020 18:45:35 -0700 Subject: [PATCH 468/673] fix synchronization again --- .../com/launchdarkly/sdk/server/EventProcessorBenchmarks.java | 1 + 1 file changed, 1 insertion(+) 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 4cbdc37ff..db93811c9 100644 --- a/benchmarks/src/jmh/java/com/launchdarkly/sdk/server/EventProcessorBenchmarks.java +++ b/benchmarks/src/jmh/java/com/launchdarkly/sdk/server/EventProcessorBenchmarks.java @@ -134,6 +134,7 @@ public void close() throws IOException {} @Override public Result sendEventData(EventDataKind arg0, String arg1, int arg2, URI arg3) { + counter.countDown(); return RESULT; } From 2bd5a3777326af895dc2c1bf980b9059ec4d21ed Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 22 May 2020 18:56:23 -0700 Subject: [PATCH 469/673] 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 470/673] 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 26575df45811ce0f8df95555a9388070ef913592 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 22 May 2020 21:00:35 -0700 Subject: [PATCH 471/673] CI fix (always store test results) --- .circleci/config.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index c13a5879b..43a1f99e2 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -79,8 +79,10 @@ jobs: when: always - store_test_results: path: ~/junit + when: always - store_artifacts: path: ~/junit + when: always - when: condition: <> steps: From 1986569e49a7bf48c82dda77b8dcf7a1c81b78a3 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 22 May 2020 23:53:30 -0700 Subject: [PATCH 472/673] improve test coverage of low-level eval logic to >99% --- .../launchdarkly/sdk/server/DataModel.java | 12 +- .../launchdarkly/sdk/server/Evaluator.java | 10 +- .../sdk/server/EvaluatorBucketing.java | 2 + .../sdk/server/EvaluatorOperators.java | 4 + .../sdk/server/EvaluatorPreprocessing.java | 39 +-- .../sdk/server/EvaluatorTypeConversion.java | 7 +- .../sdk/server/DataModelTest.java | 83 ++++++ .../sdk/server/EvaluatorBucketingTest.java | 10 + .../sdk/server/EvaluatorClauseTest.java | 155 +++++++++-- .../EvaluatorOperatorsParameterizedTest.java | 22 +- .../sdk/server/EvaluatorRuleTest.java | 75 ++++-- .../sdk/server/EvaluatorTest.java | 254 ++++++++++-------- .../sdk/server/ModelBuilders.java | 12 +- 13 files changed, 475 insertions(+), 210 deletions(-) create mode 100644 src/test/java/com/launchdarkly/sdk/server/DataModelTest.java diff --git a/src/main/java/com/launchdarkly/sdk/server/DataModel.java b/src/main/java/com/launchdarkly/sdk/server/DataModel.java index 64c4563ab..60a1254e6 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DataModel.java +++ b/src/main/java/com/launchdarkly/sdk/server/DataModel.java @@ -13,6 +13,7 @@ import java.util.Set; import static java.util.Collections.emptyList; +import static java.util.Collections.emptySet; /** * Contains information about the internal data model for feature flags and user segments. @@ -153,7 +154,6 @@ boolean isOn() { return on; } - // Guaranteed non-null List getPrerequisites() { return prerequisites == null ? emptyList() : prerequisites; } @@ -321,8 +321,9 @@ Operator getOp() { return op; } + // Guaranteed non-null List getValues() { - return values; + return values == null ? emptyList() : values; } boolean isNegate() { @@ -349,8 +350,9 @@ static final class Rollout { this.bucketBy = bucketBy; } + // Guaranteed non-null List getVariations() { - return variations; + return variations == null ? emptyList() : variations; } UserAttribute getBucketBy() { @@ -430,12 +432,12 @@ public String getKey() { // Guaranteed non-null Collection getIncluded() { - return included == null ? emptyList() : included; + return included == null ? emptySet() : included; } // Guaranteed non-null Collection getExcluded() { - return excluded == null ? emptyList() : excluded; + return excluded == null ? emptySet() : excluded; } String getSalt() { diff --git a/src/main/java/com/launchdarkly/sdk/server/Evaluator.java b/src/main/java/com/launchdarkly/sdk/server/Evaluator.java index f956c37c3..b0127f1b0 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Evaluator.java +++ b/src/main/java/com/launchdarkly/sdk/server/Evaluator.java @@ -109,7 +109,7 @@ private void setPrerequisiteEvents(List prerequisiteEvents * @param flag an existing feature flag; any other referenced flags or segments will be queried via {@link Getters} * @param user the user to evaluate against * @param eventFactory produces feature request events - * @return an {@link EvalResult} + * @return an {@link EvalResult} - guaranteed non-null */ EvalResult evaluate(DataModel.FeatureFlag flag, LDUser user, EventFactory eventFactory) { if (user == null || user.getKey() == null) { @@ -174,9 +174,10 @@ private EvaluationReason checkPrerequisites(DataModel.FeatureFlag flag, LDUser u EvalResult prereqEvalResult = evaluateInternal(prereqFeatureFlag, user, eventFactory, eventsOut); // 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()) { + if (!prereqFeatureFlag.isOn() || prereqEvalResult.getVariationIndex() != prereq.getVariation()) { prereqOk = false; } + // COVERAGE: currently eventsOut is never null because we preallocate the list in evaluate() if there are any prereqs if (eventsOut != null) { eventsOut.add(eventFactory.newPrerequisiteFeatureRequestEvent(prereqFeatureFlag, user, prereqEvalResult, flag)); } @@ -304,10 +305,7 @@ private boolean maybeNegate(DataModel.Clause clause, boolean b) { } private boolean segmentMatchesUser(DataModel.Segment segment, LDUser user) { - String userKey = user.getKey(); - if (userKey == null) { - return false; - } + String userKey = user.getKey(); // we've already verified that the key is non-null at the top of evaluate() if (segment.getIncluded().contains(userKey)) { // getIncluded(), getExcluded(), and getRules() are guaranteed non-null return true; } diff --git a/src/main/java/com/launchdarkly/sdk/server/EvaluatorBucketing.java b/src/main/java/com/launchdarkly/sdk/server/EvaluatorBucketing.java index f45425e2b..98d6be3a9 100644 --- a/src/main/java/com/launchdarkly/sdk/server/EvaluatorBucketing.java +++ b/src/main/java/com/launchdarkly/sdk/server/EvaluatorBucketing.java @@ -10,6 +10,8 @@ * Encapsulates the logic for percentage rollouts. */ abstract class EvaluatorBucketing { + private EvaluatorBucketing() {} + private static final float LONG_SCALE = (float) 0xFFFFFFFFFFFFFFFL; // Attempt to determine the variation index for a given user. Returns null if no index can be computed diff --git a/src/main/java/com/launchdarkly/sdk/server/EvaluatorOperators.java b/src/main/java/com/launchdarkly/sdk/server/EvaluatorOperators.java index b9338dff5..fa9a2cdf2 100644 --- a/src/main/java/com/launchdarkly/sdk/server/EvaluatorOperators.java +++ b/src/main/java/com/launchdarkly/sdk/server/EvaluatorOperators.java @@ -13,6 +13,8 @@ * Defines the behavior of all operators that can be used in feature flag rules and segment rules. */ abstract class EvaluatorOperators { + private EvaluatorOperators() {} + private static enum ComparisonOp { EQ, LT, @@ -33,6 +35,7 @@ boolean test(int delta) { case GTE: return delta >= 0; } + // COVERAGE: the compiler insists on a fallthrough line here, even though it's unreachable return false; } } @@ -95,6 +98,7 @@ static boolean apply( // Evaluator.clauseMatchesUser(). return false; }; + // COVERAGE: the compiler insists on a fallthrough line here, even though it's unreachable return false; } diff --git a/src/main/java/com/launchdarkly/sdk/server/EvaluatorPreprocessing.java b/src/main/java/com/launchdarkly/sdk/server/EvaluatorPreprocessing.java index b94646ce6..6a40c45cc 100644 --- a/src/main/java/com/launchdarkly/sdk/server/EvaluatorPreprocessing.java +++ b/src/main/java/com/launchdarkly/sdk/server/EvaluatorPreprocessing.java @@ -53,27 +53,21 @@ static final class ValueExtra { } static void preprocessFlag(FeatureFlag f) { - if (f.getPrerequisites() != null) { - for (Prerequisite p: f.getPrerequisites()) { - EvaluatorPreprocessing.preprocessPrerequisite(p); - } + for (Prerequisite p: f.getPrerequisites()) { + EvaluatorPreprocessing.preprocessPrerequisite(p); } List rules = f.getRules(); - if (rules != null) { - int n = rules.size(); - for (int i = 0; i < n; i++) { - preprocessFlagRule(rules.get(i), i); - } + int n = rules.size(); + for (int i = 0; i < n; i++) { + preprocessFlagRule(rules.get(i), i); } } static void preprocessSegment(Segment s) { List rules = s.getRules(); - if (rules != null) { - int n = rules.size(); - for (int i = 0; i < n; i++) { - preprocessSegmentRule(rules.get(i), i); - } + int n = rules.size(); + for (int i = 0; i < n; i++) { + preprocessSegmentRule(rules.get(i), i); } } @@ -86,18 +80,14 @@ static void preprocessFlagRule(Rule r, int ruleIndex) { // Precompute an immutable EvaluationReason instance that will be used if a user matches this rule. r.setRuleMatchReason(EvaluationReason.ruleMatch(ruleIndex, r.getId())); - if (r.getClauses() != null) { - for (Clause c: r.getClauses()) { - preprocessClause(c); - } + for (Clause c: r.getClauses()) { + preprocessClause(c); } } static void preprocessSegmentRule(SegmentRule r, int ruleIndex) { - if (r.getClauses() != null) { - for (Clause c: r.getClauses()) { - preprocessClause(c); - } + for (Clause c: r.getClauses()) { + preprocessClause(c); } } @@ -113,7 +103,7 @@ static void preprocessClause(Clause c) { // a linear search. We do not do this for other operators (or if there are fewer than two // values) because the slight extra overhead of a Set is not worthwhile in those case. List values = c.getValues(); - if (values != null && values.size() > 1) { + if (values.size() > 1) { c.setPreprocessed(new ClauseExtra(ImmutableSet.copyOf(values), null)); } break; @@ -144,9 +134,6 @@ private static ClauseExtra preprocessClauseValues( List values, Function f ) { - if (values == null) { - return null; - } List valuesExtra = new ArrayList<>(values.size()); for (LDValue v: values) { valuesExtra.add(f.apply(v)); diff --git a/src/main/java/com/launchdarkly/sdk/server/EvaluatorTypeConversion.java b/src/main/java/com/launchdarkly/sdk/server/EvaluatorTypeConversion.java index 75e440a13..ccc0e12b9 100644 --- a/src/main/java/com/launchdarkly/sdk/server/EvaluatorTypeConversion.java +++ b/src/main/java/com/launchdarkly/sdk/server/EvaluatorTypeConversion.java @@ -12,9 +12,6 @@ abstract class EvaluatorTypeConversion { private EvaluatorTypeConversion() {} static ZonedDateTime valueToDateTime(LDValue value) { - if (value == null) { - return null; - } if (value.isNumber()) { return ZonedDateTime.ofInstant(Instant.ofEpochMilli(value.longValue()), ZoneOffset.UTC); } else if (value.isString()) { @@ -29,7 +26,7 @@ static ZonedDateTime valueToDateTime(LDValue value) { } static Pattern valueToRegex(LDValue value) { - if (value == null || !value.isString()) { + if (!value.isString()) { return null; } try { @@ -40,7 +37,7 @@ static Pattern valueToRegex(LDValue value) { } static SemanticVersion valueToSemVer(LDValue value) { - if (value == null || !value.isString()) { + if (!value.isString()) { return null; } try { diff --git a/src/test/java/com/launchdarkly/sdk/server/DataModelTest.java b/src/test/java/com/launchdarkly/sdk/server/DataModelTest.java new file mode 100644 index 000000000..78a8c71fb --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/DataModelTest.java @@ -0,0 +1,83 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import com.launchdarkly.sdk.server.DataModel.Clause; +import com.launchdarkly.sdk.server.DataModel.FeatureFlag; +import com.launchdarkly.sdk.server.DataModel.Rollout; +import com.launchdarkly.sdk.server.DataModel.Rule; +import com.launchdarkly.sdk.server.DataModel.Segment; +import com.launchdarkly.sdk.server.DataModel.SegmentRule; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +@SuppressWarnings("javadoc") +public class DataModelTest { + @Test + public void flagPrerequisitesListCanNeverBeNull() { + FeatureFlag f = new FeatureFlag("key", 0, false, null, null, null, null, null, null, null, false, false, false, null, false); + assertEquals(ImmutableList.of(), f.getPrerequisites()); + } + + @Test + public void flagTargetsListCanNeverBeNull() { + FeatureFlag f = new FeatureFlag("key", 0, false, null, null, null, null, null, null, null, false, false, false, null, false); + assertEquals(ImmutableList.of(), f.getTargets()); + } + + @Test + public void flagRulesListCanNeverBeNull() { + FeatureFlag f = new FeatureFlag("key", 0, false, null, null, null, null, null, null, null, false, false, false, null, false); + assertEquals(ImmutableList.of(), f.getRules()); + } + + @Test + public void flagVariationsListCanNeverBeNull() { + FeatureFlag f = new FeatureFlag("key", 0, false, null, null, null, null, null, null, null, false, false, false, null, false); + assertEquals(ImmutableList.of(), f.getVariations()); + } + + @Test + public void ruleClausesListCanNeverBeNull() { + Rule r = new Rule("id", null, null, null, false); + assertEquals(ImmutableList.of(), r.getClauses()); + } + + @Test + public void clauseValuesListCanNeverBeNull() { + Clause c = new Clause(null, null, null, false); + assertEquals(ImmutableList.of(), c.getValues()); + } + + @Test + public void segmentIncludedCanNeverBeNull() { + Segment s = new Segment("key", null, null, null, null, 0, false); + assertEquals(ImmutableSet.of(), s.getIncluded()); + } + + @Test + public void segmentExcludedCanNeverBeNull() { + Segment s = new Segment("key", null, null, null, null, 0, false); + assertEquals(ImmutableSet.of(), s.getExcluded()); + } + + @Test + public void segmentRulesListCanNeverBeNull() { + Segment s = new Segment("key", null, null, null, null, 0, false); + assertEquals(ImmutableList.of(), s.getRules()); + } + + @Test + public void segmentRuleClausesListCanNeverBeNull() { + SegmentRule r = new SegmentRule(null, null, null); + assertEquals(ImmutableList.of(), r.getClauses()); + } + + @Test + public void rolloutVariationsListCanNeverBeNull() { + Rollout r = new Rollout(null, null); + assertEquals(ImmutableList.of(), r.getVariations()); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorBucketingTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorBucketingTest.java index 7cafb3705..2e245874d 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EvaluatorBucketingTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorBucketingTest.java @@ -15,6 +15,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.greaterThanOrEqualTo; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; @SuppressWarnings("javadoc") public class EvaluatorBucketingTest { @@ -85,4 +86,13 @@ public void cannotBucketByBooleanAttribute() { float result = EvaluatorBucketing.bucketUser(user, "key", UserAttribute.forName("boolattr"), "salt"); assertEquals(0f, result, Float.MIN_VALUE); } + + @Test + public void userSecondaryKeyAffectsBucketValue() { + LDUser user1 = new LDUser.Builder("key").build(); + LDUser user2 = new LDUser.Builder("key").secondary("other").build(); + float result1 = EvaluatorBucketing.bucketUser(user1, "flagkey", UserAttribute.KEY, "salt"); + float result2 = EvaluatorBucketing.bucketUser(user2, "flagkey", UserAttribute.KEY, "salt"); + assertNotEquals(result1, result2); + } } diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorClauseTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorClauseTest.java index 38b7e8454..c5731de24 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EvaluatorClauseTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorClauseTest.java @@ -24,13 +24,21 @@ @SuppressWarnings("javadoc") public class EvaluatorClauseTest { + private static void assertMatch(Evaluator eval, DataModel.FeatureFlag flag, LDUser user, boolean expectMatch) { + assertEquals(LDValue.of(expectMatch), eval.evaluate(flag, user, EventFactory.DEFAULT).getDetails().getValue()); + } + + private static DataModel.Segment makeSegmentThatMatchesUser(String segmentKey, String userKey) { + return segmentBuilder(segmentKey).included(userKey).build(); + } + @Test public void clauseCanMatchBuiltInAttribute() throws Exception { DataModel.Clause clause = clause(UserAttribute.NAME, DataModel.Operator.in, LDValue.of("Bob")); DataModel.FeatureFlag f = booleanFlagWithClauses("flag", clause); LDUser user = new LDUser.Builder("key").name("Bob").build(); - - assertEquals(LDValue.of(true), BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT).getDetails().getValue()); + + assertMatch(BASE_EVALUATOR, f, user, true); } @Test @@ -39,7 +47,7 @@ public void clauseCanMatchCustomAttribute() throws Exception { DataModel.FeatureFlag f = booleanFlagWithClauses("flag", clause); LDUser user = new LDUser.Builder("key").custom("legs", 4).build(); - assertEquals(LDValue.of(true), BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT).getDetails().getValue()); + assertMatch(BASE_EVALUATOR, f, user, true); } @Test @@ -48,18 +56,107 @@ public void clauseReturnsFalseForMissingAttribute() throws Exception { DataModel.FeatureFlag f = booleanFlagWithClauses("flag", clause); LDUser user = new LDUser.Builder("key").name("Bob").build(); - assertEquals(LDValue.of(false), BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT).getDetails().getValue()); + assertMatch(BASE_EVALUATOR, f, user, false); + } + + @Test + public void clauseMatchesUserValueToAnyOfMultipleValues() throws Exception { + DataModel.Clause clause = clause(UserAttribute.NAME, DataModel.Operator.in, LDValue.of("Bob"), LDValue.of("Carol")); + DataModel.FeatureFlag f = booleanFlagWithClauses("flag", clause); + LDUser user = new LDUser.Builder("key").name("Carol").build(); + + assertMatch(BASE_EVALUATOR, f, user, true); + } + + @Test + public void clauseMatchesUserValueToAnyOfMultipleValuesWithNonEqualityOperator() throws Exception { + // We check this separately because of the special preprocessing logic for equality matches. + DataModel.Clause clause = clause(UserAttribute.NAME, DataModel.Operator.contains, LDValue.of("Bob"), LDValue.of("Carol")); + DataModel.FeatureFlag f = booleanFlagWithClauses("flag", clause); + LDUser user = new LDUser.Builder("key").name("Caroline").build(); + + assertMatch(BASE_EVALUATOR, f, user, true); + } + + @Test + public void clauseMatchesArrayOfUserValuesToClauseValue() throws Exception { + DataModel.Clause clause = clause(UserAttribute.forName("alias"), DataModel.Operator.in, LDValue.of("Maurice")); + DataModel.FeatureFlag f = booleanFlagWithClauses("flag", clause); + LDUser user = new LDUser.Builder("key").custom("alias", + LDValue.buildArray().add("Space Cowboy").add("Maurice").build()).build(); + + assertMatch(BASE_EVALUATOR, f, user, true); + } + + @Test + public void clauseFindsNoMatchInArrayOfUserValues() throws Exception { + DataModel.Clause clause = clause(UserAttribute.forName("alias"), DataModel.Operator.in, LDValue.of("Ma")); + DataModel.FeatureFlag f = booleanFlagWithClauses("flag", clause); + LDUser user = new LDUser.Builder("key").custom("alias", + LDValue.buildArray().add("Mary").add("May").build()).build(); + + assertMatch(BASE_EVALUATOR, f, user, false); + } + + @Test + public void userValueMustNotBeAnArrayOfArrays() throws Exception { + LDValue arrayValue = LDValue.buildArray().add("thing").build(); + LDValue arrayOfArrays = LDValue.buildArray().add(arrayValue).build(); + DataModel.Clause clause = clause(UserAttribute.forName("data"), DataModel.Operator.in, arrayOfArrays); + DataModel.FeatureFlag f = booleanFlagWithClauses("flag", clause); + LDUser user = new LDUser.Builder("key").custom("data", arrayOfArrays).build(); + + assertMatch(BASE_EVALUATOR, f, user, false); + } + + @Test + public void userValueMustNotBeAnObject() throws Exception { + LDValue objectValue = LDValue.buildObject().put("thing", LDValue.of(true)).build(); + DataModel.Clause clause = clause(UserAttribute.forName("data"), DataModel.Operator.in, objectValue); + DataModel.FeatureFlag f = booleanFlagWithClauses("flag", clause); + LDUser user = new LDUser.Builder("key").custom("data", objectValue).build(); + + assertMatch(BASE_EVALUATOR, f, user, false); + } + + @Test + public void userValueMustNotBeAnArrayOfObjects() throws Exception { + LDValue objectValue = LDValue.buildObject().put("thing", LDValue.of(true)).build(); + LDValue arrayOfObjects = LDValue.buildArray().add(objectValue).build(); + DataModel.Clause clause = clause(UserAttribute.forName("data"), DataModel.Operator.in, arrayOfObjects); + DataModel.FeatureFlag f = booleanFlagWithClauses("flag", clause); + LDUser user = new LDUser.Builder("key").custom("data", arrayOfObjects).build(); + + assertMatch(BASE_EVALUATOR, f, user, false); + } + + @Test + public void clauseReturnsFalseForNullOperator() throws Exception { + DataModel.Clause clause = clause(UserAttribute.KEY, null, LDValue.of("key")); + DataModel.FeatureFlag f = booleanFlagWithClauses("flag", clause); + LDUser user = new LDUser("key"); + + assertMatch(BASE_EVALUATOR, f, user, false); } @Test - public void clauseCanBeNegated() throws Exception { - DataModel.Clause clause = clause(UserAttribute.NAME, DataModel.Operator.in, true, LDValue.of("Bob")); + public void clauseCanBeNegatedToReturnFalse() throws Exception { + DataModel.Clause clause = clause(UserAttribute.KEY, DataModel.Operator.in, true, LDValue.of("key")); DataModel.FeatureFlag f = booleanFlagWithClauses("flag", clause); LDUser user = new LDUser.Builder("key").name("Bob").build(); - assertEquals(LDValue.of(false), BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT).getDetails().getValue()); + assertMatch(BASE_EVALUATOR, f, user, false); } - + + @Test + public void clauseCanBeNegatedToReturnTrue() throws Exception { + DataModel.Clause clause = clause(UserAttribute.KEY, DataModel.Operator.in, true, LDValue.of("other")); + DataModel.FeatureFlag f = booleanFlagWithClauses("flag", clause); + LDUser user = new LDUser.Builder("key").name("Bob").build(); + + assertMatch(BASE_EVALUATOR, f, user, true); + } + @Test public void clauseWithUnsupportedOperatorStringIsUnmarshalledWithNullOperator() throws Exception { // This just verifies that GSON will give us a null in this case instead of throwing an exception, @@ -81,7 +178,7 @@ public void clauseWithNullOperatorDoesNotMatch() throws Exception { DataModel.FeatureFlag f = booleanFlagWithClauses("flag", badClause); LDUser user = new LDUser.Builder("key").name("Bob").build(); - assertEquals(LDValue.of(false), BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT).getDetails().getValue()); + assertMatch(BASE_EVALUATOR, f, user, false); } @Test @@ -105,31 +202,37 @@ public void clauseWithNullOperatorDoesNotStopSubsequentRuleFromMatching() throws @Test public void testSegmentMatchClauseRetrievesSegmentFromStore() throws Exception { - DataModel.Segment segment = segmentBuilder("segkey") - .included("foo") - .version(1) - .build(); - Evaluator e = evaluatorBuilder().withStoredSegments(segment).build(); - - DataModel.FeatureFlag flag = segmentMatchBooleanFlag("segkey"); + String segmentKey = "segkey"; + DataModel.Clause clause = clause(null, DataModel.Operator.segmentMatch, LDValue.of(segmentKey)); + DataModel.FeatureFlag flag = booleanFlagWithClauses("flag", clause); + DataModel.Segment segment = makeSegmentThatMatchesUser(segmentKey, "foo"); LDUser user = new LDUser.Builder("foo").build(); - Evaluator.EvalResult result = e.evaluate(flag, user, EventFactory.DEFAULT); - assertEquals(LDValue.of(true), result.getDetails().getValue()); + Evaluator e = evaluatorBuilder().withStoredSegments(segment).build(); + assertMatch(e, flag, user, true); } @Test public void testSegmentMatchClauseFallsThroughIfSegmentNotFound() throws Exception { - DataModel.FeatureFlag flag = segmentMatchBooleanFlag("segkey"); + String segmentKey = "segkey"; + DataModel.Clause clause = clause(null, DataModel.Operator.segmentMatch, LDValue.of(segmentKey)); + DataModel.FeatureFlag flag = booleanFlagWithClauses("flag", clause); LDUser user = new LDUser.Builder("foo").build(); - Evaluator e = evaluatorBuilder().withNonexistentSegment("segkey").build(); - Evaluator.EvalResult result = e.evaluate(flag, user, EventFactory.DEFAULT); - assertEquals(LDValue.of(false), result.getDetails().getValue()); + Evaluator e = evaluatorBuilder().withNonexistentSegment(segmentKey).build(); + assertMatch(e, flag, user, false); } - - private DataModel.FeatureFlag segmentMatchBooleanFlag(String segmentKey) { - DataModel.Clause clause = clause(null, DataModel.Operator.segmentMatch, LDValue.of(segmentKey)); - return booleanFlagWithClauses("flag", clause); + + @Test + public void testSegmentMatchClauseIgnoresNonStringValues() throws Exception { + String segmentKey = "segkey"; + DataModel.Clause clause = clause(null, DataModel.Operator.segmentMatch, + LDValue.of(123), LDValue.of(segmentKey)); + DataModel.FeatureFlag flag = booleanFlagWithClauses("flag", clause); + DataModel.Segment segment = makeSegmentThatMatchesUser(segmentKey, "foo"); + LDUser user = new LDUser.Builder("foo").build(); + + Evaluator e = evaluatorBuilder().withStoredSegments(segment).build(); + assertMatch(e, flag, user, true); } } diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorOperatorsParameterizedTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorOperatorsParameterizedTest.java index 51f091e1b..c8a21bf44 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EvaluatorOperatorsParameterizedTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorOperatorsParameterizedTest.java @@ -48,7 +48,7 @@ public EvaluatorOperatorsParameterizedTest( this.shouldBe = shouldBe; } - @Parameterized.Parameters(name = "{1} {0} {2} should be {3}") + @Parameterized.Parameters(name = "{1} {0} {2}+{3} should be {4}") public static Iterable data() { return Arrays.asList(new Object[][] { // numeric comparisons @@ -77,10 +77,16 @@ public static Iterable data() { { Operator.in, LDValue.of("x"), LDValue.of("x"), new LDValue[] { LDValue.of("a"), LDValue.of("b"), LDValue.of("c") }, true }, { Operator.startsWith, LDValue.of("xyz"), LDValue.of("x"), null, true }, { Operator.startsWith, LDValue.of("x"), LDValue.of("xyz"), null, false }, + { Operator.startsWith, LDValue.of(1), LDValue.of("xyz"), null, false }, + { Operator.startsWith, LDValue.of("1xyz"), LDValue.of(1), null, false }, { Operator.endsWith, LDValue.of("xyz"), LDValue.of("z"), null, true }, { Operator.endsWith, LDValue.of("z"), LDValue.of("xyz"), null, false }, + { Operator.endsWith, LDValue.of(1), LDValue.of("xyz"), null, false }, + { Operator.endsWith, LDValue.of("xyz1"), LDValue.of(1), null, false }, { Operator.contains, LDValue.of("xyz"), LDValue.of("y"), null, true }, { Operator.contains, LDValue.of("y"), LDValue.of("xyz"), null, false }, + { Operator.contains, LDValue.of(2), LDValue.of("xyz"), null, false }, + { Operator.contains, LDValue.of("that 2 is not a string"), LDValue.of(2), null, false }, // mixed strings and numbers { Operator.in, LDValue.of("99"), LDValue.of(99), null, false }, @@ -108,6 +114,7 @@ public static Iterable data() { { Operator.matches, LDValue.of("hello world"), LDValue.of("aloha"), null, false }, // note that an invalid regex in a clause should *not* cause an exception, just a non-match { Operator.matches, LDValue.of("hello world"), LDValue.of("***not a regex"), null, false }, + { Operator.matches, LDValue.of(2), LDValue.of("that 2 is not a string"), null, false }, // dates { Operator.before, dateStr1, dateStr2, null, true }, @@ -119,6 +126,7 @@ public static Iterable data() { { Operator.before, dateStr1, dateStr1, null, false }, { Operator.before, dateMs1, dateMs1, null, false }, { Operator.before, dateStr1, invalidDate, null, false }, + { Operator.before, invalidDate, dateStr1, null, false }, { Operator.after, dateStr1, dateStr2, null, false }, { Operator.after, dateStrUtc1, dateStrUtc2, null, false }, { Operator.after, dateMs1, dateMs2, null, false }, @@ -128,13 +136,19 @@ public static Iterable data() { { Operator.after, dateStr1, dateStr1, null, false }, { Operator.after, dateMs1, dateMs1, null, false }, { Operator.after, dateStr1, invalidDate, null, false }, + { Operator.after, invalidDate, dateStr1, null, false }, // semver { Operator.semVerEqual, LDValue.of("2.0.1"), LDValue.of("2.0.1"), null, true }, + { Operator.semVerEqual, LDValue.of("2.0.2"), LDValue.of("2.0.1"), null, false }, { Operator.semVerEqual, LDValue.of("2.0"), LDValue.of("2.0.0"), null, true }, { Operator.semVerEqual, LDValue.of("2"), LDValue.of("2.0.0"), null, true }, { Operator.semVerEqual, LDValue.of("2-rc1"), LDValue.of("2.0.0-rc1"), null, true }, { Operator.semVerEqual, LDValue.of("2+build2"), LDValue.of("2.0.0+build2"), null, true }, + { Operator.semVerEqual, LDValue.of("xxx"), LDValue.of("2.0.1"), null, false }, + { Operator.semVerEqual, LDValue.of(2), LDValue.of("2.0.1"), null, false }, + { Operator.semVerEqual, LDValue.of("2.0.1"), LDValue.of("xxx"), null, false }, + { Operator.semVerEqual, LDValue.of("2.0.1"), LDValue.of(2), null, false }, { Operator.semVerLessThan, LDValue.of("2.0.0"), LDValue.of("2.0.1"), null, true }, { Operator.semVerLessThan, LDValue.of("2.0"), LDValue.of("2.0.1"), null, true }, { Operator.semVerLessThan, LDValue.of("2.0.1"), LDValue.of("2.0.0"), null, false }, @@ -147,7 +161,11 @@ public static Iterable data() { { Operator.semVerGreaterThan, LDValue.of("2.0"), LDValue.of("2.0.1"), null, false }, { Operator.semVerGreaterThan, LDValue.of("2.0.0-rc.1"), LDValue.of("2.0.0-rc.0"), null, true }, { Operator.semVerLessThan, LDValue.of("2.0.1"), invalidVer, null, false }, - { Operator.semVerGreaterThan, LDValue.of("2.0.1"), invalidVer, null, false } + { Operator.semVerGreaterThan, LDValue.of("2.0.1"), invalidVer, null, false }, + + // miscellaneous invalid conditions + { null, LDValue.of("x"), LDValue.of("y"), null, false }, // no operator + { Operator.segmentMatch, LDValue.of("x"), LDValue.of("y"), null, false } // segmentMatch is handled elsewhere }); } diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorRuleTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorRuleTest.java index b15a21594..f3a658956 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EvaluatorRuleTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorRuleTest.java @@ -5,29 +5,49 @@ import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.server.ModelBuilders.FlagBuilder; +import com.launchdarkly.sdk.server.ModelBuilders.RuleBuilder; import org.junit.Test; import static com.launchdarkly.sdk.server.EvaluatorTestUtil.BASE_EVALUATOR; import static com.launchdarkly.sdk.server.ModelBuilders.clause; import static com.launchdarkly.sdk.server.ModelBuilders.emptyRollout; -import static com.launchdarkly.sdk.server.ModelBuilders.fallthroughVariation; import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; import static com.launchdarkly.sdk.server.ModelBuilders.ruleBuilder; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.emptyIterable; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; @SuppressWarnings("javadoc") public class EvaluatorRuleTest { + private static final int FALLTHROUGH_VARIATION = 0; + private static final int MATCH_VARIATION = 1; + + private FlagBuilder buildBooleanFlagWithRules(String flagKey, DataModel.Rule... rules) { + return flagBuilder(flagKey) + .on(true) + .rules(rules) + .fallthroughVariation(FALLTHROUGH_VARIATION) + .offVariation(FALLTHROUGH_VARIATION) + .variations(LDValue.of(false), LDValue.of(true)); + } + + private RuleBuilder buildTestRule(String id, DataModel.Clause... clauses) { + return ruleBuilder().id(id).clauses(clauses).variation(MATCH_VARIATION); + } + @Test public void ruleMatchReasonInstanceIsReusedForSameRule() { DataModel.Clause clause0 = clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of("wrongkey")); DataModel.Clause clause1 = clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of("userkey")); - DataModel.Rule rule0 = ruleBuilder().id("ruleid0").clauses(clause0).variation(2).build(); - DataModel.Rule rule1 = ruleBuilder().id("ruleid1").clauses(clause1).variation(2).build(); - DataModel.FeatureFlag f = featureFlagWithRules("feature", rule0, rule1); + DataModel.Rule rule0 = buildTestRule("ruleid0", clause0).build(); + DataModel.Rule rule1 = buildTestRule("ruleid1", clause1).build(); + + DataModel.FeatureFlag f = buildBooleanFlagWithRules("feature", rule0, rule1).build(); LDUser user = new LDUser.Builder("userkey").build(); LDUser otherUser = new LDUser.Builder("wrongkey").build(); @@ -41,11 +61,32 @@ public void ruleMatchReasonInstanceIsReusedForSameRule() { assertEquals(EvaluationReason.ruleMatch(0, "ruleid0"), otherResult.getDetails().getReason()); } + @Test + public void ruleMatchReasonInstanceCanBeCreatedFromScratch() { + // Normally we will always do the preprocessing step that creates the reason instances ahead of time, + // but if somehow we didn't, it should create them as needed + DataModel.Clause clause = clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of("userkey")); + DataModel.Rule rule = buildTestRule("ruleid", clause).build(); + LDUser user = new LDUser("userkey"); + + DataModel.FeatureFlag f = buildBooleanFlagWithRules("feature", rule) + .disablePreprocessing(true) + .build(); + assertNull(f.getRules().get(0).getRuleMatchReason()); + + Evaluator.EvalResult result1 = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); + Evaluator.EvalResult result2 = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); + + assertEquals(EvaluationReason.ruleMatch(0, "ruleid"), result1.getDetails().getReason()); + assertNotSame(result1.getDetails().getReason(), result2.getDetails().getReason()); // they were created individually + assertEquals(result1.getDetails().getReason(), result2.getDetails().getReason()); // but they're equal + } + @Test public void ruleWithTooHighVariationReturnsMalformedFlagError() { DataModel.Clause clause = clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of("userkey")); - DataModel.Rule rule = ruleBuilder().id("ruleid").clauses(clause).variation(999).build(); - DataModel.FeatureFlag f = featureFlagWithRules("feature", rule); + DataModel.Rule rule = buildTestRule("ruleid", clause).variation(999).build(); + DataModel.FeatureFlag f = buildBooleanFlagWithRules("feature", rule).build(); LDUser user = new LDUser.Builder("userkey").build(); Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); @@ -56,8 +97,8 @@ public void ruleWithTooHighVariationReturnsMalformedFlagError() { @Test public void ruleWithNegativeVariationReturnsMalformedFlagError() { DataModel.Clause clause = clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of("userkey")); - DataModel.Rule rule = ruleBuilder().id("ruleid").clauses(clause).variation(-1).build(); - DataModel.FeatureFlag f = featureFlagWithRules("feature", rule); + DataModel.Rule rule = buildTestRule("ruleid", clause).variation(-1).build(); + DataModel.FeatureFlag f = buildBooleanFlagWithRules("feature", rule).build(); LDUser user = new LDUser.Builder("userkey").build(); Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); @@ -68,8 +109,8 @@ public void ruleWithNegativeVariationReturnsMalformedFlagError() { @Test public void ruleWithNoVariationOrRolloutReturnsMalformedFlagError() { DataModel.Clause clause = clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of("userkey")); - DataModel.Rule rule = ruleBuilder().id("ruleid").clauses(clause).build(); - DataModel.FeatureFlag f = featureFlagWithRules("feature", rule); + DataModel.Rule rule = buildTestRule("ruleid", clause).variation(null).build(); + DataModel.FeatureFlag f = buildBooleanFlagWithRules("feature", rule).build(); LDUser user = new LDUser.Builder("userkey").build(); Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); @@ -80,22 +121,12 @@ public void ruleWithNoVariationOrRolloutReturnsMalformedFlagError() { @Test public void ruleWithRolloutWithEmptyVariationsListReturnsMalformedFlagError() { DataModel.Clause clause = clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of("userkey")); - DataModel.Rule rule = ruleBuilder().id("ruleid").clauses(clause).rollout(emptyRollout()).build(); - DataModel.FeatureFlag f = featureFlagWithRules("feature", rule); + DataModel.Rule rule = buildTestRule("ruleid", clause).variation(null).rollout(emptyRollout()).build(); + DataModel.FeatureFlag f = buildBooleanFlagWithRules("feature", rule).build(); LDUser user = new LDUser.Builder("userkey").build(); Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); assertThat(result.getPrerequisiteEvents(), emptyIterable()); } - - private DataModel.FeatureFlag featureFlagWithRules(String flagKey, DataModel.Rule... rules) { - return flagBuilder(flagKey) - .on(true) - .rules(rules) - .fallthrough(fallthroughVariation(0)) - .offVariation(1) - .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) - .build(); - } } diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java index ffdf26ed8..bf2fc214d 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java @@ -6,6 +6,7 @@ import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.server.ModelBuilders.FlagBuilder; import com.launchdarkly.sdk.server.interfaces.Event; import org.junit.Test; @@ -15,7 +16,6 @@ import static com.launchdarkly.sdk.server.EvaluatorTestUtil.BASE_EVALUATOR; import static com.launchdarkly.sdk.server.EvaluatorTestUtil.evaluatorBuilder; import static com.launchdarkly.sdk.server.ModelBuilders.clause; -import static com.launchdarkly.sdk.server.ModelBuilders.fallthroughVariation; import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; import static com.launchdarkly.sdk.server.ModelBuilders.prerequisite; import static com.launchdarkly.sdk.server.ModelBuilders.ruleBuilder; @@ -23,33 +23,84 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.emptyIterable; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; @SuppressWarnings("javadoc") public class EvaluatorTest { + private static final LDUser BASE_USER = new LDUser.Builder("x").build(); - private static LDUser BASE_USER = new LDUser.Builder("x").build(); + // These constants and flag builders define two kinds of flag: one with three variations-- allowing us to + // distinguish between match, fallthrough, and off results-- and one with two. + private static final int OFF_VARIATION = 0; + private static final LDValue OFF_VALUE = LDValue.of("off"); + private static final int FALLTHROUGH_VARIATION = 1; + private static final LDValue FALLTHROUGH_VALUE = LDValue.of("fall"); + private static final int MATCH_VARIATION = 2; + private static final LDValue MATCH_VALUE = LDValue.of("match"); + private static final LDValue[] THREE_VARIATIONS = new LDValue[] { OFF_VALUE, FALLTHROUGH_VALUE, MATCH_VALUE }; + + private static final int RED_VARIATION = 0; + private static final LDValue RED_VALUE = LDValue.of("red"); + private static final int GREEN_VARIATION = 1; + private static final LDValue GREEN_VALUE = LDValue.of("green"); + private static final LDValue[] RED_GREEN_VARIATIONS = new LDValue[] { RED_VALUE, GREEN_VALUE }; + + private static FlagBuilder buildThreeWayFlag(String flagKey) { + return flagBuilder(flagKey) + .fallthroughVariation(FALLTHROUGH_VARIATION) + .offVariation(OFF_VARIATION) + .variations(THREE_VARIATIONS) + .version(versionFromKey(flagKey)); + } + + private static FlagBuilder buildRedGreenFlag(String flagKey) { + return flagBuilder(flagKey) + .fallthroughVariation(GREEN_VARIATION) + .offVariation(RED_VARIATION) + .variations(RED_GREEN_VARIATIONS) + .version(versionFromKey(flagKey)); + } + + private static int versionFromKey(String flagKey) { + return Math.abs(flagKey.hashCode()); + } + @Test + public void evaluationReturnsErrorIfUserIsNull() throws Exception { + DataModel.FeatureFlag f = flagBuilder("feature").build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, null, EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.USER_NOT_SPECIFIED, null), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + + @Test + public void evaluationReturnsErrorIfUserKeyIsNull() throws Exception { + DataModel.FeatureFlag f = flagBuilder("feature").build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, new LDUser(null), EventFactory.DEFAULT); + + assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.USER_NOT_SPECIFIED, null), result.getDetails()); + assertThat(result.getPrerequisiteEvents(), emptyIterable()); + } + @Test public void flagReturnsOffVariationIfFlagIsOff() throws Exception { - DataModel.FeatureFlag f = flagBuilder("feature") + DataModel.FeatureFlag f = buildThreeWayFlag("feature") .on(false) - .offVariation(1) - .fallthrough(fallthroughVariation(0)) - .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .build(); Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); - assertEquals(fromValue(LDValue.of("off"), 1, EvaluationReason.off()), result.getDetails()); + assertEquals(fromValue(OFF_VALUE, OFF_VARIATION, EvaluationReason.off()), result.getDetails()); assertThat(result.getPrerequisiteEvents(), emptyIterable()); } @Test public void flagReturnsNullIfFlagIsOffAndOffVariationIsUnspecified() throws Exception { - DataModel.FeatureFlag f = flagBuilder("feature") + DataModel.FeatureFlag f = buildThreeWayFlag("feature") .on(false) - .fallthrough(fallthroughVariation(0)) - .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .offVariation(null) .build(); Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); @@ -59,11 +110,9 @@ public void flagReturnsNullIfFlagIsOffAndOffVariationIsUnspecified() throws Exce @Test public void flagReturnsErrorIfFlagIsOffAndOffVariationIsTooHigh() throws Exception { - DataModel.FeatureFlag f = flagBuilder("feature") + DataModel.FeatureFlag f = buildThreeWayFlag("feature") .on(false) .offVariation(999) - .fallthrough(fallthroughVariation(0)) - .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .build(); Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); @@ -73,11 +122,9 @@ public void flagReturnsErrorIfFlagIsOffAndOffVariationIsTooHigh() throws Excepti @Test public void flagReturnsErrorIfFlagIsOffAndOffVariationIsNegative() throws Exception { - DataModel.FeatureFlag f = flagBuilder("feature") + DataModel.FeatureFlag f = buildThreeWayFlag("feature") .on(false) .offVariation(-1) - .fallthrough(fallthroughVariation(0)) - .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .build(); Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); @@ -87,25 +134,20 @@ public void flagReturnsErrorIfFlagIsOffAndOffVariationIsNegative() throws Except @Test public void flagReturnsFallthroughIfFlagIsOnAndThereAreNoRules() throws Exception { - DataModel.FeatureFlag f = flagBuilder("feature") + DataModel.FeatureFlag f = buildThreeWayFlag("feature") .on(true) - .offVariation(1) - .fallthrough(fallthroughVariation(0)) - .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .build(); Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); - assertEquals(fromValue(LDValue.of("fall"), 0, EvaluationReason.fallthrough()), result.getDetails()); + assertEquals(fromValue(FALLTHROUGH_VALUE, FALLTHROUGH_VARIATION, EvaluationReason.fallthrough()), result.getDetails()); assertThat(result.getPrerequisiteEvents(), emptyIterable()); } @Test public void flagReturnsErrorIfFallthroughHasTooHighVariation() throws Exception { - DataModel.FeatureFlag f = flagBuilder("feature") + DataModel.FeatureFlag f = buildThreeWayFlag("feature") .on(true) - .offVariation(1) - .fallthrough(fallthroughVariation(999)) - .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .fallthroughVariation(999) .build(); Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); @@ -115,11 +157,9 @@ public void flagReturnsErrorIfFallthroughHasTooHighVariation() throws Exception @Test public void flagReturnsErrorIfFallthroughHasNegativeVariation() throws Exception { - DataModel.FeatureFlag f = flagBuilder("feature") + DataModel.FeatureFlag f = buildThreeWayFlag("feature") .on(true) - .offVariation(1) - .fallthrough(fallthroughVariation(-1)) - .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .fallthroughVariation(-1) .build(); Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); @@ -129,11 +169,9 @@ public void flagReturnsErrorIfFallthroughHasNegativeVariation() throws Exception @Test public void flagReturnsErrorIfFallthroughHasNeitherVariationNorRollout() throws Exception { - DataModel.FeatureFlag f = flagBuilder("feature") + DataModel.FeatureFlag f = buildThreeWayFlag("feature") .on(true) - .offVariation(1) .fallthrough(new DataModel.VariationOrRollout(null, null)) - .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .build(); Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); @@ -143,11 +181,9 @@ public void flagReturnsErrorIfFallthroughHasNeitherVariationNorRollout() throws @Test public void flagReturnsErrorIfFallthroughHasEmptyRolloutVariationList() throws Exception { - DataModel.FeatureFlag f = flagBuilder("feature") + DataModel.FeatureFlag f = buildThreeWayFlag("feature") .on(true) - .offVariation(1) .fallthrough(new DataModel.VariationOrRollout(null, ModelBuilders.emptyRollout())) - .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .build(); Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); @@ -157,91 +193,74 @@ public void flagReturnsErrorIfFallthroughHasEmptyRolloutVariationList() throws E @Test public void flagReturnsOffVariationIfPrerequisiteIsNotFound() throws Exception { - DataModel.FeatureFlag f0 = flagBuilder("feature0") + DataModel.FeatureFlag f0 = buildThreeWayFlag("feature") .on(true) .prerequisites(prerequisite("feature1", 1)) - .fallthrough(fallthroughVariation(0)) - .offVariation(1) - .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .build(); Evaluator e = evaluatorBuilder().withNonexistentFlag("feature1").build(); Evaluator.EvalResult result = e.evaluate(f0, BASE_USER, EventFactory.DEFAULT); EvaluationReason expectedReason = EvaluationReason.prerequisiteFailed("feature1"); - assertEquals(fromValue(LDValue.of("off"), 1, expectedReason), result.getDetails()); + assertEquals(fromValue(OFF_VALUE, OFF_VARIATION, expectedReason), result.getDetails()); assertThat(result.getPrerequisiteEvents(), emptyIterable()); } @Test public void flagReturnsOffVariationAndEventIfPrerequisiteIsOff() throws Exception { - DataModel.FeatureFlag f0 = flagBuilder("feature0") + DataModel.FeatureFlag f0 = buildThreeWayFlag("feature") .on(true) - .prerequisites(prerequisite("feature1", 1)) - .fallthrough(fallthroughVariation(0)) - .offVariation(1) - .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) - .version(1) + .prerequisites(prerequisite("feature1", GREEN_VARIATION)) .build(); - DataModel.FeatureFlag f1 = flagBuilder("feature1") + DataModel.FeatureFlag f1 = buildRedGreenFlag("feature1") .on(false) - .offVariation(1) + .offVariation(GREEN_VARIATION) // note that even though it returns the desired variation, it is still off and therefore not a match - .fallthrough(fallthroughVariation(0)) - .variations(LDValue.of("nogo"), LDValue.of("go")) - .version(2) .build(); Evaluator e = evaluatorBuilder().withStoredFlags(f1).build(); Evaluator.EvalResult result = e.evaluate(f0, BASE_USER, EventFactory.DEFAULT); EvaluationReason expectedReason = EvaluationReason.prerequisiteFailed("feature1"); - assertEquals(fromValue(LDValue.of("off"), 1, expectedReason), result.getDetails()); + assertEquals(fromValue(OFF_VALUE, OFF_VARIATION, expectedReason), result.getDetails()); assertEquals(1, Iterables.size(result.getPrerequisiteEvents())); Event.FeatureRequest event = Iterables.get(result.getPrerequisiteEvents(), 0); assertEquals(f1.getKey(), event.getKey()); - assertEquals(LDValue.of("go"), event.getValue()); + assertEquals(GREEN_VARIATION, event.getVariation()); + assertEquals(GREEN_VALUE, event.getValue()); assertEquals(f1.getVersion(), event.getVersion()); assertEquals(f0.getKey(), event.getPrereqOf()); } @Test public void flagReturnsOffVariationAndEventIfPrerequisiteIsNotMet() throws Exception { - DataModel.FeatureFlag f0 = flagBuilder("feature0") + DataModel.FeatureFlag f0 = buildThreeWayFlag("feature") .on(true) - .prerequisites(prerequisite("feature1", 1)) - .fallthrough(fallthroughVariation(0)) - .offVariation(1) - .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) - .version(1) + .prerequisites(prerequisite("feature1", GREEN_VARIATION)) .build(); - DataModel.FeatureFlag f1 = flagBuilder("feature1") + DataModel.FeatureFlag f1 = buildRedGreenFlag("feature1") .on(true) - .fallthrough(fallthroughVariation(0)) - .variations(LDValue.of("nogo"), LDValue.of("go")) - .version(2) + .fallthroughVariation(RED_VARIATION) .build(); Evaluator e = evaluatorBuilder().withStoredFlags(f1).build(); Evaluator.EvalResult result = e.evaluate(f0, BASE_USER, EventFactory.DEFAULT); EvaluationReason expectedReason = EvaluationReason.prerequisiteFailed("feature1"); - assertEquals(fromValue(LDValue.of("off"), 1, expectedReason), result.getDetails()); + assertEquals(fromValue(OFF_VALUE, OFF_VARIATION, expectedReason), result.getDetails()); assertEquals(1, Iterables.size(result.getPrerequisiteEvents())); Event.FeatureRequest event = Iterables.get(result.getPrerequisiteEvents(), 0); assertEquals(f1.getKey(), event.getKey()); - assertEquals(LDValue.of("nogo"), event.getValue()); + assertEquals(RED_VARIATION, event.getVariation()); + assertEquals(RED_VALUE, event.getValue()); assertEquals(f1.getVersion(), event.getVersion()); assertEquals(f0.getKey(), event.getPrereqOf()); } @Test public void prerequisiteFailedReasonInstanceIsReusedForSamePrerequisite() throws Exception { - DataModel.FeatureFlag f0 = flagBuilder("feature0") + DataModel.FeatureFlag f0 = buildThreeWayFlag("feature") .on(true) - .prerequisites(prerequisite("feature1", 1)) - .fallthrough(fallthroughVariation(0)) - .offVariation(1) - .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .prerequisites(prerequisite("feature1", GREEN_VARIATION)) .build(); Evaluator e = evaluatorBuilder().withNonexistentFlag("feature1").build(); Evaluator.EvalResult result0 = e.evaluate(f0, BASE_USER, EventFactory.DEFAULT); @@ -252,90 +271,98 @@ public void prerequisiteFailedReasonInstanceIsReusedForSamePrerequisite() throws assertSame(result0.getDetails().getReason(), result1.getDetails().getReason()); } + @Test + public void prerequisiteFailedReasonInstanceCanBeCreatedFromScratch() throws Exception { + // Normally we will always do the preprocessing step that creates the reason instances ahead of time, + // but if somehow we didn't, it should create them as needed + DataModel.FeatureFlag f0 = buildThreeWayFlag("feature") + .on(true) + .prerequisites(prerequisite("feature1", GREEN_VARIATION)) + .disablePreprocessing(true) + .build(); + assertNull(f0.getPrerequisites().get(0).getPrerequisiteFailedReason()); + + Evaluator e = evaluatorBuilder().withNonexistentFlag("feature1").build(); + Evaluator.EvalResult result0 = e.evaluate(f0, BASE_USER, EventFactory.DEFAULT); + Evaluator.EvalResult result1 = e.evaluate(f0, BASE_USER, EventFactory.DEFAULT); + + EvaluationReason expectedReason = EvaluationReason.prerequisiteFailed("feature1"); + assertEquals(expectedReason, result0.getDetails().getReason()); + assertNotSame(result0.getDetails().getReason(), result1.getDetails().getReason()); // they were created individually + assertEquals(result0.getDetails().getReason(), result1.getDetails().getReason()); // but they're equal + } + @Test public void flagReturnsFallthroughVariationAndEventIfPrerequisiteIsMetAndThereAreNoRules() throws Exception { - DataModel.FeatureFlag f0 = flagBuilder("feature0") + DataModel.FeatureFlag f0 = buildThreeWayFlag("feature") .on(true) - .prerequisites(prerequisite("feature1", 1)) - .fallthrough(fallthroughVariation(0)) - .offVariation(1) - .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) - .version(1) + .prerequisites(prerequisite("feature1", GREEN_VARIATION)) .build(); - DataModel.FeatureFlag f1 = flagBuilder("feature1") + DataModel.FeatureFlag f1 = buildRedGreenFlag("feature1") .on(true) - .fallthrough(fallthroughVariation(1)) - .variations(LDValue.of("nogo"), LDValue.of("go")) + .fallthroughVariation(GREEN_VARIATION) .version(2) .build(); Evaluator e = evaluatorBuilder().withStoredFlags(f1).build(); Evaluator.EvalResult result = e.evaluate(f0, BASE_USER, EventFactory.DEFAULT); - assertEquals(fromValue(LDValue.of("fall"), 0, EvaluationReason.fallthrough()), result.getDetails()); + assertEquals(fromValue(FALLTHROUGH_VALUE, FALLTHROUGH_VARIATION, EvaluationReason.fallthrough()), result.getDetails()); assertEquals(1, Iterables.size(result.getPrerequisiteEvents())); Event.FeatureRequest event = Iterables.get(result.getPrerequisiteEvents(), 0); assertEquals(f1.getKey(), event.getKey()); - assertEquals(LDValue.of("go"), event.getValue()); + assertEquals(GREEN_VARIATION, event.getVariation()); + assertEquals(GREEN_VALUE, event.getValue()); assertEquals(f1.getVersion(), event.getVersion()); assertEquals(f0.getKey(), event.getPrereqOf()); } @Test public void multipleLevelsOfPrerequisitesProduceMultipleEvents() throws Exception { - DataModel.FeatureFlag f0 = flagBuilder("feature0") + DataModel.FeatureFlag f0 = buildThreeWayFlag("feature") .on(true) - .prerequisites(prerequisite("feature1", 1)) - .fallthrough(fallthroughVariation(0)) - .offVariation(1) - .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) - .version(1) + .prerequisites(prerequisite("feature1", GREEN_VARIATION)) .build(); - DataModel.FeatureFlag f1 = flagBuilder("feature1") + DataModel.FeatureFlag f1 = buildRedGreenFlag("feature1") .on(true) - .prerequisites(prerequisite("feature2", 1)) - .fallthrough(fallthroughVariation(1)) - .variations(LDValue.of("nogo"), LDValue.of("go")) - .version(2) + .prerequisites(prerequisite("feature2", GREEN_VARIATION)) + .fallthroughVariation(GREEN_VARIATION) .build(); - DataModel.FeatureFlag f2 = flagBuilder("feature2") + DataModel.FeatureFlag f2 = buildRedGreenFlag("feature2") .on(true) - .fallthrough(fallthroughVariation(1)) - .variations(LDValue.of("nogo"), LDValue.of("go")) - .version(3) + .fallthroughVariation(GREEN_VARIATION) .build(); Evaluator e = evaluatorBuilder().withStoredFlags(f1, f2).build(); Evaluator.EvalResult result = e.evaluate(f0, BASE_USER, EventFactory.DEFAULT); - assertEquals(fromValue(LDValue.of("fall"), 0, EvaluationReason.fallthrough()), result.getDetails()); + assertEquals(fromValue(FALLTHROUGH_VALUE, FALLTHROUGH_VARIATION, EvaluationReason.fallthrough()), result.getDetails()); assertEquals(2, Iterables.size(result.getPrerequisiteEvents())); Event.FeatureRequest event0 = Iterables.get(result.getPrerequisiteEvents(), 0); assertEquals(f2.getKey(), event0.getKey()); - assertEquals(LDValue.of("go"), event0.getValue()); + assertEquals(GREEN_VARIATION, event0.getVariation()); + assertEquals(GREEN_VALUE, event0.getValue()); assertEquals(f2.getVersion(), event0.getVersion()); assertEquals(f1.getKey(), event0.getPrereqOf()); Event.FeatureRequest event1 = Iterables.get(result.getPrerequisiteEvents(), 1); assertEquals(f1.getKey(), event1.getKey()); - assertEquals(LDValue.of("go"), event1.getValue()); + assertEquals(GREEN_VARIATION, event1.getVariation()); + assertEquals(GREEN_VALUE, event1.getValue()); assertEquals(f1.getVersion(), event1.getVersion()); assertEquals(f0.getKey(), event1.getPrereqOf()); } @Test public void flagMatchesUserFromTargets() throws Exception { - DataModel.FeatureFlag f = flagBuilder("feature") + DataModel.FeatureFlag f = buildThreeWayFlag("feature") .on(true) .targets(target(2, "whoever", "userkey")) - .fallthrough(fallthroughVariation(0)) - .offVariation(1) - .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .build(); LDUser user = new LDUser.Builder("userkey").build(); Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); - assertEquals(fromValue(LDValue.of("on"), 2, EvaluationReason.targetMatch()), result.getDetails()); + assertEquals(fromValue(MATCH_VALUE, MATCH_VARIATION, EvaluationReason.targetMatch()), result.getDetails()); assertThat(result.getPrerequisiteEvents(), emptyIterable()); } @@ -345,21 +372,16 @@ public void flagMatchesUserFromRules() { DataModel.Clause clause1 = clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of("userkey")); DataModel.Rule rule0 = ruleBuilder().id("ruleid0").clauses(clause0).variation(2).build(); DataModel.Rule rule1 = ruleBuilder().id("ruleid1").clauses(clause1).variation(2).build(); - DataModel.FeatureFlag f = featureFlagWithRules("feature", rule0, rule1); + + DataModel.FeatureFlag f = buildThreeWayFlag("feature") + .on(true) + .rules(rule0, rule1) + .build(); + LDUser user = new LDUser.Builder("userkey").build(); Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); - assertEquals(fromValue(LDValue.of("on"), 2, EvaluationReason.ruleMatch(1, "ruleid1")), result.getDetails()); + assertEquals(fromValue(MATCH_VALUE, MATCH_VARIATION, EvaluationReason.ruleMatch(1, "ruleid1")), result.getDetails()); assertThat(result.getPrerequisiteEvents(), emptyIterable()); } - - private DataModel.FeatureFlag featureFlagWithRules(String flagKey, DataModel.Rule... rules) { - return flagBuilder(flagKey) - .on(true) - .rules(rules) - .fallthrough(fallthroughVariation(0)) - .offVariation(1) - .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) - .build(); - } } diff --git a/src/test/java/com/launchdarkly/sdk/server/ModelBuilders.java b/src/test/java/com/launchdarkly/sdk/server/ModelBuilders.java index 37dca814e..ea5af29f5 100644 --- a/src/test/java/com/launchdarkly/sdk/server/ModelBuilders.java +++ b/src/test/java/com/launchdarkly/sdk/server/ModelBuilders.java @@ -103,7 +103,8 @@ public static class FlagBuilder { private boolean trackEventsFallthrough; private Long debugEventsUntilDate; private boolean deleted; - + private boolean disablePreprocessing = false; + private FlagBuilder(String key) { this.key = key; } @@ -212,10 +213,17 @@ FlagBuilder deleted(boolean deleted) { return this; } + FlagBuilder disablePreprocessing(boolean disable) { + this.disablePreprocessing = disable; + return this; + } + DataModel.FeatureFlag build() { FeatureFlag flag = new DataModel.FeatureFlag(key, version, on, prerequisites, salt, targets, rules, fallthrough, offVariation, variations, clientSide, trackEvents, trackEventsFallthrough, debugEventsUntilDate, deleted); - flag.afterDeserialized(); + if (!disablePreprocessing) { + flag.afterDeserialized(); + } return flag; } } From e6e37a559db22048b6d93cd2fe8aa08cddb5ce0c Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Sat, 23 May 2020 00:03:42 -0700 Subject: [PATCH 473/673] revert change to flush payload queue behavior --- .../sdk/server/DefaultEventProcessor.java | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java index 68ad3c521..0bbe3fa81 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java @@ -22,7 +22,6 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.Semaphore; -import java.util.concurrent.SynchronousQueue; import java.util.concurrent.ThreadFactory; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -226,11 +225,10 @@ private EventDispatcher( .setPriority(threadPriority) .build(); - // A SynchronousQueue is not exactly a queue - it represents an opportunity to transfer a - // single value from the producer to whichever consumer has asked for it first. We will - // never allow the producer (EventDispatcher) to block on this: if we want to push a - // payload and all of the workers are busy so no one is waiting, we'll cancel the flush. - final SynchronousQueue payloadQueue = new SynchronousQueue<>(); + // 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. + final BlockingQueue payloadQueue = new ArrayBlockingQueue<>(1); final EventBuffer outbox = new EventBuffer(eventsConfig.capacity); final SimpleLRUCache userKeys = new SimpleLRUCache(eventsConfig.userKeysCapacity); @@ -289,7 +287,7 @@ public void uncaughtException(Thread t, Throwable e) { */ private void runMainLoop(BlockingQueue inbox, EventBuffer outbox, SimpleLRUCache userKeys, - SynchronousQueue payloadQueue) { + BlockingQueue payloadQueue) { List batch = new ArrayList(MESSAGE_BATCH_SIZE); while (true) { try { @@ -439,7 +437,7 @@ private boolean shouldDebugEvent(Event.FeatureRequest fe) { return false; } - private void triggerFlush(EventBuffer outbox, SynchronousQueue payloadQueue) { + private void triggerFlush(EventBuffer outbox, BlockingQueue payloadQueue) { if (disabled.get() || outbox.isEmpty()) { return; } From ab4370320b7c0d690d5090e8fffbd5fc3a5a6ef1 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Sat, 23 May 2020 01:43:05 -0700 Subject: [PATCH 474/673] avoid test race condition --- .../com/launchdarkly/sdk/server/DefaultEventProcessorTest.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java index 600e6110a..9354f97c4 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java @@ -288,6 +288,9 @@ public void noMoreEventsAreProcessedAfterUnrecoverableError() throws Exception { ep.flush(); es.awaitRequest(); + // allow a little time for the event processor to pass the "must shut down" signal back from the sender + Thread.sleep(50); + ep.sendEvent(EventFactory.DEFAULT.newIdentifyEvent(user)); ep.flush(); es.expectNoRequests(Duration.ofMillis(100)); From a9b6c813ea60588d863ad0aa1c639115e7b6564e Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Sat, 23 May 2020 11:24:43 -0700 Subject: [PATCH 475/673] add test coverage reporting --- .circleci/config.yml | 22 ++++++++++++++++++++-- CONTRIBUTING.md | 10 ++++++++++ build.gradle | 9 +++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 449edfca5..33885e9f3 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -25,6 +25,7 @@ workflows: - test-linux: name: Java 11 - Linux - OpenJDK docker-image: circleci/openjdk:11 + with-coverage: true requires: - build-linux - packaging: @@ -52,6 +53,9 @@ jobs: parameters: docker-image: type: string + with-coverage: + type: boolean + default: false docker: - image: <> steps: @@ -61,16 +65,30 @@ jobs: at: build - run: java -version - run: ./gradlew test + - when: + condition: <> + steps: + - run: + name: Generate test coverage report + command: | + ./gradlew jacocoTestReport + mkdir -p coverage/ + cp -r build/reports/jacoco/test/* ./coverage - run: name: Save test results command: | - mkdir -p ~/junit/; - find . -type f -regex ".*/build/test-results/.*xml" -exec cp {} ~/junit/ \; + 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 + - when: + condition: <> + steps: + - store_artifacts: + path: coverage build-test-windows: executor: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 229d7cad7..fae89e6f5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,3 +42,13 @@ To build the SDK and run all unit tests: ``` ./gradlew test ``` + +## Code coverage + +It is important to keep unit test coverage as close to 100% as possible in this project. + +Sometimes a gap in coverage is unavoidable, usually because the compiler requires us to provide a code path for some condition that in practice can't happen and can't be tested, or because of a known issue with the code coverage tool. Please handle all such cases as follows: + +* Mark the code with an explanatory comment beginning with "COVERAGE:". + +The current coverage report can be observed by running `./gradlew jacocoTestReport` and viewing `build/reports/jacoco/test/html/index.html`. This report is also produced as an artifact of the CircleCI build for the most recent Java version. diff --git a/build.gradle b/build.gradle index ca071191e..92bd5e8c8 100644 --- a/build.gradle +++ b/build.gradle @@ -17,6 +17,7 @@ plugins { id "java" id "java-library" id "checkstyle" + id "jacoco" id "signing" id "com.github.johnrengelman.shadow" version "5.2.0" id "maven-publish" @@ -415,6 +416,14 @@ test { } } +jacocoTestReport { // code coverage report + reports { + xml.enabled + csv.enabled true + html.enabled true + } +} + idea { module { downloadJavadoc = true From 38a492220cad90d38650f430644d6635166d26d9 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Sat, 23 May 2020 11:28:45 -0700 Subject: [PATCH 476/673] CI fix --- .circleci/config.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 33885e9f3..ace3d879f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -64,7 +64,9 @@ jobs: - attach_workspace: at: build - run: java -version - - run: ./gradlew test + - run: + name: Run tests + command: ./gradlew test - when: condition: <> steps: @@ -78,7 +80,7 @@ jobs: name: Save test results command: | mkdir -p ~/junit/ - find . -type f -regex ".*/build/test-results/.*xml" -exec cp {} ~/junit/ \ + find . -type f -regex ".*/build/test-results/.*xml" -exec cp {} ~/junit/ when: always - store_test_results: path: ~/junit From 4e3eee301010f61984d01946c6ac70566ddbb259 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Sat, 23 May 2020 11:31:51 -0700 Subject: [PATCH 477/673] CI fix --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index ace3d879f..7a4d6590e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -80,7 +80,7 @@ jobs: name: Save test results command: | mkdir -p ~/junit/ - find . -type f -regex ".*/build/test-results/.*xml" -exec cp {} ~/junit/ + find . -type f -regex ".*/build/test-results/.*xml" -exec cp {} ~/junit/ \; when: always - store_test_results: path: ~/junit From 4b85a3fd0952e069cf6fe1e8a5a950730e340c40 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Sat, 23 May 2020 12:32:52 -0700 Subject: [PATCH 478/673] more event test improvements --- .../sdk/server/DefaultEventProcessor.java | 53 +++++----- .../sdk/server/DefaultEventSender.java | 11 ++- .../DefaultEventProcessorOutputTest.java | 54 +++++++++++ .../sdk/server/DefaultEventProcessorTest.java | 97 +++++++++++++++++++ .../server/DefaultEventProcessorTestBase.java | 18 ++++ .../sdk/server/DefaultEventSenderTest.java | 63 ++++++++++++ 6 files changed, 265 insertions(+), 31 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java index 0bbe3fa81..270203b4c 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java @@ -118,6 +118,7 @@ private void postMessageAsync(MessageType type, Event event) { private void postMessageAndWait(MessageType type, Event event) { EventProcessorMessage message = new EventProcessorMessage(type, event, true); if (postToChannel(message)) { + // COVERAGE: There is no way to reliably cause this to fail in tests message.waitForCompletion(); } } @@ -132,6 +133,7 @@ private boolean postToChannel(EventProcessorMessage message) { // 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; // 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; + // COVERAGE: There is no way to reliably cause this condition in tests if (!alreadyLogged) { logger.warn("Events are being produced faster than they can be processed; some events will be dropped"); } @@ -165,7 +167,7 @@ void completed() { } void waitForCompletion() { - if (reply == null) { + if (reply == null) { // COVERAGE: there is no way to make this happen from test code return; } while (true) { @@ -173,7 +175,7 @@ void waitForCompletion() { reply.acquire(); return; } - catch (InterruptedException ex) { + catch (InterruptedException ex) { // COVERAGE: there is no way to make this happen from test code. } } } @@ -238,21 +240,21 @@ private EventDispatcher( }); mainThread.setDaemon(true); - mainThread.setUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() { + mainThread.setUncaughtExceptionHandler((Thread t, Throwable e) -> { // 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(); - inbox.drainTo(messages); - for (EventProcessorMessage m: messages) { - m.completed(); - } + + // COVERAGE: there is no way to make this happen from test code. + 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(); + inbox.drainTo(messages); + for (EventProcessorMessage m: messages) { + m.completed(); } }); @@ -295,7 +297,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) { // COVERAGE: adding a default branch does not prevent coverage warnings here due to compiler issues case EVENT: processEvent(message.event, userKeys, outbox); break; @@ -319,7 +321,7 @@ private void runMainLoop(BlockingQueue inbox, message.completed(); } } catch (InterruptedException e) { - } catch (Exception e) { + } catch (Exception e) { // COVERAGE: there is no way to cause this condition in tests logger.error("Unexpected error in event processor: {}", e.toString()); logger.debug(e.toString(), e); } @@ -358,7 +360,7 @@ private void waitUntilAllFlushWorkersInactive() { busyFlushWorkersCount.wait(); } } - } catch (InterruptedException e) {} + } catch (InterruptedException e) {} // COVERAGE: there is no way to cause this condition in tests } } @@ -414,9 +416,6 @@ private void processEvent(Event e, SimpleLRUCache userKeys, Even // 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) { - return false; - } String key = user.getKey(); return userKeys.put(key, key) != null; } @@ -571,15 +570,13 @@ public void run() { try { StringWriter stringWriter = new StringWriter(); int outputEventCount = formatter.writeOutputEvents(payload.events, payload.summary, stringWriter); - if (outputEventCount > 0) { - EventSender.Result result = eventsConfig.eventSender.sendEventData( - EventDataKind.ANALYTICS, - stringWriter.toString(), - outputEventCount, - eventsConfig.eventsUri - ); - responseListener.handleResponse(result); - } + 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()); logger.debug(e.toString(), e); diff --git a/src/main/java/com/launchdarkly/sdk/server/DefaultEventSender.java b/src/main/java/com/launchdarkly/sdk/server/DefaultEventSender.java index 8ccba212e..6ea5b9453 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DefaultEventSender.java +++ b/src/main/java/com/launchdarkly/sdk/server/DefaultEventSender.java @@ -42,7 +42,7 @@ final class DefaultEventSender implements EventSender { private final OkHttpClient httpClient; private final Headers baseHeaders; - private final Duration retryDelay; + final Duration retryDelay; // visible for testing DefaultEventSender( String sdkKey, @@ -67,6 +67,11 @@ public void close() throws IOException { @Override public Result sendEventData(EventDataKind kind, String data, int eventCount, URI eventsBaseUri) { + if (data == null || data.isEmpty()) { + // DefaultEventProcessor won't normally pass us an empty payload, but if it does, don't bother sending + return new Result(true, false, null); + } + Headers.Builder headersBuilder = baseHeaders.newBuilder(); String path; String description; @@ -84,7 +89,7 @@ public Result sendEventData(EventDataKind kind, String data, int eventCount, URI description = "diagnostic event"; break; default: - throw new IllegalArgumentException("kind"); + throw new IllegalArgumentException("kind"); // COVERAGE: unreachable code, those are the only enum values } URI uri = eventsBaseUri.resolve(eventsBaseUri.getPath().endsWith("/") ? path : ("/" + path)); @@ -99,7 +104,7 @@ public Result sendEventData(EventDataKind kind, String data, int eventCount, URI logger.warn("Will retry posting {} after {}", description, describeDuration(retryDelay)); try { Thread.sleep(retryDelay.toMillis()); - } catch (InterruptedException e) { + } catch (InterruptedException e) { // COVERAGE: there's no way to cause this in tests } } diff --git a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorOutputTest.java b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorOutputTest.java index 6cfd4f4fa..518626509 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorOutputTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorOutputTest.java @@ -22,6 +22,8 @@ */ @SuppressWarnings("javadoc") public class DefaultEventProcessorOutputTest extends DefaultEventProcessorTestBase { + private static final LDUser userWithNullKey = new LDUser(null); + @Test public void identifyEventIsQueued() throws Exception { MockEventSender es = new MockEventSender(); @@ -49,6 +51,22 @@ public void userIsFilteredInIdentifyEvent() throws Exception { isIdentifyEvent(e, filteredUserJson) )); } + + @Test + public void identifyEventWithNullUserKeyDoesNotCauseError() throws Exception { + // This should never happen because LDClient.identify() rejects such a user, but just in case, + // we want to make sure it doesn't blow up the event processor. + MockEventSender es = new MockEventSender(); + Event e = EventFactory.DEFAULT.newIdentifyEvent(userWithNullKey); + + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es).allAttributesPrivate(true))) { + ep.sendEvent(e); + } + + assertThat(es.getEventsFromLastRequest(), contains( + isIdentifyEvent(e, LDValue.buildObject().build()) + )); + } @SuppressWarnings("unchecked") @Test @@ -125,6 +143,25 @@ public void userIsFilteredInFeatureEvent() throws Exception { )); } + @Test + public void featureEventWithNullUserKeyIsIgnored() throws Exception { + // This should never happen because LDClient rejects such a user, but just in case, + // we want to make sure it doesn't blow up the event processor. + MockEventSender es = new MockEventSender(); + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).build(); + Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, userWithNullKey, + simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); + + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es) + .inlineUsersInEvents(true).allAttributesPrivate(true))) { + ep.sendEvent(fe); + } + + assertThat(es.getEventsFromLastRequest(), contains( + isSummaryEvent() + )); + } + @SuppressWarnings("unchecked") @Test public void featureEventCanContainReason() throws Exception { @@ -407,4 +444,21 @@ public void userIsFilteredInCustomEvent() throws Exception { assertThat(es.getEventsFromLastRequest(), contains(isCustomEvent(ce, filteredUserJson))); } + + @Test + public void customEventWithNullUserKeyDoesNotCauseError() throws Exception { + // This should never happen because LDClient rejects such a user, but just in case, + // we want to make sure it doesn't blow up the event processor. + MockEventSender es = new MockEventSender(); + Event.Custom ce = EventFactory.DEFAULT.newCustomEvent("eventkey", userWithNullKey, LDValue.ofNull(), null); + + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es) + .inlineUsersInEvents(true).allAttributesPrivate(true))) { + ep.sendEvent(ce); + } + + assertThat(es.getEventsFromLastRequest(), contains( + isCustomEvent(ce, LDValue.buildObject().build()) + )); + } } diff --git a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java index 9354f97c4..f35302491 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java @@ -2,6 +2,7 @@ import com.google.common.collect.ImmutableSet; import com.google.common.collect.Iterables; +import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.UserAttribute; import com.launchdarkly.sdk.server.integrations.EventProcessorBuilder; @@ -12,8 +13,10 @@ import org.hamcrest.Matchers; import org.junit.Test; +import java.io.IOException; import java.net.URI; import java.time.Duration; +import java.util.concurrent.CountDownLatch; import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; import static com.launchdarkly.sdk.server.TestComponents.clientContext; @@ -222,6 +225,16 @@ public void eventSenderIsClosedWithEventProcessor() throws Exception { ep.close(); assertThat(es.closed, is(true)); } + + @Test + public void eventProcessorCatchesExceptionWhenClosingEventSender() throws Exception { + MockEventSender es = new MockEventSender(); + es.fakeErrorOnClose = new IOException("sorry"); + assertThat(es.closed, is(false)); + DefaultEventProcessor ep = makeEventProcessor(baseConfig(es)); + ep.close(); + assertThat(es.closed, is(true)); + } @Test public void customBaseUriIsPassedToEventSenderForAnalyticsEvents() throws Exception { @@ -297,6 +310,20 @@ public void noMoreEventsAreProcessedAfterUnrecoverableError() throws Exception { } } + @Test + public void noMoreEventsAreProcessedAfterClosingEventProcessor() throws Exception { + MockEventSender es = new MockEventSender(); + + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { + ep.close(); + + ep.sendEvent(EventFactory.DEFAULT.newIdentifyEvent(user)); + ep.flush(); + + es.expectNoRequests(Duration.ofMillis(100)); + } + } + @Test public void uncheckedExceptionFromEventSenderDoesNotStopWorkerThread() throws Exception { MockEventSender es = new MockEventSender(); @@ -316,4 +343,74 @@ public void uncheckedExceptionFromEventSenderDoesNotStopWorkerThread() throws Ex es.awaitRequest(); } } + + @SuppressWarnings("unchecked") + @Test + public void eventsAreKeptInBufferIfAllFlushWorkersAreBusy() throws Exception { + // Note that in the current implementation, although the intention was that we would cancel a flush + // if there's not an available flush worker, instead what happens is that we will queue *one* flush + // in that case, and then cancel the *next* flush if the workers are still busy. This is because we + // used a BlockingQueue with a size of 1, rather than a SynchronousQueue. The test below verifies + // the current behavior. + + int numWorkers = 5; // must equal EventDispatcher.MAX_FLUSH_THREADS + LDUser testUser1 = new LDUser("me"); + LDValue testUserJson1 = LDValue.buildObject().put("key", "me").build(); + LDUser testUser2 = new LDUser("you"); + LDValue testUserJson2 = LDValue.buildObject().put("key", "you").build(); + LDUser testUser3 = new LDUser("everyone we know"); + LDValue testUserJson3 = LDValue.buildObject().put("key", "everyone we know").build(); + + Object sendersWaitOnThis = new Object(); + CountDownLatch sendersSignalThisWhenWaiting = new CountDownLatch(numWorkers); + MockEventSender es = new MockEventSender(); + es.waitSignal = sendersWaitOnThis; + es.receivedCounter = sendersSignalThisWhenWaiting; + + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { + for (int i = 0; i < 5; i++) { + ep.sendEvent(EventFactory.DEFAULT.newIdentifyEvent(user)); + ep.flush(); + es.awaitRequest(); // we don't need to see this payload, just throw it away + } + + // When our CountDownLatch reaches zero, it means all of the worker threads are blocked in MockEventSender + sendersSignalThisWhenWaiting.await(); + es.waitSignal = null; + es.receivedCounter = null; + + // Now, put an event in the buffer and try to flush again. In the current implementation (see + // above) this payload gets queued in a holding area, and will be flushed after a worker + // becomes free. + Event.Identify event1 = EventFactory.DEFAULT.newIdentifyEvent(testUser1); + ep.sendEvent(event1); + ep.flush(); + + // Do an additional flush with another event. This time, the event processor should see that there's + // no space available and simply ignore the flush request. There's no way to verify programmatically + // that this has happened, so just give it a short delay. + Event.Identify event2 = EventFactory.DEFAULT.newIdentifyEvent(testUser2); + ep.sendEvent(event2); + ep.flush(); + Thread.sleep(100); + + // Enqueue a third event. The current payload should now be event2 + event3. + Event.Identify event3 = EventFactory.DEFAULT.newIdentifyEvent(testUser3); + ep.sendEvent(event3); + + // Now allow the workers to unblock + synchronized (sendersWaitOnThis) { + sendersWaitOnThis.notifyAll(); + } + + // The first unblocked worker should pick up the queued payload with event1. + assertThat(es.getEventsFromLastRequest(), contains(isIdentifyEvent(event1, testUserJson1))); + + // Now a flush should succeed and send the current payload. + ep.flush(); + assertThat(es.getEventsFromLastRequest(), contains( + isIdentifyEvent(event2, testUserJson2), + isIdentifyEvent(event3, testUserJson3))); + } + } } diff --git a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTestBase.java b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTestBase.java index a7eb03c71..52532e053 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTestBase.java +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTestBase.java @@ -16,6 +16,7 @@ import java.net.URI; import java.time.Duration; import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; @@ -73,6 +74,9 @@ public static final class MockEventSender implements EventSender { volatile boolean closed; volatile Result result = new Result(true, false, null); volatile RuntimeException fakeError = null; + volatile IOException fakeErrorOnClose = null; + volatile CountDownLatch receivedCounter = null; + volatile Object waitSignal = null; final BlockingQueue receivedParams = new LinkedBlockingQueue<>(); @@ -94,11 +98,25 @@ static final class Params { @Override public void close() throws IOException { closed = true; + if (fakeErrorOnClose != null) { + throw fakeErrorOnClose; + } } @Override public Result sendEventData(EventDataKind kind, String data, int eventCount, URI eventsBaseUri) { receivedParams.add(new Params(kind, data, eventCount, eventsBaseUri)); + if (waitSignal != null) { + // this is used in DefaultEventProcessorTest.eventsAreKeptInBufferIfAllFlushWorkersAreBusy + synchronized (waitSignal) { + if (receivedCounter != null) { + receivedCounter.countDown(); + } + try { + waitSignal.wait(); + } catch (InterruptedException e) {} + } + } if (fakeError != null) { throw fakeError; } diff --git a/src/test/java/com/launchdarkly/sdk/server/DefaultEventSenderTest.java b/src/test/java/com/launchdarkly/sdk/server/DefaultEventSenderTest.java index 8e00942bd..d5fda59fe 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DefaultEventSenderTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultEventSenderTest.java @@ -1,6 +1,7 @@ package com.launchdarkly.sdk.server; import com.launchdarkly.sdk.server.interfaces.EventSender; +import com.launchdarkly.sdk.server.interfaces.EventSenderFactory; import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; import org.junit.Test; @@ -18,6 +19,7 @@ import static com.launchdarkly.sdk.server.interfaces.EventSender.EventDataKind.DIAGNOSTICS; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.isA; import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.notNullValue; import static org.hamcrest.Matchers.nullValue; @@ -54,6 +56,25 @@ private static URI getBaseUri(MockWebServer server) { return server.url("/").uri(); } + @Test + public void factoryCreatesDefaultSenderWithDefaultRetryDelay() throws Exception { + EventSenderFactory f = new DefaultEventSender.Factory(); + try (EventSender es = f.createEventSender(SDK_KEY, Components.httpConfiguration().createHttpConfiguration())) { + assertThat(es, isA(EventSender.class)); + assertThat(((DefaultEventSender)es).retryDelay, equalTo(DefaultEventSender.DEFAULT_RETRY_DELAY)); + } + } + + @Test + public void constructorUsesDefaultRetryDelayIfNotSpecified() throws Exception { + try (EventSender es = new DefaultEventSender( + SDK_KEY, + Components.httpConfiguration().createHttpConfiguration(), + null)) { + assertThat(((DefaultEventSender)es).retryDelay, equalTo(DefaultEventSender.DEFAULT_RETRY_DELAY)); + } + } + @Test public void analyticsDataIsDelivered() throws Exception { try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { @@ -236,6 +257,20 @@ public void serverDateIsParsed() throws Exception { } } } + + @Test + public void invalidServerDateIsIgnored() throws Exception { + MockResponse resp = eventsSuccessResponse().addHeader("Date", "not a date"); + + try (MockWebServer server = makeStartedServer(resp)) { + try (EventSender es = makeEventSender()) { + EventSender.Result result = es.sendEventData(DIAGNOSTICS, FAKE_DATA, 1, getBaseUri(server)); + + assertTrue(result.isSuccess()); + assertNull(result.getTimeFromServer()); + } + } + } @Test public void httpClientDoesNotAllowSelfSignedCertByDefault() throws Exception { @@ -288,6 +323,34 @@ public void baseUriDoesNotNeedToEndInSlash() throws Exception { } } + @Test + public void nothingIsSentForNullData() throws Exception { + try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { + try (EventSender es = makeEventSender()) { + EventSender.Result result1 = es.sendEventData(ANALYTICS, null, 0, getBaseUri(server)); + EventSender.Result result2 = es.sendEventData(DIAGNOSTICS, null, 0, getBaseUri(server)); + + assertTrue(result1.isSuccess()); + assertTrue(result2.isSuccess()); + assertEquals(0, server.getRequestCount()); + } + } + } + + @Test + public void nothingIsSentForEmptyData() throws Exception { + try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { + try (EventSender es = makeEventSender()) { + EventSender.Result result1 = es.sendEventData(ANALYTICS, "", 0, getBaseUri(server)); + EventSender.Result result2 = es.sendEventData(DIAGNOSTICS, "", 0, getBaseUri(server)); + + assertTrue(result1.isSuccess()); + assertTrue(result2.isSuccess()); + assertEquals(0, server.getRequestCount()); + } + } + } + private void testUnrecoverableHttpError(int status) throws Exception { MockResponse errorResponse = new MockResponse().setResponseCode(status); From b898d818a805258e735680e6093f4625201a01ed Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Sat, 23 May 2020 12:47:36 -0700 Subject: [PATCH 479/673] misc cleanup + test improvements --- .../sdk/server/DefaultEventProcessor.java | 10 ++---- .../DefaultEventProcessorOutputTest.java | 35 ++++++++++++------- 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java index 270203b4c..0cf1f941b 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java @@ -394,7 +394,9 @@ private void processEvent(Event e, SimpleLRUCache userKeys, Even LDUser user = e.getUser(); if (user != null && user.getKey() != null) { boolean isIndexEvent = e instanceof Event.Identify; - boolean alreadySeen = noticeUser(user, userKeys); + String key = user.getKey(); + // Add to the set of users we've noticed + boolean alreadySeen = (userKeys.put(key, key) != null); addIndexEvent = !isIndexEvent & !alreadySeen; if (!isIndexEvent & alreadySeen) { deduplicatedUsers++; @@ -414,12 +416,6 @@ private void processEvent(Event e, SimpleLRUCache userKeys, Even } } - // 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) { - String key = user.getKey(); - return userKeys.put(key, key) != null; - } - private boolean shouldDebugEvent(Event.FeatureRequest fe) { long debugEventsUntilDate = fe.getDebugEventsUntilDate(); if (debugEventsUntilDate > 0) { diff --git a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorOutputTest.java b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorOutputTest.java index 518626509..4647a242c 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorOutputTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorOutputTest.java @@ -52,22 +52,26 @@ public void userIsFilteredInIdentifyEvent() throws Exception { )); } + @SuppressWarnings("unchecked") @Test - public void identifyEventWithNullUserKeyDoesNotCauseError() throws Exception { + public void identifyEventWithNullUserOrNullUserKeyDoesNotCauseError() throws Exception { // This should never happen because LDClient.identify() rejects such a user, but just in case, // we want to make sure it doesn't blow up the event processor. MockEventSender es = new MockEventSender(); - Event e = EventFactory.DEFAULT.newIdentifyEvent(userWithNullKey); + Event event1 = EventFactory.DEFAULT.newIdentifyEvent(userWithNullKey); + Event event2 = EventFactory.DEFAULT.newIdentifyEvent(null); try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es).allAttributesPrivate(true))) { - ep.sendEvent(e); + ep.sendEvent(event1); + ep.sendEvent(event2); } assertThat(es.getEventsFromLastRequest(), contains( - isIdentifyEvent(e, LDValue.buildObject().build()) + isIdentifyEvent(event1, LDValue.buildObject().build()), + isIdentifyEvent(event2, LDValue.ofNull()) )); } - + @SuppressWarnings("unchecked") @Test public void individualFeatureEventIsQueuedWithIndexEvent() throws Exception { @@ -144,17 +148,20 @@ public void userIsFilteredInFeatureEvent() throws Exception { } @Test - public void featureEventWithNullUserKeyIsIgnored() throws Exception { + public void featureEventWithNullUserOrNullUserKeyIsIgnored() throws Exception { // This should never happen because LDClient rejects such a user, but just in case, // we want to make sure it doesn't blow up the event processor. MockEventSender es = new MockEventSender(); DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).build(); - Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, userWithNullKey, + Event.FeatureRequest event1 = EventFactory.DEFAULT.newFeatureRequestEvent(flag, userWithNullKey, + simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); + Event.FeatureRequest event2 = EventFactory.DEFAULT.newFeatureRequestEvent(flag, null, simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es) .inlineUsersInEvents(true).allAttributesPrivate(true))) { - ep.sendEvent(fe); + ep.sendEvent(event1); + ep.sendEvent(event2); } assertThat(es.getEventsFromLastRequest(), contains( @@ -445,20 +452,24 @@ public void userIsFilteredInCustomEvent() throws Exception { assertThat(es.getEventsFromLastRequest(), contains(isCustomEvent(ce, filteredUserJson))); } + @SuppressWarnings("unchecked") @Test - public void customEventWithNullUserKeyDoesNotCauseError() throws Exception { + public void customEventWithNullUserOrNullUserKeyDoesNotCauseError() throws Exception { // This should never happen because LDClient rejects such a user, but just in case, // we want to make sure it doesn't blow up the event processor. MockEventSender es = new MockEventSender(); - Event.Custom ce = EventFactory.DEFAULT.newCustomEvent("eventkey", userWithNullKey, LDValue.ofNull(), null); + Event.Custom event1 = EventFactory.DEFAULT.newCustomEvent("eventkey", userWithNullKey, LDValue.ofNull(), null); + Event.Custom event2 = EventFactory.DEFAULT.newCustomEvent("eventkey", null, LDValue.ofNull(), null); try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es) .inlineUsersInEvents(true).allAttributesPrivate(true))) { - ep.sendEvent(ce); + ep.sendEvent(event1); + ep.sendEvent(event2); } assertThat(es.getEventsFromLastRequest(), contains( - isCustomEvent(ce, LDValue.buildObject().build()) + isCustomEvent(event1, LDValue.buildObject().build()), + isCustomEvent(event2, LDValue.ofNull()) )); } } From 60046ef8dcc69d983e6f736677125d9f6d8d04b5 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Sat, 23 May 2020 13:02:56 -0700 Subject: [PATCH 480/673] misc cleanup + test improvements --- .../launchdarkly/sdk/server/DataModel.java | 4 ++- .../sdk/server/EvaluatorBucketing.java | 2 +- .../server/DataModelSerializationTest.java | 26 +++++++++++++++++++ .../sdk/server/DataModelTest.java | 7 +++++ 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/DataModel.java b/src/main/java/com/launchdarkly/sdk/server/DataModel.java index 60a1254e6..1aab7d6a6 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DataModel.java +++ b/src/main/java/com/launchdarkly/sdk/server/DataModel.java @@ -25,6 +25,8 @@ * store or serialize them. */ public abstract class DataModel { + private DataModel() {} + /** * The {@link DataKind} instance that describes feature flag data. *

    @@ -239,7 +241,7 @@ static final class Target { // Guaranteed non-null Collection getValues() { - return values == null ? emptyList() : values; + return values == null ? emptySet() : values; } int getVariation() { diff --git a/src/main/java/com/launchdarkly/sdk/server/EvaluatorBucketing.java b/src/main/java/com/launchdarkly/sdk/server/EvaluatorBucketing.java index 98d6be3a9..6f6891dff 100644 --- a/src/main/java/com/launchdarkly/sdk/server/EvaluatorBucketing.java +++ b/src/main/java/com/launchdarkly/sdk/server/EvaluatorBucketing.java @@ -22,7 +22,7 @@ static Integer variationIndexForUser(DataModel.VariationOrRollout vr, LDUser use return variation; } else { DataModel.Rollout rollout = vr.getRollout(); - if (rollout != null && rollout.getVariations() != null && !rollout.getVariations().isEmpty()) { + if (rollout != null && !rollout.getVariations().isEmpty()) { float bucket = bucketUser(user, key, rollout.getBucketBy(), salt); float sum = 0F; for (DataModel.WeightedVariation wv : rollout.getVariations()) { diff --git a/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java b/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java index 7d455c2d4..ca1dfff0d 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java @@ -58,6 +58,19 @@ public void flagIsDeserializedWithMinimalProperties() { assertNull(flag.getDebugEventsUntilDate()); } + @Test + public void deletedFlagIsConvertedToAndFromJsonPlaceholder() { + String json0 = LDValue.buildObject().put("version", 99) + .put("deleted", true).build().toJsonString(); + ItemDescriptor item = FEATURES.deserialize(json0); + assertNotNull(item); + assertNull(item.getItem()); + assertEquals(99, item.getVersion()); + + String json1 = FEATURES.serialize(item); + assertEquals(LDValue.parse(json0), LDValue.parse(json1)); + } + @Test public void segmentIsDeserializedWithAllProperties() { String json0 = segmentWithAllPropertiesJson().toJsonString(); @@ -82,6 +95,19 @@ public void segmentIsDeserializedWithMinimalProperties() { assertNotNull(segment.getRules()); assertEquals(0, segment.getRules().size()); } + + @Test + public void deletedSegmentIsConvertedToAndFromJsonPlaceholder() { + String json0 = LDValue.buildObject().put("version", 99) + .put("deleted", true).build().toJsonString(); + ItemDescriptor item = SEGMENTS.deserialize(json0); + assertNotNull(item); + assertNull(item.getItem()); + assertEquals(99, item.getVersion()); + + String json1 = SEGMENTS.serialize(item); + assertEquals(LDValue.parse(json0), LDValue.parse(json1)); + } private LDValue flagWithAllPropertiesJson() { return LDValue.buildObject() diff --git a/src/test/java/com/launchdarkly/sdk/server/DataModelTest.java b/src/test/java/com/launchdarkly/sdk/server/DataModelTest.java index 78a8c71fb..3b3c951e3 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataModelTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataModelTest.java @@ -8,6 +8,7 @@ import com.launchdarkly.sdk.server.DataModel.Rule; import com.launchdarkly.sdk.server.DataModel.Segment; import com.launchdarkly.sdk.server.DataModel.SegmentRule; +import com.launchdarkly.sdk.server.DataModel.Target; import org.junit.Test; @@ -39,6 +40,12 @@ public void flagVariationsListCanNeverBeNull() { assertEquals(ImmutableList.of(), f.getVariations()); } + @Test + public void targetKeysSetCanNeverBeNull() { + Target t = new Target(null, 0); + assertEquals(ImmutableSet.of(), t.getValues()); + } + @Test public void ruleClausesListCanNeverBeNull() { Rule r = new Rule("id", null, null, null, false); From 0f898fadba326a31bdec62e52a2a9c318f452987 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Sat, 23 May 2020 13:31:09 -0700 Subject: [PATCH 481/673] make intVariation and doubleVariation non-nullable --- .../com/launchdarkly/sdk/server/LDClient.java | 4 ++-- .../server/interfaces/LDClientInterface.java | 4 ++-- .../sdk/server/LDClientEvaluationTest.java | 24 +++++++++---------- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClient.java b/src/main/java/com/launchdarkly/sdk/server/LDClient.java index 5ec07ee16..71c14c47f 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClient.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClient.java @@ -340,12 +340,12 @@ public boolean boolVariation(String featureKey, LDUser user, boolean defaultValu } @Override - public Integer intVariation(String featureKey, LDUser user, int defaultValue) { + public int intVariation(String featureKey, LDUser user, int defaultValue) { return evaluate(featureKey, user, LDValue.of(defaultValue), true).intValue(); } @Override - public Double doubleVariation(String featureKey, LDUser user, Double defaultValue) { + public double doubleVariation(String featureKey, LDUser user, double defaultValue) { return evaluate(featureKey, user, LDValue.of(defaultValue), true).doubleValue(); } diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/LDClientInterface.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/LDClientInterface.java index 46c714d09..4906359d2 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/LDClientInterface.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/LDClientInterface.java @@ -105,7 +105,7 @@ public interface LDClientInterface extends Closeable { * @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 */ - Integer intVariation(String featureKey, LDUser user, int defaultValue); + int intVariation(String featureKey, LDUser user, int defaultValue); /** * Calculates the floating point numeric value of a feature flag for a given user. @@ -115,7 +115,7 @@ public interface LDClientInterface extends Closeable { * @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 */ - Double doubleVariation(String featureKey, LDUser user, Double defaultValue); + double doubleVariation(String featureKey, LDUser user, double defaultValue); /** * Calculates the String value of a feature flag for a given user. diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java index 989dda8e9..2917d9fe0 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java @@ -75,14 +75,14 @@ public void boolVariationReturnsDefaultValueForWrongType() throws Exception { public void intVariationReturnsFlagValue() throws Exception { upsertFlag(dataStore, flagWithValue("key", LDValue.of(2))); - assertEquals(new Integer(2), client.intVariation("key", user, 1)); + assertEquals(2, client.intVariation("key", user, 1)); } @Test public void intVariationReturnsFlagValueEvenIfEncodedAsDouble() throws Exception { upsertFlag(dataStore, flagWithValue("key", LDValue.of(2.0))); - assertEquals(new Integer(2), client.intVariation("key", user, 1)); + assertEquals(2, client.intVariation("key", user, 1)); } @Test @@ -92,48 +92,48 @@ public void intVariationFromDoubleRoundsTowardZero() throws Exception { upsertFlag(dataStore, flagWithValue("flag3", LDValue.of(-2.25))); upsertFlag(dataStore, flagWithValue("flag4", LDValue.of(-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)); + assertEquals(2, client.intVariation("flag1", user, 1)); + assertEquals(2, client.intVariation("flag2", user, 1)); + assertEquals(-2, client.intVariation("flag3", user, 1)); + assertEquals(-2, client.intVariation("flag4", user, 1)); } @Test public void intVariationReturnsDefaultValueForUnknownFlag() throws Exception { - assertEquals(new Integer(1), client.intVariation("key", user, 1)); + assertEquals(1, client.intVariation("key", user, 1)); } @Test public void intVariationReturnsDefaultValueForWrongType() throws Exception { upsertFlag(dataStore, flagWithValue("key", LDValue.of("wrong"))); - assertEquals(new Integer(1), client.intVariation("key", user, 1)); + assertEquals(1, client.intVariation("key", user, 1)); } @Test public void doubleVariationReturnsFlagValue() throws Exception { upsertFlag(dataStore, flagWithValue("key", LDValue.of(2.5d))); - assertEquals(new Double(2.5d), client.doubleVariation("key", user, 1.0d)); + assertEquals(2.5d, client.doubleVariation("key", user, 1.0d), 0d); } @Test public void doubleVariationReturnsFlagValueEvenIfEncodedAsInt() throws Exception { upsertFlag(dataStore, flagWithValue("key", LDValue.of(2))); - assertEquals(new Double(2.0d), client.doubleVariation("key", user, 1.0d)); + assertEquals(2.0d, client.doubleVariation("key", user, 1.0d), 0d); } @Test public void doubleVariationReturnsDefaultValueForUnknownFlag() throws Exception { - assertEquals(new Double(1.0d), client.doubleVariation("key", user, 1.0d)); + assertEquals(1.0d, client.doubleVariation("key", user, 1.0d), 0d); } @Test public void doubleVariationReturnsDefaultValueForWrongType() throws Exception { upsertFlag(dataStore, flagWithValue("key", LDValue.of("wrong"))); - assertEquals(new Double(1.0d), client.doubleVariation("key", user, 1.0d)); + assertEquals(1.0d, client.doubleVariation("key", user, 1.0d), 0d); } @Test From 3b0e95b948e64bb64d7d083a296b4584009bc32f Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Sat, 23 May 2020 14:31:08 -0700 Subject: [PATCH 482/673] (5.0) don't use jar magic to find out our own version string --- build.gradle | 9 ++++++ .../sdk/server/DiagnosticEvent.java | 2 +- .../com/launchdarkly/sdk/server/LDClient.java | 29 +------------------ .../com/launchdarkly/sdk/server/Util.java | 2 +- .../com/launchdarkly/sdk/server/Version.java | 8 +++++ .../com/launchdarkly/sdk/server/Version.java | 8 +++++ .../sdk/server/DiagnosticSdkTest.java | 10 +++---- .../sdk/server/FeatureRequestorTest.java | 9 +----- .../launchdarkly/sdk/server/LDClientTest.java | 13 ++++++++- .../sdk/server/StreamProcessorTest.java | 2 +- 10 files changed, 46 insertions(+), 46 deletions(-) create mode 100644 src/main/java/com/launchdarkly/sdk/server/Version.java create mode 100644 src/templates/java/com/launchdarkly/sdk/server/Version.java diff --git a/build.gradle b/build.gradle index 92bd5e8c8..60484d905 100644 --- a/build.gradle +++ b/build.gradle @@ -135,6 +135,15 @@ checkstyle { configDir file("${project.rootDir}/config/checkstyle") } +task generateJava(type: Copy) { + // This updates Version.java + from 'src/templates/java' + into "src/main/java" + filter(org.apache.tools.ant.filters.ReplaceTokens, tokens: [VERSION: version.toString()]) +} + +compileJava.dependsOn 'generateJava' + 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. diff --git a/src/main/java/com/launchdarkly/sdk/server/DiagnosticEvent.java b/src/main/java/com/launchdarkly/sdk/server/DiagnosticEvent.java index 4f2c8b887..ed8f5db2e 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DiagnosticEvent.java +++ b/src/main/java/com/launchdarkly/sdk/server/DiagnosticEvent.java @@ -148,7 +148,7 @@ private static void mergeComponentProperties(ObjectBuilder builder, Object compo static class DiagnosticSdk { final String name = "java-server-sdk"; - final String version = LDClient.CLIENT_VERSION; + final String version = Version.SDK_VERSION; final String wrapperName; final String wrapperVersion; diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClient.java b/src/main/java/com/launchdarkly/sdk/server/LDClient.java index 71c14c47f..7194be259 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClient.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClient.java @@ -29,7 +29,6 @@ import java.io.IOException; import java.io.UnsupportedEncodingException; -import java.net.URL; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.util.Map; @@ -39,8 +38,6 @@ import java.util.concurrent.ThreadFactory; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; -import java.util.jar.Attributes; -import java.util.jar.Manifest; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; @@ -59,7 +56,6 @@ public final class LDClient implements LDClientInterface { static final Logger logger = LoggerFactory.getLogger(LDClient.class); private static final String HMAC_ALGORITHM = "HmacSHA256"; - static final String CLIENT_VERSION = getClientVersion(); private final String sdkKey; private final boolean offline; @@ -547,7 +543,7 @@ public String secureModeHash(LDUser user) { */ @Override public String version() { - return CLIENT_VERSION; + return Version.SDK_VERSION; } // This executor is used for a variety of SDK tasks such as flag change events, checking the data store @@ -563,27 +559,4 @@ private ScheduledExecutorService createSharedExecutor(LDConfig config) { .build(); return Executors.newSingleThreadScheduledExecutor(threadFactory); } - - private static String getClientVersion() { - Class clazz = LDConfig.class; - String className = clazz.getSimpleName() + ".class"; - String classPath = clazz.getResource(className).toString(); - if (!classPath.startsWith("jar")) { - // Class not from JAR - return "Unknown"; - } - String manifestPath = classPath.substring(0, classPath.lastIndexOf("!") + 1) + - "/META-INF/MANIFEST.MF"; - Manifest manifest = null; - try { - manifest = new Manifest(new URL(manifestPath).openStream()); - Attributes attr = manifest.getMainAttributes(); - String value = attr.getValue("Implementation-Version"); - return value; - } catch (IOException e) { - logger.warn("Unable to determine LaunchDarkly client library version: {}", e.toString()); - logger.debug(e.toString(), e); - return "Unknown"; - } - } } diff --git a/src/main/java/com/launchdarkly/sdk/server/Util.java b/src/main/java/com/launchdarkly/sdk/server/Util.java index 881e07eac..eb576c5c9 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Util.java +++ b/src/main/java/com/launchdarkly/sdk/server/Util.java @@ -23,7 +23,7 @@ class Util { static Headers.Builder getHeadersBuilderFor(String sdkKey, HttpConfiguration config) { Headers.Builder builder = new Headers.Builder() .add("Authorization", sdkKey) - .add("User-Agent", "JavaClient/" + LDClient.CLIENT_VERSION); + .add("User-Agent", "JavaClient/" + Version.SDK_VERSION); if (config.getWrapperIdentifier() != null) { builder.add("X-LaunchDarkly-Wrapper", config.getWrapperIdentifier()); diff --git a/src/main/java/com/launchdarkly/sdk/server/Version.java b/src/main/java/com/launchdarkly/sdk/server/Version.java new file mode 100644 index 000000000..f71d442a1 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/Version.java @@ -0,0 +1,8 @@ +package com.launchdarkly.sdk.server; + +abstract class Version { + private Version() {} + + // This constant is updated automatically by our Gradle script during a release, if the project version has changed + static final String SDK_VERSION = "5.0.0-rc2"; +} diff --git a/src/templates/java/com/launchdarkly/sdk/server/Version.java b/src/templates/java/com/launchdarkly/sdk/server/Version.java new file mode 100644 index 000000000..acfd3be18 --- /dev/null +++ b/src/templates/java/com/launchdarkly/sdk/server/Version.java @@ -0,0 +1,8 @@ +package com.launchdarkly.sdk.server; + +abstract class Version { + private Version() {} + + // This constant is updated automatically by our Gradle script during a release, if the project version has changed + static final String SDK_VERSION = "@VERSION@"; +} diff --git a/src/test/java/com/launchdarkly/sdk/server/DiagnosticSdkTest.java b/src/test/java/com/launchdarkly/sdk/server/DiagnosticSdkTest.java index 01271c472..35525ff27 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DiagnosticSdkTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DiagnosticSdkTest.java @@ -2,8 +2,6 @@ import com.google.gson.Gson; import com.google.gson.JsonObject; -import com.launchdarkly.sdk.server.LDClient; -import com.launchdarkly.sdk.server.LDConfig; import com.launchdarkly.sdk.server.DiagnosticEvent.Init.DiagnosticSdk; import org.junit.Test; @@ -19,7 +17,7 @@ public class DiagnosticSdkTest { public void defaultFieldValues() { DiagnosticSdk diagnosticSdk = new DiagnosticSdk(new LDConfig.Builder().build()); assertEquals("java-server-sdk", diagnosticSdk.name); - assertEquals(LDClient.CLIENT_VERSION, diagnosticSdk.version); + assertEquals(Version.SDK_VERSION, diagnosticSdk.version); assertNull(diagnosticSdk.wrapperName); assertNull(diagnosticSdk.wrapperVersion); } @@ -31,7 +29,7 @@ public void getsWrapperValuesFromConfig() { .build(); DiagnosticSdk diagnosticSdk = new DiagnosticSdk(config); assertEquals("java-server-sdk", diagnosticSdk.name); - assertEquals(LDClient.CLIENT_VERSION, diagnosticSdk.version); + assertEquals(Version.SDK_VERSION, diagnosticSdk.version); assertEquals(diagnosticSdk.wrapperName, "Scala"); assertEquals(diagnosticSdk.wrapperVersion, "0.1.0"); } @@ -42,7 +40,7 @@ public void gsonSerializationNoWrapper() { 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()); + assertEquals(Version.SDK_VERSION, jsonObject.getAsJsonPrimitive("version").getAsString()); } @Test @@ -54,7 +52,7 @@ public void gsonSerializationWithWrapper() { 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(Version.SDK_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/sdk/server/FeatureRequestorTest.java b/src/test/java/com/launchdarkly/sdk/server/FeatureRequestorTest.java index 99a37daad..cce5e171d 100644 --- a/src/test/java/com/launchdarkly/sdk/server/FeatureRequestorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/FeatureRequestorTest.java @@ -1,12 +1,5 @@ package com.launchdarkly.sdk.server; -import com.launchdarkly.sdk.server.DataModel; -import com.launchdarkly.sdk.server.DefaultFeatureRequestor; -import com.launchdarkly.sdk.server.FeatureRequestor; -import com.launchdarkly.sdk.server.HttpErrorException; -import com.launchdarkly.sdk.server.LDClient; -import com.launchdarkly.sdk.server.LDConfig; - import org.junit.Assert; import org.junit.Test; @@ -216,7 +209,7 @@ public void httpClientCanUseProxyConfig() throws Exception { private void verifyHeaders(RecordedRequest req) { assertEquals(sdkKey, req.getHeader("Authorization")); - assertEquals("JavaClient/" + LDClient.CLIENT_VERSION, req.getHeader("User-Agent")); + assertEquals("JavaClient/" + Version.SDK_VERSION, req.getHeader("User-Agent")); } private void verifyFlag(DataModel.FeatureFlag flag, String key) { diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java index e00ec126e..ecf6a6681 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java @@ -368,11 +368,22 @@ public void evaluationUsesStoreIfStoreIsInitializedButClientIsNot() throws Excep client = createMockClient(config); upsertFlag(testDataStore, flagWithValue("key", LDValue.of(1))); - assertEquals(new Integer(1), client.intVariation("key", new LDUser("user"), 0)); + assertEquals(1, client.intVariation("key", new LDUser("user"), 0)); verifyAll(); } + @Test + public void getVersion() throws Exception { + LDConfig config = new LDConfig.Builder() + .dataSource(Components.externalUpdatesOnly()) + .events(Components.noEvents()) + .build(); + try (LDClient client = new LDClient(SDK_KEY, config)) { + assertEquals(Version.SDK_VERSION, client.version()); + } + } + private void expectEventsSent(int count) { eventProcessor.sendEvent(anyObject(Event.class)); if (count > 0) { diff --git a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java index 4392aaf2d..2584e7d51 100644 --- a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java @@ -143,7 +143,7 @@ public void headersHaveAuthorization() { @Test public void headersHaveUserAgent() { createStreamProcessor(STREAM_URI).start(); - assertEquals("JavaClient/" + LDClient.CLIENT_VERSION, + assertEquals("JavaClient/" + Version.SDK_VERSION, mockEventSourceCreator.getNextReceivedParams().headers.get("User-Agent")); } From f6b7ff25c7e6f2139eac091da7cdb087f596a50f Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Sat, 23 May 2020 14:31:47 -0700 Subject: [PATCH 483/673] fix test --- src/test/java/com/launchdarkly/sdk/server/LDClientTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java index e00ec126e..6f50cf03d 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java @@ -368,7 +368,7 @@ public void evaluationUsesStoreIfStoreIsInitializedButClientIsNot() throws Excep client = createMockClient(config); upsertFlag(testDataStore, flagWithValue("key", LDValue.of(1))); - assertEquals(new Integer(1), client.intVariation("key", new LDUser("user"), 0)); + assertEquals(1, client.intVariation("key", new LDUser("user"), 0)); verifyAll(); } From b3f313c313d89659ebd885ba64ce096649f97c66 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Sat, 23 May 2020 14:33:48 -0700 Subject: [PATCH 484/673] make intVariation and doubleVariation non-nullable --- .../com/launchdarkly/sdk/server/LDClient.java | 4 ++-- .../server/interfaces/LDClientInterface.java | 4 ++-- .../sdk/server/LDClientEvaluationTest.java | 24 +++++++++---------- .../launchdarkly/sdk/server/LDClientTest.java | 2 +- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClient.java b/src/main/java/com/launchdarkly/sdk/server/LDClient.java index 5ec07ee16..71c14c47f 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClient.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClient.java @@ -340,12 +340,12 @@ public boolean boolVariation(String featureKey, LDUser user, boolean defaultValu } @Override - public Integer intVariation(String featureKey, LDUser user, int defaultValue) { + public int intVariation(String featureKey, LDUser user, int defaultValue) { return evaluate(featureKey, user, LDValue.of(defaultValue), true).intValue(); } @Override - public Double doubleVariation(String featureKey, LDUser user, Double defaultValue) { + public double doubleVariation(String featureKey, LDUser user, double defaultValue) { return evaluate(featureKey, user, LDValue.of(defaultValue), true).doubleValue(); } diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/LDClientInterface.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/LDClientInterface.java index 46c714d09..4906359d2 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/LDClientInterface.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/LDClientInterface.java @@ -105,7 +105,7 @@ public interface LDClientInterface extends Closeable { * @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 */ - Integer intVariation(String featureKey, LDUser user, int defaultValue); + int intVariation(String featureKey, LDUser user, int defaultValue); /** * Calculates the floating point numeric value of a feature flag for a given user. @@ -115,7 +115,7 @@ public interface LDClientInterface extends Closeable { * @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 */ - Double doubleVariation(String featureKey, LDUser user, Double defaultValue); + double doubleVariation(String featureKey, LDUser user, double defaultValue); /** * Calculates the String value of a feature flag for a given user. diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java index 989dda8e9..2917d9fe0 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java @@ -75,14 +75,14 @@ public void boolVariationReturnsDefaultValueForWrongType() throws Exception { public void intVariationReturnsFlagValue() throws Exception { upsertFlag(dataStore, flagWithValue("key", LDValue.of(2))); - assertEquals(new Integer(2), client.intVariation("key", user, 1)); + assertEquals(2, client.intVariation("key", user, 1)); } @Test public void intVariationReturnsFlagValueEvenIfEncodedAsDouble() throws Exception { upsertFlag(dataStore, flagWithValue("key", LDValue.of(2.0))); - assertEquals(new Integer(2), client.intVariation("key", user, 1)); + assertEquals(2, client.intVariation("key", user, 1)); } @Test @@ -92,48 +92,48 @@ public void intVariationFromDoubleRoundsTowardZero() throws Exception { upsertFlag(dataStore, flagWithValue("flag3", LDValue.of(-2.25))); upsertFlag(dataStore, flagWithValue("flag4", LDValue.of(-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)); + assertEquals(2, client.intVariation("flag1", user, 1)); + assertEquals(2, client.intVariation("flag2", user, 1)); + assertEquals(-2, client.intVariation("flag3", user, 1)); + assertEquals(-2, client.intVariation("flag4", user, 1)); } @Test public void intVariationReturnsDefaultValueForUnknownFlag() throws Exception { - assertEquals(new Integer(1), client.intVariation("key", user, 1)); + assertEquals(1, client.intVariation("key", user, 1)); } @Test public void intVariationReturnsDefaultValueForWrongType() throws Exception { upsertFlag(dataStore, flagWithValue("key", LDValue.of("wrong"))); - assertEquals(new Integer(1), client.intVariation("key", user, 1)); + assertEquals(1, client.intVariation("key", user, 1)); } @Test public void doubleVariationReturnsFlagValue() throws Exception { upsertFlag(dataStore, flagWithValue("key", LDValue.of(2.5d))); - assertEquals(new Double(2.5d), client.doubleVariation("key", user, 1.0d)); + assertEquals(2.5d, client.doubleVariation("key", user, 1.0d), 0d); } @Test public void doubleVariationReturnsFlagValueEvenIfEncodedAsInt() throws Exception { upsertFlag(dataStore, flagWithValue("key", LDValue.of(2))); - assertEquals(new Double(2.0d), client.doubleVariation("key", user, 1.0d)); + assertEquals(2.0d, client.doubleVariation("key", user, 1.0d), 0d); } @Test public void doubleVariationReturnsDefaultValueForUnknownFlag() throws Exception { - assertEquals(new Double(1.0d), client.doubleVariation("key", user, 1.0d)); + assertEquals(1.0d, client.doubleVariation("key", user, 1.0d), 0d); } @Test public void doubleVariationReturnsDefaultValueForWrongType() throws Exception { upsertFlag(dataStore, flagWithValue("key", LDValue.of("wrong"))); - assertEquals(new Double(1.0d), client.doubleVariation("key", user, 1.0d)); + assertEquals(1.0d, client.doubleVariation("key", user, 1.0d), 0d); } @Test diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java index e00ec126e..6f50cf03d 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java @@ -368,7 +368,7 @@ public void evaluationUsesStoreIfStoreIsInitializedButClientIsNot() throws Excep client = createMockClient(config); upsertFlag(testDataStore, flagWithValue("key", LDValue.of(1))); - assertEquals(new Integer(1), client.intVariation("key", new LDUser("user"), 0)); + assertEquals(1, client.intVariation("key", new LDUser("user"), 0)); verifyAll(); } From 472dfc73a27ecc1e460adeeb7ac1edbc24cc14d9 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Sat, 23 May 2020 14:43:58 -0700 Subject: [PATCH 485/673] better unit test coverage of LDClient and FeatureFlagsState --- .../launchdarkly/sdk/server/Evaluator.java | 12 ++ .../sdk/server/FeatureFlagsState.java | 4 +- .../com/launchdarkly/sdk/server/LDClient.java | 13 +- .../sdk/server/EvaluatorTest.java | 7 + .../sdk/server/FeatureFlagsStateTest.java | 99 +++++++++++ .../sdk/server/LDClientEndToEndTest.java | 61 +++++++ .../sdk/server/LDClientEvaluationTest.java | 162 +++++++++++++++++- .../sdk/server/LDClientEventTest.java | 19 ++ .../sdk/server/LDClientOfflineTest.java | 12 -- .../launchdarkly/sdk/server/LDClientTest.java | 73 +++++--- .../sdk/server/TestComponents.java | 7 +- 11 files changed, 420 insertions(+), 49 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/Evaluator.java b/src/main/java/com/launchdarkly/sdk/server/Evaluator.java index b0127f1b0..526357f35 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Evaluator.java +++ b/src/main/java/com/launchdarkly/sdk/server/Evaluator.java @@ -25,6 +25,14 @@ */ class Evaluator { private final static Logger logger = LoggerFactory.getLogger(Evaluator.class); + + /** + * This key cannot exist in LaunchDarkly because it contains invalid characters. We use it in tests as a way to + * simulate an unexpected RuntimeException during flag evaluations. We check for it by reference equality, so + * the tests must use this exact constant. + */ + static final String INVALID_FLAG_KEY_THAT_THROWS_EXCEPTION = "$ test error flag $"; + static final RuntimeException EXPECTED_EXCEPTION_FROM_INVALID_FLAG = new RuntimeException("deliberate test error"); private final Getters getters; @@ -112,6 +120,10 @@ private void setPrerequisiteEvents(List prerequisiteEvents * @return an {@link EvalResult} - guaranteed non-null */ EvalResult evaluate(DataModel.FeatureFlag flag, LDUser user, EventFactory eventFactory) { + if (flag.getKey() == INVALID_FLAG_KEY_THAT_THROWS_EXCEPTION) { + throw EXPECTED_EXCEPTION_FROM_INVALID_FLAG; + } + 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", flag.getKey()); diff --git a/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java b/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java index 2d0d17d4d..7e9eca8da 100644 --- a/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java +++ b/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java @@ -61,6 +61,7 @@ public boolean equals(Object other) { if (other instanceof FlagMetadata) { FlagMetadata o = (FlagMetadata)other; return Objects.equals(variation, o.variation) && + Objects.equals(reason, o.reason) && Objects.equals(version, o.version) && Objects.equals(trackEvents, o.trackEvents) && Objects.equals(debugEventsUntilDate, o.debugEventsUntilDate); @@ -93,7 +94,8 @@ public boolean isValid() { /** * 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 + * @return the flag's JSON value; {@link LDValue#ofNull()} if the flag returned the default value; + * {@code null} if there was no such flag */ public LDValue getFlagValue(String key) { return flagValues.get(key); diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClient.java b/src/main/java/com/launchdarkly/sdk/server/LDClient.java index 7194be259..b6667d847 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClient.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClient.java @@ -95,6 +95,8 @@ public final class LDClient implements LDClientInterface { * @see LDClient#LDClient(String, LDConfig) */ public LDClient(String sdkKey) { + // COVERAGE: this constructor cannot be called in unit tests because it uses the default base + // URI and will attempt to make a live connection to LaunchDarkly. this(sdkKey, LDConfig.DEFAULT); } @@ -309,7 +311,15 @@ public FeatureFlagsState allFlagsState(LDUser user, FlagsStateOption... options) } boolean clientSideOnly = FlagsStateOption.hasOption(options, FlagsStateOption.CLIENT_SIDE_ONLY); - KeyedItems flags = dataStore.getAll(FEATURES); + KeyedItems flags; + try { + flags = dataStore.getAll(FEATURES); + } catch (Exception e) { + logger.error("Exception from data store when evaluating all flags: {}", e.toString()); + logger.debug(e.toString(), e); + return builder.valid(false).build(); + } + for (Map.Entry entry : flags.getItems()) { if (entry.getValue().getItem() == null) { continue; // deleted flag placeholder @@ -531,6 +541,7 @@ public String secureModeHash(LDUser user) { mac.init(new SecretKeySpec(sdkKey.getBytes(), HMAC_ALGORITHM)); return Hex.encodeHexString(mac.doFinal(user.getKey().getBytes("UTF8"))); } catch (InvalidKeyException | UnsupportedEncodingException | NoSuchAlgorithmException e) { + // COVERAGE: there is no way to cause these errors in a unit test. logger.error("Could not generate secure mode hash: {}", e.toString()); logger.debug(e.toString(), e); } diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java index bf2fc214d..49a112a0d 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java @@ -384,4 +384,11 @@ public void flagMatchesUserFromRules() { assertEquals(fromValue(MATCH_VALUE, MATCH_VARIATION, EvaluationReason.ruleMatch(1, "ruleid1")), result.getDetails()); assertThat(result.getPrerequisiteEvents(), emptyIterable()); } + + @Test(expected=RuntimeException.class) + public void canSimulateErrorUsingTestInstrumentationFlagKey() { + // Other tests rely on the ability to simulate an exception in this way + DataModel.FeatureFlag badFlag = flagBuilder(Evaluator.INVALID_FLAG_KEY_THAT_THROWS_EXCEPTION).build(); + BASE_EVALUATOR.evaluate(badFlag, BASE_USER, EventFactory.DEFAULT); + } } diff --git a/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java b/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java index d442141b6..279fb019b 100644 --- a/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java @@ -10,10 +10,17 @@ import org.junit.Test; +import java.util.ArrayList; +import java.util.List; + import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; import static com.launchdarkly.sdk.EvaluationReason.ErrorKind.MALFORMED_FLAG; +import static com.launchdarkly.sdk.server.FlagsStateOption.DETAILS_ONLY_FOR_TRACKED_FLAGS; +import static com.launchdarkly.sdk.server.FlagsStateOption.WITH_REASONS; import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; @SuppressWarnings("javadoc") @@ -60,6 +67,28 @@ public void reasonIsNullIfReasonsWereNotRecorded() { assertNull(state.getFlagReason("key")); } + @Test + public void flagIsTreatedAsTrackedIfDebugEventsUntilDateIsInFuture() { + Evaluator.EvalResult eval = new Evaluator.EvalResult(LDValue.of("value1"), 1, EvaluationReason.off()); + DataModel.FeatureFlag flag = flagBuilder("key").debugEventsUntilDate(System.currentTimeMillis() + 1000000).build(); + FeatureFlagsState state = new FeatureFlagsState.Builder( + FlagsStateOption.WITH_REASONS, DETAILS_ONLY_FOR_TRACKED_FLAGS + ).addFlag(flag, eval).build(); + + assertNotNull(state.getFlagReason("key")); + } + + @Test + public void flagIsNotTreatedAsTrackedIfDebugEventsUntilDateIsInPast() { + Evaluator.EvalResult eval = new Evaluator.EvalResult(LDValue.of("value1"), 1, EvaluationReason.off()); + DataModel.FeatureFlag flag = flagBuilder("key").debugEventsUntilDate(System.currentTimeMillis() - 1000000).build(); + FeatureFlagsState state = new FeatureFlagsState.Builder( + FlagsStateOption.WITH_REASONS, DETAILS_ONLY_FOR_TRACKED_FLAGS + ).addFlag(flag, eval).build(); + + assertNull(state.getFlagReason("key")); + } + @Test public void flagCanHaveNullValue() { Evaluator.EvalResult eval = new Evaluator.EvalResult(LDValue.ofNull(), 1, null); @@ -82,6 +111,76 @@ public void canConvertToValuesMap() { assertEquals(expected, state.toValuesMap()); } + @Test + public void equalInstancesAreEqual() { + DataModel.FeatureFlag flag1 = flagBuilder("key1").build(); + DataModel.FeatureFlag flag2 = flagBuilder("key2").build(); + Evaluator.EvalResult eval1a = new Evaluator.EvalResult(LDValue.of("value1"), 0, EvaluationReason.off()); + Evaluator.EvalResult eval1b = new Evaluator.EvalResult(LDValue.of("value1"), 0, EvaluationReason.off()); + Evaluator.EvalResult eval2a = new Evaluator.EvalResult(LDValue.of("value2"), 1, EvaluationReason.fallthrough()); + Evaluator.EvalResult eval2b = new Evaluator.EvalResult(LDValue.of("value2"), 1, EvaluationReason.fallthrough()); + Evaluator.EvalResult eval3a = new Evaluator.EvalResult(LDValue.of("value1"), 1, EvaluationReason.off()); + + FeatureFlagsState justOneFlag = new FeatureFlagsState.Builder() + .addFlag(flag1, eval1a).build(); + FeatureFlagsState sameFlagsDifferentInstances1 = new FeatureFlagsState.Builder(WITH_REASONS) + .addFlag(flag1, eval1a).addFlag(flag2, eval2a).build(); + FeatureFlagsState sameFlagsDifferentInstances2 = new FeatureFlagsState.Builder(WITH_REASONS) + .addFlag(flag2, eval2b).addFlag(flag1, eval1b).build(); + FeatureFlagsState sameFlagsDifferentMetadata = new FeatureFlagsState.Builder(WITH_REASONS) + .addFlag(flag1, eval3a).addFlag(flag2, eval2a).build(); + FeatureFlagsState noFlagsButValid = new FeatureFlagsState.Builder(WITH_REASONS).build(); + FeatureFlagsState noFlagsAndNotValid = new FeatureFlagsState.Builder(WITH_REASONS).valid(false).build(); + + assertEquals(sameFlagsDifferentInstances1, sameFlagsDifferentInstances2); + assertEquals(sameFlagsDifferentInstances1.hashCode(), sameFlagsDifferentInstances2.hashCode()); + assertNotEquals(justOneFlag, sameFlagsDifferentInstances1); + assertNotEquals(sameFlagsDifferentInstances1, sameFlagsDifferentMetadata); + + assertNotEquals(noFlagsButValid, noFlagsAndNotValid); + assertNotEquals(noFlagsButValid, ""); + } + + @Test + public void equalMetadataInstancesAreEqual() { + // Testing this various cases is easier at a low level - equalInstancesAreEqual() above already + // verifies that we test for metadata equality in general + List allPermutations = new ArrayList<>(); + for (Integer variation: new Integer[] { null, 0, 1 }) { + for (EvaluationReason reason: new EvaluationReason[] { null, EvaluationReason.off(), EvaluationReason.fallthrough() }) { + for (Integer version: new Integer[] { null, 10, 11 }) { + for (boolean trackEvents: new boolean[] { false, true }) { + for (Long debugEventsUntilDate: new Long[] { null, 1000L, 1001L }) { + FeatureFlagsState.FlagMetadata m1 = new FeatureFlagsState.FlagMetadata( + variation, reason, version, trackEvents, debugEventsUntilDate); + FeatureFlagsState.FlagMetadata m2 = new FeatureFlagsState.FlagMetadata( + variation, reason, version, trackEvents, debugEventsUntilDate); + assertEquals(m1, m2); + assertEquals(m2, m1); + assertNotEquals(m1, null); + assertNotEquals(m1, "x"); + allPermutations.add(m1); + } + } + } + } + } + for (int i = 0; i < allPermutations.size(); i++) { + for (int j = 0; j < allPermutations.size(); j++) { + if (i != j) { + assertNotEquals(allPermutations.get(i), allPermutations.get(j)); + } + } + } + } + + @Test + public void optionsHaveHumanReadableNames() { + assertEquals("CLIENT_SIDE_ONLY", FlagsStateOption.CLIENT_SIDE_ONLY.toString()); + assertEquals("WITH_REASONS", FlagsStateOption.WITH_REASONS.toString()); + assertEquals("DETAILS_ONLY_FOR_TRACKED_FLAGS", FlagsStateOption.DETAILS_ONLY_FOR_TRACKED_FLAGS.toString()); + } + @Test public void canConvertToJson() { String actualJsonString = JsonSerialization.serialize(makeInstanceForSerialization()); diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientEndToEndTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientEndToEndTest.java index c7f19e76d..57a188aa3 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientEndToEndTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientEndToEndTest.java @@ -7,6 +7,8 @@ import org.junit.Test; +import java.net.URI; + import static com.launchdarkly.sdk.server.Components.externalUpdatesOnly; import static com.launchdarkly.sdk.server.Components.noEvents; import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; @@ -15,10 +17,15 @@ import static com.launchdarkly.sdk.server.TestHttpUtil.httpsServerWithSelfSignedCert; import static com.launchdarkly.sdk.server.TestHttpUtil.jsonResponse; import static com.launchdarkly.sdk.server.TestHttpUtil.makeStartedServer; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.nullValue; +import static org.hamcrest.Matchers.startsWith; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; +import okhttp3.HttpUrl; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; import okhttp3.mockwebserver.RecordedRequest; @@ -143,6 +150,60 @@ public void clientStartsInStreamingModeWithSelfSignedCert() throws Exception { } } + @Test + public void clientUsesProxy() throws Exception { + URI fakeBaseUri = URI.create("http://not-a-real-host"); + MockResponse resp = jsonResponse(makeAllDataJson()); + + try (MockWebServer server = makeStartedServer(resp)) { + HttpUrl serverUrl = server.url("/"); + LDConfig config = new LDConfig.Builder() + .http(Components.httpConfiguration() + .proxyHostAndPort(serverUrl.host(), serverUrl.port())) + .dataSource(Components.pollingDataSource().baseURI(fakeBaseUri)) + .events(Components.noEvents()) + .build(); + + try (LDClient client = new LDClient(sdkKey, config)) { + assertTrue(client.isInitialized()); + + RecordedRequest req = server.takeRequest(); + assertThat(req.getRequestLine(), startsWith("GET " + fakeBaseUri + "/sdk/latest-all")); + assertThat(req.getHeader("Proxy-Authorization"), nullValue()); + } + } + } + + @Test + public void clientUsesProxyWithBasicAuth() throws Exception { + URI fakeBaseUri = URI.create("http://not-a-real-host"); + MockResponse challengeResp = new MockResponse().setResponseCode(407).setHeader("Proxy-Authenticate", "Basic realm=x"); + MockResponse resp = jsonResponse(makeAllDataJson()); + + try (MockWebServer server = makeStartedServer(challengeResp, resp)) { + HttpUrl serverUrl = server.url("/"); + LDConfig config = new LDConfig.Builder() + .http(Components.httpConfiguration() + .proxyHostAndPort(serverUrl.host(), serverUrl.port()) + .proxyAuth(Components.httpBasicAuthentication("user", "pass"))) + .dataSource(Components.pollingDataSource().baseURI(fakeBaseUri)) + .events(Components.noEvents()) + .build(); + + try (LDClient client = new LDClient(sdkKey, config)) { + assertTrue(client.isInitialized()); + + RecordedRequest req1 = server.takeRequest(); + assertThat(req1.getRequestLine(), startsWith("GET " + fakeBaseUri + "/sdk/latest-all")); + assertThat(req1.getHeader("Proxy-Authorization"), nullValue()); + + RecordedRequest req2 = server.takeRequest(); + assertThat(req2.getRequestLine(), equalTo(req1.getRequestLine())); + assertThat(req2.getHeader("Proxy-Authorization"), equalTo("Basic dXNlcjpwYXNz")); + } + } + } + @Test public void clientSendsAnalyticsEvent() throws Exception { MockResponse resp = new MockResponse().setResponseCode(202); diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java index 2917d9fe0..00d553f2c 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java @@ -8,8 +8,8 @@ import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.server.interfaces.DataStore; -import com.launchdarkly.sdk.server.interfaces.LDClientInterface; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.LDClientInterface; import org.junit.Test; @@ -19,6 +19,8 @@ import static com.google.common.collect.Iterables.getFirst; import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.Evaluator.EXPECTED_EXCEPTION_FROM_INVALID_FLAG; +import static com.launchdarkly.sdk.server.Evaluator.INVALID_FLAG_KEY_THAT_THROWS_EXCEPTION; import static com.launchdarkly.sdk.server.ModelBuilders.booleanFlagWithClauses; import static com.launchdarkly.sdk.server.ModelBuilders.clause; import static com.launchdarkly.sdk.server.ModelBuilders.fallthroughVariation; @@ -166,6 +168,13 @@ public void stringVariationReturnsDefaultValueForWrongType() throws Exception { assertEquals("a", client.stringVariation("key", user, "a")); } + + @Test + public void stringVariationWithNullDefaultReturnsDefaultValueForWrongType() throws Exception { + upsertFlag(dataStore, flagWithValue("key", LDValue.of(true))); + + assertNull(client.stringVariation("key", user, null)); + } @Test public void jsonValueVariationReturnsFlagValue() throws Exception { @@ -183,7 +192,8 @@ public void jsonValueVariationReturnsDefaultValueForUnknownFlag() throws Excepti @Test public void canMatchUserBySegment() throws Exception { - // This is similar to one of the tests in FeatureFlagTest, but more end-to-end + // This is similar to EvaluatorSegmentMatchTest, but more end-to-end - we're verifying that + // the client is forwarding the Evaluator's segment queries to the data store DataModel.Segment segment = segmentBuilder("segment1") .version(1) .included(user.getKey()) @@ -196,7 +206,19 @@ public void canMatchUserBySegment() throws Exception { assertTrue(client.boolVariation("feature", user, false)); } - + + @Test + public void canTryToMatchUserBySegmentWhenSegmentIsNotFound() throws Exception { + // This is similar to EvaluatorSegmentMatchTest, but more end-to-end - we're verifying that + // the client is forwarding the Evaluator's segment queries to the data store, and that we + // don't blow up if the segment is missing. + DataModel.Clause clause = clause(null, DataModel.Operator.segmentMatch, LDValue.of("segment1")); + DataModel.FeatureFlag feature = booleanFlagWithClauses("feature", clause); + upsertFlag(dataStore, feature); + + assertFalse(client.boolVariation("feature", user, false)); + } + @Test public void canGetDetailsForSuccessfulEvaluation() throws Exception { upsertFlag(dataStore, flagWithValue("key", LDValue.of(true))); @@ -207,11 +229,21 @@ public void canGetDetailsForSuccessfulEvaluation() throws Exception { } @Test - public void variationReturnsDefaultIfFlagEvaluatesToNull() { - DataModel.FeatureFlag flag = flagBuilder("key").on(false).offVariation(null).build(); + public void jsonVariationReturnsNullIfFlagEvaluatesToNull() { + DataModel.FeatureFlag flag = flagBuilder("key").on(false).offVariation(0).variations(LDValue.ofNull()).build(); + upsertFlag(dataStore, flag); + + assertEquals(LDValue.ofNull(), client.jsonValueVariation("key", user, LDValue.buildObject().build())); + } + + @Test + public void typedVariationReturnsZeroValueForTypeIfFlagEvaluatesToNull() { + DataModel.FeatureFlag flag = flagBuilder("key").on(false).offVariation(0).variations(LDValue.ofNull()).build(); upsertFlag(dataStore, flag); - assertEquals("default", client.stringVariation("key", user, "default")); + assertEquals(false, client.boolVariation("key", user, true)); + assertEquals(0, client.intVariation("key", user, 1)); + assertEquals(0d, client.doubleVariation("key", user, 1.0d), 0d); } @Test @@ -225,6 +257,15 @@ public void variationDetailReturnsDefaultIfFlagEvaluatesToNull() { assertEquals(expected, actual); assertTrue(actual.isDefaultValue()); } + + @Test + public void deletedFlagPlaceholderIsTreatedAsUnknownFlag() { + DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of("hello")); + upsertFlag(dataStore, flag); + dataStore.upsert(DataModel.FEATURES, flag.getKey(), ItemDescriptor.deletedItem(flag.getVersion() + 1)); + + assertEquals("default", client.stringVariation(flag.getKey(), user, "default")); + } @Test public void appropriateErrorIfClientNotInitialized() throws Exception { @@ -257,6 +298,16 @@ public void appropriateErrorIfUserNotSpecified() throws Exception { EvaluationReason.error(EvaluationReason.ErrorKind.USER_NOT_SPECIFIED)); assertEquals(expectedResult, client.stringVariationDetail("key", null, "default")); } + + @Test + public void appropriateErrorIfUserHasNullKey() throws Exception { + LDUser userWithNullKey = new LDUser(null); + upsertFlag(dataStore, flagWithValue("key", LDValue.of(true))); + + EvaluationDetail expectedResult = EvaluationDetail.fromValue("default", NO_VARIATION, + EvaluationReason.error(EvaluationReason.ErrorKind.USER_NOT_SPECIFIED)); + assertEquals(expectedResult, client.stringVariationDetail("key", userWithNullKey, "default")); + } @Test public void appropriateErrorIfValueWrongType() throws Exception { @@ -268,7 +319,7 @@ public void appropriateErrorIfValueWrongType() throws Exception { } @Test - public void appropriateErrorForUnexpectedException() throws Exception { + public void appropriateErrorForUnexpectedExceptionFromDataStore() throws Exception { RuntimeException exception = new RuntimeException("sorry"); DataStore badDataStore = dataStoreThatThrowsException(exception); LDConfig badConfig = new LDConfig.Builder() @@ -282,7 +333,41 @@ public void appropriateErrorForUnexpectedException() throws Exception { assertEquals(expectedResult, badClient.boolVariationDetail("key", user, false)); } } + + @Test + public void appropriateErrorForUnexpectedExceptionFromFlagEvaluation() throws Exception { + upsertFlag(dataStore, flagWithValue(INVALID_FLAG_KEY_THAT_THROWS_EXCEPTION, LDValue.of(true))); + + EvaluationDetail expectedResult = EvaluationDetail.fromValue(false, NO_VARIATION, + EvaluationReason.exception(EXPECTED_EXCEPTION_FROM_INVALID_FLAG)); + assertEquals(expectedResult, client.boolVariationDetail(INVALID_FLAG_KEY_THAT_THROWS_EXCEPTION, user, false)); + } + @Test + public void canEvaluateWithNonNullButEmptyUserKey() throws Exception { + LDUser userWithEmptyKey = new LDUser(""); + upsertFlag(dataStore, flagWithValue("key", LDValue.of(true))); + + assertEquals(true, client.boolVariation("key", userWithEmptyKey, false)); + } + + @Test + public void evaluationUsesStoreIfStoreIsInitializedButClientIsNot() throws Exception { + upsertFlag(dataStore, flagWithValue("key", LDValue.of("value"))); + LDConfig customConfig = new LDConfig.Builder() + .dataStore(specificDataStore(dataStore)) + .events(Components.noEvents()) + .dataSource(specificDataSource(failedDataSource())) + .startWait(Duration.ZERO) + .build(); + + try (LDClient client = new LDClient("SDK_KEY", customConfig)) { + assertFalse(client.isInitialized()); + + assertEquals("value", client.stringVariation("key", user, "")); + } + } + @Test public void allFlagsStateReturnsState() throws Exception { DataModel.FeatureFlag flag1 = flagBuilder("key1") @@ -458,4 +543,67 @@ public void allFlagsStateReturnsEmptyStateForNullUserKey() throws Exception { assertFalse(state.isValid()); assertEquals(0, state.toValuesMap().size()); } + + @Test + public void allFlagsStateReturnsEmptyStateIfDataStoreThrowsException() throws Exception { + LDConfig customConfig = new LDConfig.Builder() + .dataStore(specificDataStore(TestComponents.dataStoreThatThrowsException(new RuntimeException("sorry")))) + .events(Components.noEvents()) + .dataSource(Components.externalUpdatesOnly()) + .startWait(Duration.ZERO) + .build(); + + try (LDClient client = new LDClient("SDK_KEY", customConfig)) { + FeatureFlagsState state = client.allFlagsState(user); + assertFalse(state.isValid()); + assertEquals(0, state.toValuesMap().size()); + } + } + + @Test + public void allFlagsStateUsesNullValueForFlagIfEvaluationThrowsException() throws Exception { + upsertFlag(dataStore, flagWithValue("goodkey", LDValue.of("value"))); + upsertFlag(dataStore, flagWithValue(INVALID_FLAG_KEY_THAT_THROWS_EXCEPTION, LDValue.of("nope"))); + + FeatureFlagsState state = client.allFlagsState(user); + assertTrue(state.isValid()); + assertEquals(2, state.toValuesMap().size()); + assertEquals(LDValue.of("value"), state.getFlagValue("goodkey")); + assertEquals(LDValue.ofNull(), state.getFlagValue(INVALID_FLAG_KEY_THAT_THROWS_EXCEPTION)); + } + + @Test + public void allFlagsStateUsesStoreDataIfStoreIsInitializedButClientIsNot() throws Exception { + upsertFlag(dataStore, flagWithValue("key", LDValue.of("value"))); + LDConfig customConfig = new LDConfig.Builder() + .dataStore(specificDataStore(dataStore)) + .events(Components.noEvents()) + .dataSource(specificDataSource(failedDataSource())) + .startWait(Duration.ZERO) + .build(); + + try (LDClient client = new LDClient("SDK_KEY", customConfig)) { + assertFalse(client.isInitialized()); + + FeatureFlagsState state = client.allFlagsState(user); + assertTrue(state.isValid()); + assertEquals(LDValue.of("value"), state.getFlagValue("key")); + } + } + + @Test + public void allFlagsStateReturnsEmptyStateIfClientAndStoreAreNotInitialized() throws Exception { + LDConfig customConfig = new LDConfig.Builder() + .events(Components.noEvents()) + .dataSource(specificDataSource(failedDataSource())) + .startWait(Duration.ZERO) + .build(); + + try (LDClient client = new LDClient("SDK_KEY", customConfig)) { + assertFalse(client.isInitialized()); + + FeatureFlagsState state = client.allFlagsState(user); + assertFalse(state.isValid()); + } + } } diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientEventTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientEventTest.java index 44ab39243..cdb07a85a 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientEventTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientEventTest.java @@ -110,12 +110,24 @@ public void trackSendsEventWithDataAndMetricValue() throws Exception { public void trackWithNullUserDoesNotSendEvent() { client.track("eventkey", null); assertEquals(0, eventSink.events.size()); + + client.trackData("eventkey", null, LDValue.of(1)); + assertEquals(0, eventSink.events.size()); + + client.trackMetric("eventkey", null, LDValue.of(1), 1.5); + assertEquals(0, eventSink.events.size()); } @Test public void trackWithUserWithNoKeyDoesNotSendEvent() { client.track("eventkey", userWithNullKey); assertEquals(0, eventSink.events.size()); + + client.trackData("eventkey", userWithNullKey, LDValue.of(1)); + assertEquals(0, eventSink.events.size()); + + client.trackMetric("eventkey", userWithNullKey, LDValue.of(1), 1.5); + assertEquals(0, eventSink.events.size()); } @Test @@ -474,6 +486,13 @@ public void failureReasonIsGivenForUnknownPrererequisiteFlagIfDetailsWereRequest EvaluationReason.prerequisiteFailed("feature1")); } + @Test + public void canFlush() { + assertEquals(0, eventSink.flushCount); + client.flush(); + assertEquals(1, eventSink.flushCount); + } + private void checkFeatureEvent(Event e, DataModel.FeatureFlag flag, LDValue value, LDValue defaultVal, String prereqOf, EvaluationReason reason) { assertEquals(Event.FeatureRequest.class, e.getClass()); diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientOfflineTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientOfflineTest.java index ceb7d1a62..7e0eed761 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientOfflineTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientOfflineTest.java @@ -5,7 +5,6 @@ import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStore; -import com.launchdarkly.sdk.server.interfaces.LDClientInterface; import org.junit.Test; @@ -78,15 +77,4 @@ public void offlineClientGetsFlagsStateFromDataStore() throws IOException { assertEquals(ImmutableMap.of("key", LDValue.of(true)), state.toValuesMap()); } } - - @Test - public void testSecureModeHash() throws IOException { - LDConfig config = new LDConfig.Builder() - .offline(true) - .build(); - try (LDClientInterface client = new LDClient("secret", config)) { - LDUser user = new LDUser.Builder("Message").build(); - assertEquals("aa747c502a898200f9e4fa21bac68136f886a0e27aec70ba06daf2e2a5cb5597", client.secureModeHash(user)); - } - } } \ No newline at end of file diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java index ecf6a6681..80c19dd6e 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java @@ -5,9 +5,8 @@ import com.launchdarkly.sdk.server.interfaces.ClientContext; import com.launchdarkly.sdk.server.interfaces.DataSource; import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; -import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; -import com.launchdarkly.sdk.server.interfaces.Event; +import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.EventProcessor; import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; import com.launchdarkly.sdk.server.interfaces.LDClientInterface; @@ -26,16 +25,15 @@ import java.util.concurrent.TimeoutException; import static com.launchdarkly.sdk.server.ModelBuilders.flagWithValue; +import static com.launchdarkly.sdk.server.TestComponents.dataStoreThatThrowsException; import static com.launchdarkly.sdk.server.TestComponents.failedDataSource; import static com.launchdarkly.sdk.server.TestComponents.initedDataStore; import static com.launchdarkly.sdk.server.TestComponents.specificDataSource; import static com.launchdarkly.sdk.server.TestComponents.specificDataStore; import static com.launchdarkly.sdk.server.TestComponents.specificEventProcessor; import static com.launchdarkly.sdk.server.TestUtil.upsertFlag; -import static org.easymock.EasyMock.anyObject; import static org.easymock.EasyMock.capture; import static org.easymock.EasyMock.expect; -import static org.easymock.EasyMock.expectLastCall; import static org.easymock.EasyMock.isA; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -45,8 +43,6 @@ 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. */ @@ -98,6 +94,7 @@ public void constructorThrowsExceptionForNullConfig() throws Exception { public void clientHasDefaultEventProcessorWithDefaultConfig() throws Exception { LDConfig config = new LDConfig.Builder() .dataSource(Components.externalUpdatesOnly()) + .diagnosticOptOut(true) .build(); try (LDClient client = new LDClient("SDK_KEY", config)) { assertEquals(DefaultEventProcessor.class, client.eventProcessor.getClass()); @@ -109,6 +106,7 @@ public void clientHasDefaultEventProcessorWithSendEvents() throws Exception { LDConfig config = new LDConfig.Builder() .dataSource(Components.externalUpdatesOnly()) .events(Components.sendEvents()) + .diagnosticOptOut(true) .build(); try (LDClient client = new LDClient("SDK_KEY", config)) { assertEquals(DefaultEventProcessor.class, client.eventProcessor.getClass()); @@ -130,6 +128,7 @@ public void clientHasNullEventProcessorWithNoEvents() throws Exception { public void streamingClientHasStreamProcessor() throws Exception { LDConfig config = new LDConfig.Builder() .dataSource(Components.streamingDataSource().baseURI(URI.create("http://fake"))) + .events(Components.noEvents()) .startWait(Duration.ZERO) .build(); try (LDClient client = new LDClient(SDK_KEY, config)) { @@ -141,6 +140,7 @@ public void streamingClientHasStreamProcessor() throws Exception { public void pollingClientHasPollingProcessor() throws IOException { LDConfig config = new LDConfig.Builder() .dataSource(Components.pollingDataSource().baseURI(URI.create("http://fake"))) + .events(Components.noEvents()) .startWait(Duration.ZERO) .build(); try (LDClient client = new LDClient(SDK_KEY, config)) { @@ -240,7 +240,7 @@ public void noWaitForDataSourceIfWaitMillisIsZero() throws Exception { } @Test - public void willWaitForDataSourceIfWaitMillisIsNonZero() throws Exception { + public void willWaitForDataSourceIfWaitMillisIsGreaterThanZero() throws Exception { LDConfig.Builder config = new LDConfig.Builder() .startWait(Duration.ofMillis(10)); @@ -255,6 +255,21 @@ public void willWaitForDataSourceIfWaitMillisIsNonZero() throws Exception { verifyAll(); } + @Test + public void noWaitForDataSourceIfWaitMillisIsNegative() throws Exception { + LDConfig.Builder config = new LDConfig.Builder() + .startWait(Duration.ofMillis(-10)); + + expect(dataSource.start()).andReturn(initFuture); + expect(dataSource.isInitialized()).andReturn(false); + replayAll(); + + client = createMockClient(config); + assertFalse(client.isInitialized()); + + verifyAll(); + } + @Test public void dataSourceCanTimeOut() throws Exception { LDConfig.Builder config = new LDConfig.Builder() @@ -355,24 +370,20 @@ public void isFlagKnownUsesStoreIfStoreIsInitializedButClientIsNot() throws Exce } @Test - public void evaluationUsesStoreIfStoreIsInitializedButClientIsNot() throws Exception { - DataStore testDataStore = initedDataStore(); + public void isFlagKnownCatchesExceptionFromDataStore() throws Exception { + DataStore badStore = dataStoreThatThrowsException(new RuntimeException("sorry")); LDConfig.Builder config = new LDConfig.Builder() - .dataStore(specificDataStore(testDataStore)) - .startWait(Duration.ZERO); + .startWait(Duration.ZERO) + .dataStore(specificDataStore(badStore)); expect(dataSource.start()).andReturn(initFuture); - expect(dataSource.isInitialized()).andReturn(false); - expectEventsSent(1); + expect(dataSource.isInitialized()).andReturn(false).times(1); replayAll(); client = createMockClient(config); - upsertFlag(testDataStore, flagWithValue("key", LDValue.of(1))); - assertEquals(1, client.intVariation("key", new LDUser("user"), 0)); - - verifyAll(); + assertFalse(client.isFlagKnown("key")); } - + @Test public void getVersion() throws Exception { LDConfig config = new LDConfig.Builder() @@ -384,16 +395,26 @@ public void getVersion() throws Exception { } } - private void expectEventsSent(int count) { - eventProcessor.sendEvent(anyObject(Event.class)); - if (count > 0) { - expectLastCall().times(count); - } else { - expectLastCall().andThrow(new AssertionFailedError("should not have queued an event")).anyTimes(); - } + @Test + public void testSecureModeHash() throws IOException { + setupMockDataSourceToInitialize(true); + LDUser user = new LDUser.Builder("userkey").build(); + String expectedHash = "c097a70924341660427c2e487b86efee789210f9e6dafc3b5f50e75bc596ff99"; + + client = createMockClient(new LDConfig.Builder().startWait(Duration.ZERO)); + assertEquals(expectedHash, client.secureModeHash(user)); + + assertNull(client.secureModeHash(null)); + assertNull(client.secureModeHash(new LDUser(null))); + } + + private void setupMockDataSourceToInitialize(boolean willInitialize) { + expect(dataSource.start()).andReturn(initFuture); + expect(dataSource.isInitialized()).andReturn(willInitialize); + replayAll(); } - private LDClientInterface createMockClient(LDConfig.Builder config) { + private LDClient createMockClient(LDConfig.Builder config) { config.dataSource(specificDataSource(dataSource)); config.events(specificEventProcessor(eventProcessor)); return new LDClient(SDK_KEY, config.build()); diff --git a/src/test/java/com/launchdarkly/sdk/server/TestComponents.java b/src/test/java/com/launchdarkly/sdk/server/TestComponents.java index 3a0bb84d7..a34f25b10 100644 --- a/src/test/java/com/launchdarkly/sdk/server/TestComponents.java +++ b/src/test/java/com/launchdarkly/sdk/server/TestComponents.java @@ -122,7 +122,8 @@ public static EventProcessorFactory specificEventProcessor(final EventProcessor } public static class TestEventProcessor implements EventProcessor { - List events = new ArrayList<>(); + volatile List events = new ArrayList<>(); + volatile int flushCount; @Override public void close() throws IOException {} @@ -133,7 +134,9 @@ public void sendEvent(Event e) { } @Override - public void flush() {} + public void flush() { + flushCount++; + } } public static class DataSourceFactoryThatExposesUpdater implements DataSourceFactory { From 3ccd7cb1cc350da832f25de35f8dad39a943abee Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Sun, 24 May 2020 11:13:11 -0700 Subject: [PATCH 486/673] test coverage improvements + minor fixes --- .../launchdarkly/sdk/server/Components.java | 2 + .../sdk/server/EventOutputFormatter.java | 56 ++----- .../sdk/server/EventSummarizer.java | 8 +- .../sdk/server/EventUserSerialization.java | 3 +- .../sdk/server/InMemoryDataStore.java | 9 +- .../launchdarkly/sdk/server/JsonHelpers.java | 2 + .../com/launchdarkly/sdk/server/LDConfig.java | 14 +- .../sdk/server/SemanticVersion.java | 5 +- .../com/launchdarkly/sdk/server/Util.java | 4 +- .../integrations/FileDataSourceImpl.java | 23 ++- .../sdk/server/ClientContextImplTest.java | 154 ++++++++++++++++++ .../sdk/server/DataSourceUpdatesImplTest.java | 3 +- .../DefaultEventProcessorOutputTest.java | 21 +++ .../server/DefaultEventProcessorTestBase.java | 4 + .../sdk/server/DiagnosticEventTest.java | 30 +++- .../sdk/server/EventOutputTest.java | 139 ++++++++++------ .../sdk/server/EventSummarizerTest.java | 127 +++++++++++++++ .../server/EventUserSerializationTest.java | 7 + .../sdk/server/InMemoryDataStoreTest.java | 10 +- .../sdk/server/JsonHelpersTest.java | 121 ++++++++++++++ .../sdk/server/LDClientEventTest.java | 33 ++++ .../launchdarkly/sdk/server/LDConfigTest.java | 89 +++++++++- .../sdk/server/SemanticVersionTest.java | 34 ++++ .../com/launchdarkly/sdk/server/UtilTest.java | 38 +++++ 24 files changed, 809 insertions(+), 127 deletions(-) create mode 100644 src/test/java/com/launchdarkly/sdk/server/ClientContextImplTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/JsonHelpersTest.java diff --git a/src/main/java/com/launchdarkly/sdk/server/Components.java b/src/main/java/com/launchdarkly/sdk/server/Components.java index 4561ee4f9..3a8a7d922 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Components.java +++ b/src/main/java/com/launchdarkly/sdk/server/Components.java @@ -50,6 +50,8 @@ * @since 4.0.0 */ public abstract class Components { + private Components() {} + /** * Returns a configuration object for using the default in-memory implementation of a data store. *

    diff --git a/src/main/java/com/launchdarkly/sdk/server/EventOutputFormatter.java b/src/main/java/com/launchdarkly/sdk/server/EventOutputFormatter.java index 5984cb2a0..9674b965c 100644 --- a/src/main/java/com/launchdarkly/sdk/server/EventOutputFormatter.java +++ b/src/main/java/com/launchdarkly/sdk/server/EventOutputFormatter.java @@ -16,6 +16,8 @@ * 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. + * + * Test coverage for this logic is in EventOutputTest and DefaultEventProcessorOutputTest. */ final class EventOutputFormatter { private final EventsConfiguration config; @@ -26,14 +28,12 @@ final class EventOutputFormatter { this.gson = JsonHelpers.gsonInstanceForEventsSerialization(config); } - int writeOutputEvents(Event[] events, EventSummarizer.EventSummary summary, Writer writer) throws IOException { - int count = 0; + final int writeOutputEvents(Event[] events, EventSummarizer.EventSummary summary, Writer writer) throws IOException { + int count = events.length; try (JsonWriter jsonWriter = new JsonWriter(writer)) { jsonWriter.beginArray(); for (Event event: events) { - if (writeOutputEvent(event, jsonWriter)) { - count++; - } + writeOutputEvent(event, jsonWriter); } if (!summary.isEmpty()) { writeSummaryEvent(summary, jsonWriter); @@ -44,7 +44,7 @@ int writeOutputEvents(Event[] events, EventSummarizer.EventSummary summary, Writ return count; } - private boolean writeOutputEvent(Event event, JsonWriter jw) throws IOException { + private final void writeOutputEvent(Event event, JsonWriter jw) throws IOException { if (event instanceof Event.FeatureRequest) { Event.FeatureRequest fe = (Event.FeatureRequest)event; startEvent(fe, fe.isDebug() ? "debug" : "feature", fe.getKey(), jw); @@ -83,13 +83,10 @@ private boolean writeOutputEvent(Event event, JsonWriter jw) throws IOException startEvent(event, "index", null, jw); writeUser(event.getUser(), jw); jw.endObject(); - } else { - return false; } - return true; } - private void writeSummaryEvent(EventSummarizer.EventSummary summary, JsonWriter jw) throws IOException { + private final void writeSummaryEvent(EventSummarizer.EventSummary summary, JsonWriter jw) throws IOException { jw.beginObject(); jw.name("kind"); @@ -158,7 +155,7 @@ private void writeSummaryEvent(EventSummarizer.EventSummary summary, JsonWriter jw.endObject(); } - private void startEvent(Event event, String kind, String key, JsonWriter jw) throws IOException { + private final void startEvent(Event event, String kind, String key, JsonWriter jw) throws IOException { jw.beginObject(); jw.name("kind"); jw.value(kind); @@ -170,7 +167,7 @@ private void startEvent(Event event, String kind, String key, JsonWriter jw) thr } } - private void writeUserOrKey(Event event, boolean forceInline, JsonWriter jw) throws IOException { + private final void writeUserOrKey(Event event, boolean forceInline, JsonWriter jw) throws IOException { LDUser user = event.getUser(); if (user != null) { if (config.inlineUsersInEvents || forceInline) { @@ -182,14 +179,14 @@ private void writeUserOrKey(Event event, boolean forceInline, JsonWriter jw) thr } } - private void writeUser(LDUser user, JsonWriter jw) throws IOException { + private final 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 gson.toJson(user, LDUser.class, jw); } - private void writeLDValue(String key, LDValue value, JsonWriter jw) throws IOException { + private final void writeLDValue(String key, LDValue value, JsonWriter jw) throws IOException { if (value == null || value.isNull()) { return; } @@ -198,38 +195,11 @@ private void writeLDValue(String key, LDValue value, JsonWriter jw) throws IOExc } // 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 { + private final 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()); - - switch (er.getKind()) { - case ERROR: - jw.name("errorKind"); - jw.value(er.getErrorKind().name()); - break; - case PREREQUISITE_FAILED: - jw.name("prerequisiteKey"); - jw.value(er.getPrerequisiteKey()); - break; - case RULE_MATCH: - jw.name("ruleIndex"); - jw.value(er.getRuleIndex()); - if (er.getRuleId() != null) { - jw.name("ruleId"); - jw.value(er.getRuleId()); - } - break; - default: - break; - } - - jw.endObject(); + gson.toJson(er, EvaluationReason.class, jw); // EvaluationReason defines its own custom serializer } } diff --git a/src/main/java/com/launchdarkly/sdk/server/EventSummarizer.java b/src/main/java/com/launchdarkly/sdk/server/EventSummarizer.java index eaa3bd583..3f6f28b44 100644 --- a/src/main/java/com/launchdarkly/sdk/server/EventSummarizer.java +++ b/src/main/java/com/launchdarkly/sdk/server/EventSummarizer.java @@ -91,12 +91,15 @@ public boolean equals(Object other) { EventSummary o = (EventSummary)other; return o.counters.equals(counters) && startDate == o.startDate && endDate == o.endDate; } - return true; + return false; } @Override public int hashCode() { - return counters.hashCode() + 31 * ((int)startDate + 31 * (int)endDate); + // We can't make meaningful hash codes for EventSummary, because the same counters could be + // represented differently in our Map. It doesn't matter because there's no reason to use an + // EventSummary instance as a hash key. + return 0; } } @@ -157,6 +160,7 @@ public boolean equals(Object other) } return false; } + @Override public String toString() { return "(" + count + "," + flagValue + "," + defaultVal + ")"; diff --git a/src/main/java/com/launchdarkly/sdk/server/EventUserSerialization.java b/src/main/java/com/launchdarkly/sdk/server/EventUserSerialization.java index 5b49a5b47..ab6b190b0 100644 --- a/src/main/java/com/launchdarkly/sdk/server/EventUserSerialization.java +++ b/src/main/java/com/launchdarkly/sdk/server/EventUserSerialization.java @@ -11,7 +11,8 @@ import java.util.Set; import java.util.TreeSet; -class EventUserSerialization { +abstract class EventUserSerialization { + private EventUserSerialization() {} // Used internally when including users in analytics events, to ensure that private attributes are stripped out. static class UserAdapterWithPrivateAttributeBehavior extends TypeAdapter { diff --git a/src/main/java/com/launchdarkly/sdk/server/InMemoryDataStore.java b/src/main/java/com/launchdarkly/sdk/server/InMemoryDataStore.java index b8378a32a..e1ab782d0 100644 --- a/src/main/java/com/launchdarkly/sdk/server/InMemoryDataStore.java +++ b/src/main/java/com/launchdarkly/sdk/server/InMemoryDataStore.java @@ -2,14 +2,12 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; -import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider.CacheStats; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; -import com.launchdarkly.sdk.server.interfaces.DiagnosticDescription; import java.io.IOException; import java.util.HashMap; @@ -22,7 +20,7 @@ * As of version 5.0.0, this is package-private; applications must use the factory method * {@link Components#inMemoryDataStore()}. */ -class InMemoryDataStore implements DataStore, DiagnosticDescription { +class InMemoryDataStore implements DataStore { private volatile ImmutableMap> allData = ImmutableMap.of(); private volatile boolean initialized = false; private Object writeLock = new Object(); @@ -120,9 +118,4 @@ public CacheStats getCacheStats() { public void close() throws IOException { return; } - - @Override - public LDValue describeConfiguration(LDConfig config) { - return LDValue.of("memory"); - } } diff --git a/src/main/java/com/launchdarkly/sdk/server/JsonHelpers.java b/src/main/java/com/launchdarkly/sdk/server/JsonHelpers.java index d5464e8e3..9b7d6a3b7 100644 --- a/src/main/java/com/launchdarkly/sdk/server/JsonHelpers.java +++ b/src/main/java/com/launchdarkly/sdk/server/JsonHelpers.java @@ -22,6 +22,8 @@ import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; abstract class JsonHelpers { + private JsonHelpers() {} + private static final Gson gson = new Gson(); /** diff --git a/src/main/java/com/launchdarkly/sdk/server/LDConfig.java b/src/main/java/com/launchdarkly/sdk/server/LDConfig.java index b9369aaca..665174d1a 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDConfig.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDConfig.java @@ -20,7 +20,7 @@ public final class LDConfig { 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 Duration DEFAULT_START_WAIT = Duration.ofSeconds(5); + static final Duration DEFAULT_START_WAIT = Duration.ofSeconds(5); protected static final LDConfig DEFAULT = new Builder().build(); @@ -49,18 +49,6 @@ protected LDConfig(Builder builder) { this.threadPriority = builder.threadPriority; } - LDConfig(LDConfig config) { - this.dataSourceFactory = config.dataSourceFactory; - this.dataStoreFactory = config.dataStoreFactory; - this.diagnosticOptOut = config.diagnosticOptOut; - this.eventProcessorFactory = config.eventProcessorFactory; - this.httpConfig = config.httpConfig; - this.loggingConfig = config.loggingConfig; - this.offline = config.offline; - this.startWait = config.startWait; - this.threadPriority = config.threadPriority; - } - /** * A builder that helps construct * {@link com.launchdarkly.sdk.server.LDConfig} objects. Builder calls can be chained, enabling the diff --git a/src/main/java/com/launchdarkly/sdk/server/SemanticVersion.java b/src/main/java/com/launchdarkly/sdk/server/SemanticVersion.java index cb5a152cd..c5fd314da 100644 --- a/src/main/java/com/launchdarkly/sdk/server/SemanticVersion.java +++ b/src/main/java/com/launchdarkly/sdk/server/SemanticVersion.java @@ -24,6 +24,7 @@ public InvalidVersionException(String message) { private final int minor; private final int patch; private final String prerelease; + private final String[] prereleaseComponents; private final String build; public SemanticVersion(int major, int minor, int patch, String prerelease, String build) { @@ -31,6 +32,7 @@ public SemanticVersion(int major, int minor, int patch, String prerelease, Strin this.minor = minor; this.patch = patch; this.prerelease = prerelease; + this.prereleaseComponents = prerelease == null ? null : prerelease.split("\\."); this.build = build; } @@ -89,6 +91,7 @@ public static SemanticVersion parse(String input, boolean allowMissingMinorAndPa minor = matcher.group("minor") == null ? 0 : Integer.parseInt(matcher.group("minor")); patch = matcher.group("patch") == null ? 0 : Integer.parseInt(matcher.group("patch")); } catch (NumberFormatException e) { + // COVERAGE: This should be impossible, because our regex should only match if these strings are numeric. throw new InvalidVersionException("Invalid semantic version"); } String prerelease = matcher.group("prerel"); @@ -129,7 +132,7 @@ public int comparePrecedence(SemanticVersion other) { if (other.prerelease == null) { return -1; } - return compareIdentifiers(prerelease.split("\\."), other.prerelease.split("\\.")); + return compareIdentifiers(prereleaseComponents, other.prereleaseComponents); } private int compareIdentifiers(String[] ids1, String[] ids2) { diff --git a/src/main/java/com/launchdarkly/sdk/server/Util.java b/src/main/java/com/launchdarkly/sdk/server/Util.java index eb576c5c9..c462768b0 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Util.java +++ b/src/main/java/com/launchdarkly/sdk/server/Util.java @@ -19,7 +19,9 @@ import okhttp3.Response; import okhttp3.Route; -class Util { +abstract class Util { + private Util() {} + static Headers.Builder getHeadersBuilderFor(String sdkKey, HttpConfiguration config) { Headers.Builder builder = new Headers.Builder() .add("Authorization", sdkKey) diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java index 02883af0b..251b5e747 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java @@ -124,6 +124,8 @@ 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 final WatchEvent.Kind[] WATCH_KINDS = new WatchEvent.Kind[] { ENTRY_CREATE, ENTRY_MODIFY, ENTRY_DELETE }; + private final WatchService watchService; private final Set watchedFilePaths; private Runnable fileModifiedAction; @@ -141,13 +143,32 @@ private static FileWatcher create(Iterable files) throws IOException { absoluteFilePaths.add(p); directoryPaths.add(p.getParent()); } + for (Path d: directoryPaths) { - d.register(ws, ENTRY_CREATE, ENTRY_MODIFY, ENTRY_DELETE); + registerWatch(d, ws); } return new FileWatcher(ws, absoluteFilePaths); } + @SuppressWarnings({ "rawtypes", "unchecked" }) + private static void registerWatch(Path dirPath, WatchService watchService) throws IOException { + // The following is a workaround for a known issue with Mac JDK implementations which do not have a + // native file watcher, and are based instead on sun.nio.fs.PollingWatchService. Without passing an + // extra parameter of type com.sun.nio.file.SensitivityWatchEventModifier (which does not exist in + // other JDKs), this polling file watcher will only detect changes every 10 seconds. + // See: https://bugs.openjdk.java.net/browse/JDK-7133447 + WatchEvent.Modifier[] modifiers = new WatchEvent.Modifier[0]; + if (watchService.getClass().getName().equals("sun.nio.fs.PollingWatchService")) { + try { + Class modifierClass = (Class)Class.forName("com.sun.nio.file.SensitivityWatchEventModifier"); + Enum mod = Enum.valueOf(modifierClass, "HIGH"); + modifiers = new WatchEvent.Modifier[] { (WatchEvent.Modifier)mod }; + } catch (Exception e) {} + } + dirPath.register(watchService, WATCH_KINDS, modifiers); + } + private FileWatcher(WatchService watchService, Set watchedFilePaths) { this.watchService = watchService; this.watchedFilePaths = watchedFilePaths; diff --git a/src/test/java/com/launchdarkly/sdk/server/ClientContextImplTest.java b/src/test/java/com/launchdarkly/sdk/server/ClientContextImplTest.java new file mode 100644 index 000000000..ea91abc33 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/ClientContextImplTest.java @@ -0,0 +1,154 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.integrations.HttpConfigurationBuilder; +import com.launchdarkly.sdk.server.integrations.LoggingConfigurationBuilder; +import com.launchdarkly.sdk.server.interfaces.ClientContext; +import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; +import com.launchdarkly.sdk.server.interfaces.LoggingConfiguration; + +import org.junit.Test; + +import java.time.Duration; + +import static com.launchdarkly.sdk.server.TestComponents.sharedExecutor; +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 ClientContextImplTest { + private static final String SDK_KEY = "sdk-key"; + + @Test + public void getBasicDefaultProperties() { + LDConfig config = new LDConfig.Builder().build(); + + ClientContext c = new ClientContextImpl(SDK_KEY, config, null, null); + + assertEquals(SDK_KEY, c.getSdkKey()); + assertFalse(c.isOffline()); + + HttpConfiguration httpConfig = c.getHttpConfiguration(); + assertEquals(HttpConfigurationBuilder.DEFAULT_CONNECT_TIMEOUT, httpConfig.getConnectTimeout()); + + LoggingConfiguration loggingConfig = c.getLoggingConfiguration(); + assertEquals(LoggingConfigurationBuilder.DEFAULT_LOG_DATA_SOURCE_OUTAGE_AS_ERROR_AFTER, + loggingConfig.getLogDataSourceOutageAsErrorAfter()); + + assertEquals(Thread.MIN_PRIORITY, c.getThreadPriority()); + } + + @Test + public void getBasicPropertiesWithCustomConfig() { + LDConfig config = new LDConfig.Builder() + .http(Components.httpConfiguration().connectTimeout(Duration.ofSeconds(10))) + .logging(Components.logging().logDataSourceOutageAsErrorAfter(Duration.ofMinutes(20))) + .offline(true) + .threadPriority(Thread.MAX_PRIORITY) + .build(); + + ClientContext c = new ClientContextImpl(SDK_KEY, config, sharedExecutor, null); + + assertEquals(SDK_KEY, c.getSdkKey()); + assertTrue(c.isOffline()); + + HttpConfiguration httpConfig = c.getHttpConfiguration(); + assertEquals(Duration.ofSeconds(10), httpConfig.getConnectTimeout()); + + LoggingConfiguration loggingConfig = c.getLoggingConfiguration(); + assertEquals(Duration.ofMinutes(20), loggingConfig.getLogDataSourceOutageAsErrorAfter()); + + assertEquals(Thread.MAX_PRIORITY, c.getThreadPriority()); + } + + @Test + public void getPackagePrivateSharedExecutor() { + LDConfig config = new LDConfig.Builder().build(); + + ClientContext c = new ClientContextImpl(SDK_KEY, config, sharedExecutor, null); + + assertSame(sharedExecutor, ClientContextImpl.get(c).sharedExecutor); + } + + @Test + public void getPackagePrivateDiagnosticAccumulator() { + LDConfig config = new LDConfig.Builder().build(); + + DiagnosticId diagnosticId = new DiagnosticId(SDK_KEY); + DiagnosticAccumulator diagnosticAccumulator = new DiagnosticAccumulator(diagnosticId); + + ClientContext c = new ClientContextImpl(SDK_KEY, config, sharedExecutor, diagnosticAccumulator); + + assertSame(diagnosticAccumulator, ClientContextImpl.get(c).diagnosticAccumulator); + } + + @Test + public void diagnosticAccumulatorIsNullIfOptedOut() { + LDConfig config = new LDConfig.Builder() + .diagnosticOptOut(true) + .build(); + + DiagnosticId diagnosticId = new DiagnosticId(SDK_KEY); + DiagnosticAccumulator diagnosticAccumulator = new DiagnosticAccumulator(diagnosticId); + + ClientContext c = new ClientContextImpl(SDK_KEY, config, sharedExecutor, diagnosticAccumulator); + + assertNull(ClientContextImpl.get(c).diagnosticAccumulator); + assertNull(ClientContextImpl.get(c).diagnosticInitEvent); + } + + @Test + public void getPackagePrivateDiagnosticInitEvent() { + LDConfig config = new LDConfig.Builder().build(); + + DiagnosticId diagnosticId = new DiagnosticId(SDK_KEY); + DiagnosticAccumulator diagnosticAccumulator = new DiagnosticAccumulator(diagnosticId); + + ClientContext c = new ClientContextImpl(SDK_KEY, config, sharedExecutor, diagnosticAccumulator); + + assertNotNull(ClientContextImpl.get(c).diagnosticInitEvent); + } + + @Test + public void packagePrivatePropertiesHaveDefaultsIfContextIsNotOurImplementation() { + // This covers a scenario where a user has created their own ClientContext and it has been + // passed to one of our SDK components. + ClientContext c = new SomeOtherContextImpl(); + + ClientContextImpl impl = ClientContextImpl.get(c); + + assertNotNull(impl.sharedExecutor); + assertNull(impl.diagnosticAccumulator); + assertNull(impl.diagnosticInitEvent); + + ClientContextImpl impl2 = ClientContextImpl.get(c); + + assertNotNull(impl2.sharedExecutor); + assertSame(impl.sharedExecutor, impl2.sharedExecutor); + } + + private static final class SomeOtherContextImpl implements ClientContext { + public String getSdkKey() { + return null; + } + + public boolean isOffline() { + return false; + } + + public HttpConfiguration getHttpConfiguration() { + return null; + } + + public LoggingConfiguration getLoggingConfiguration() { + return null; + } + + public int getThreadPriority() { + return 0; + } + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/DataSourceUpdatesImplTest.java b/src/test/java/com/launchdarkly/sdk/server/DataSourceUpdatesImplTest.java index 3474e112d..233ab1d66 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataSourceUpdatesImplTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataSourceUpdatesImplTest.java @@ -47,7 +47,8 @@ public class DataSourceUpdatesImplTest extends EasyMockSupport { EventBroadcasterImpl.forFlagChangeEvents(TestComponents.sharedExecutor); private DataSourceUpdatesImpl makeInstance(DataStore store) { - return new DataSourceUpdatesImpl(store, null, flagChangeBroadcaster, null, sharedExecutor, null); + return new DataSourceUpdatesImpl(store, null, flagChangeBroadcaster, null, sharedExecutor, + Components.logging().logDataSourceOutageAsErrorAfter(null).createLoggingConfiguration()); } @Test diff --git a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorOutputTest.java b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorOutputTest.java index 4647a242c..dd4850263 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorOutputTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorOutputTest.java @@ -146,7 +146,28 @@ public void userIsFilteredInFeatureEvent() throws Exception { isSummaryEvent() )); } + + @SuppressWarnings("unchecked") + @Test + public void featureEventCanBeForPrerequisite() throws Exception { + MockEventSender es = new MockEventSender(); + DataModel.FeatureFlag mainFlag = flagBuilder("flagkey").version(11).build(); + DataModel.FeatureFlag prereqFlag = flagBuilder("prereqkey").version(12).trackEvents(true).build(); + Event.FeatureRequest fe = EventFactory.DEFAULT.newPrerequisiteFeatureRequestEvent(prereqFlag, user, + simpleEvaluation(1, LDValue.of("value")), + mainFlag); + + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { + ep.sendEvent(fe); + } + assertThat(es.getEventsFromLastRequest(), contains( + isIndexEvent(fe, userJson), + allOf(isFeatureEvent(fe, prereqFlag, false, null), isPrerequisiteOf(mainFlag.getKey())), + isSummaryEvent() + )); + } + @Test public void featureEventWithNullUserOrNullUserKeyIsIgnored() throws Exception { // This should never happen because LDClient rejects such a user, but just in case, diff --git a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTestBase.java b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTestBase.java index 52532e053..904edf66a 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTestBase.java +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTestBase.java @@ -182,6 +182,10 @@ public static Matcher isFeatureEvent(Event.FeatureRequest sourceEvent, ); } + public static Matcher isPrerequisiteOf(String parentFlagKey) { + return hasJsonProperty("prereqOf", parentFlagKey); + } + @SuppressWarnings("unchecked") public static Matcher isCustomEvent(Event.Custom sourceEvent, LDValue inlineUser) { return allOf( diff --git a/src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java b/src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java index 6014086bd..ae5717f6d 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java @@ -152,6 +152,7 @@ public void testCustomDiagnosticConfigurationForEvents() { .events( Components.sendEvents() .allAttributesPrivate(true) + .baseURI(URI.create("http://custom")) .capacity(20_000) .diagnosticRecordingInterval(Duration.ofSeconds(1_800)) .flushInterval(Duration.ofSeconds(10)) @@ -164,6 +165,7 @@ public void testCustomDiagnosticConfigurationForEvents() { LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig); LDValue expected = expectedDefaultProperties() .put("allAttributesPrivate", true) + .put("customEventsURI", true) .put("diagnosticRecordingIntervalMillis", 1_800_000) .put("eventsCapacity", 20_000) .put("eventsFlushIntervalMillis", 10_000) @@ -193,16 +195,36 @@ public void testCustomDiagnosticConfigurationForDaemonMode() { @Test public void testCustomDiagnosticConfigurationForOffline() { - LDConfig ldConfig = new LDConfig.Builder() - .offline(true) - .build(); - + LDConfig ldConfig = new LDConfig.Builder().offline(true).build(); LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig); LDValue expected = expectedDefaultPropertiesWithoutStreaming() .put("offline", true) .build(); assertEquals(expected, diagnosticJson); + + LDConfig ldConfig2 = new LDConfig.Builder().offline(true) + .dataStore(Components.inMemoryDataStore()) // just double-checking the logic in NullDataSourceFactory.describeConfiguration() + .build(); + LDValue diagnosticJson2 = DiagnosticEvent.Init.getConfigurationData(ldConfig2); + + assertEquals(expected, diagnosticJson2); + + // streaming or polling + offline == offline + + LDConfig ldConfig3 = new LDConfig.Builder().offline(true) + .dataSource(Components.streamingDataSource()) + .build(); + LDValue diagnosticJson3 = DiagnosticEvent.Init.getConfigurationData(ldConfig3); + + assertEquals(expected, diagnosticJson3); + + LDConfig ldConfig4 = new LDConfig.Builder().offline(true) + .dataSource(Components.pollingDataSource()) + .build(); + LDValue diagnosticJson4 = DiagnosticEvent.Init.getConfigurationData(ldConfig4); + + assertEquals(expected, diagnosticJson4); } @Test diff --git a/src/test/java/com/launchdarkly/sdk/server/EventOutputTest.java b/src/test/java/com/launchdarkly/sdk/server/EventOutputTest.java index 18b0041d1..5450669c9 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EventOutputTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EventOutputTest.java @@ -166,6 +166,14 @@ private void testPrivateAttributes(EventsConfiguration config, LDUser user, Stri assertEquals(o.build(), userJson); } + private ObjectBuilder buildFeatureEventProps(String key) { + return LDValue.buildObject() + .put("kind", "feature") + .put("key", key) + .put("creationDate", 100000) + .put("userKey", "userkey"); + } + @Test public void featureEventIsSerialized() throws Exception { EventFactory factory = eventFactoryWithTimestamp(100000, false); @@ -177,72 +185,87 @@ public void featureEventIsSerialized() throws Exception { FeatureRequest feWithVariation = factory.newFeatureRequestEvent(flag, user, new Evaluator.EvalResult(LDValue.of("flagvalue"), 1, EvaluationReason.off()), LDValue.of("defaultvalue")); - LDValue feJson1 = parseValue("{" + - "\"kind\":\"feature\"," + - "\"creationDate\":100000," + - "\"key\":\"flag\"," + - "\"version\":11," + - "\"userKey\":\"userkey\"," + - "\"value\":\"flagvalue\"," + - "\"variation\":1," + - "\"default\":\"defaultvalue\"" + - "}"); + LDValue feJson1 = buildFeatureEventProps("flag") + .put("version", 11) + .put("variation", 1) + .put("value", "flagvalue") + .put("default", "defaultvalue") + .build(); assertEquals(feJson1, getSingleOutputEvent(f, feWithVariation)); FeatureRequest feWithoutVariationOrDefault = factory.newFeatureRequestEvent(flag, user, new Evaluator.EvalResult(LDValue.of("flagvalue"), NO_VARIATION, EvaluationReason.off()), LDValue.ofNull()); - LDValue feJson2 = parseValue("{" + - "\"kind\":\"feature\"," + - "\"creationDate\":100000," + - "\"key\":\"flag\"," + - "\"version\":11," + - "\"userKey\":\"userkey\"," + - "\"value\":\"flagvalue\"" + - "}"); + LDValue feJson2 = buildFeatureEventProps("flag") + .put("version", 11) + .put("value", "flagvalue") + .build(); assertEquals(feJson2, getSingleOutputEvent(f, feWithoutVariationOrDefault)); FeatureRequest feWithReason = factoryWithReason.newFeatureRequestEvent(flag, user, - new Evaluator.EvalResult(LDValue.of("flagvalue"), 1, EvaluationReason.ruleMatch(1, "id")), + new Evaluator.EvalResult(LDValue.of("flagvalue"), 1, EvaluationReason.fallthrough()), 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\"}" + - "}"); + LDValue feJson3 = buildFeatureEventProps("flag") + .put("version", 11) + .put("variation", 1) + .put("value", "flagvalue") + .put("default", "defaultvalue") + .put("reason", LDValue.buildObject().put("kind", "FALLTHROUGH").build()) + .build(); 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\"}" + - "}"); + LDValue feJson4 = buildFeatureEventProps("flag") + .put("value", "defaultvalue") + .put("default", "defaultvalue") + .put("reason", LDValue.buildObject().put("kind", "ERROR").put("errorKind", "FLAG_NOT_FOUND").build()) + .build(); assertEquals(feJson4, getSingleOutputEvent(f, feUnknownFlag)); Event.FeatureRequest debugEvent = EventFactory.newDebugEvent(feWithVariation); - LDValue feJson5 = parseValue("{" + - "\"kind\":\"debug\"," + - "\"creationDate\":100000," + - "\"key\":\"flag\"," + - "\"version\":11," + - "\"user\":{\"key\":\"userkey\",\"name\":\"me\"}," + - "\"value\":\"flagvalue\"," + - "\"variation\":1," + - "\"default\":\"defaultvalue\"" + - "}"); + + LDValue feJson5 = LDValue.buildObject() + .put("kind", "debug") + .put("key", "flag") + .put("creationDate", 100000) + .put("version", 11) + .put("variation", 1) + .put("user", LDValue.buildObject().put("key", "userkey").put("name", "me").build()) + .put("value", "flagvalue") + .put("default", "defaultvalue") + .build(); assertEquals(feJson5, getSingleOutputEvent(f, debugEvent)); + + DataModel.FeatureFlag parentFlag = flagBuilder("parent").build(); + Event.FeatureRequest prereqEvent = factory.newPrerequisiteFeatureRequestEvent(flag, user, + new Evaluator.EvalResult(LDValue.of("flagvalue"), 1, EvaluationReason.fallthrough()), parentFlag); + LDValue feJson6 = buildFeatureEventProps("flag") + .put("version", 11) + .put("variation", 1) + .put("value", "flagvalue") + .put("prereqOf", "parent") + .build(); + assertEquals(feJson6, getSingleOutputEvent(f, prereqEvent)); + + Event.FeatureRequest prereqWithReason = factoryWithReason.newPrerequisiteFeatureRequestEvent(flag, user, + new Evaluator.EvalResult(LDValue.of("flagvalue"), 1, EvaluationReason.fallthrough()), parentFlag); + LDValue feJson7 = buildFeatureEventProps("flag") + .put("version", 11) + .put("variation", 1) + .put("value", "flagvalue") + .put("reason", LDValue.buildObject().put("kind", "FALLTHROUGH").build()) + .put("prereqOf", "parent") + .build(); + assertEquals(feJson7, getSingleOutputEvent(f, prereqWithReason)); + + Event.FeatureRequest prereqWithoutResult = factoryWithReason.newPrerequisiteFeatureRequestEvent(flag, user, + null, parentFlag); + LDValue feJson8 = buildFeatureEventProps("flag") + .put("version", 11) + .put("prereqOf", "parent") + .build(); + assertEquals(feJson8, getSingleOutputEvent(f, prereqWithoutResult)); } @Test @@ -366,6 +389,24 @@ public void summaryEventIsSerialized() throws Exception { )); } + @Test + public void unknownEventClassIsNotSerialized() throws Exception { + // This shouldn't be able to happen in reality. + Event event = new FakeEventClass(1000, new LDUser("user")); + + EventOutputFormatter f = new EventOutputFormatter(defaultEventsConfig()); + StringWriter w = new StringWriter(); + f.writeOutputEvents(new Event[] { event }, new EventSummary(), w); + + assertEquals("[]", w.toString()); + } + + private static class FakeEventClass extends Event { + public FakeEventClass(long creationDate, LDUser user) { + super(creationDate, user); + } + } + private static LDValue parseValue(String json) { return gson.fromJson(json, LDValue.class); } diff --git a/src/test/java/com/launchdarkly/sdk/server/EventSummarizerTest.java b/src/test/java/com/launchdarkly/sdk/server/EventSummarizerTest.java index b423c2929..f65e4c3e5 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EventSummarizerTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EventSummarizerTest.java @@ -3,6 +3,9 @@ import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.EventSummarizer.CounterKey; +import com.launchdarkly.sdk.server.EventSummarizer.CounterValue; +import com.launchdarkly.sdk.server.EventSummarizer.EventSummary; import com.launchdarkly.sdk.server.interfaces.Event; import org.junit.Test; @@ -15,6 +18,9 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; @SuppressWarnings("javadoc") public class EventSummarizerTest { @@ -22,6 +28,22 @@ public class EventSummarizerTest { private long eventTimestamp; private EventFactory eventFactory = new EventFactory.Default(false, () -> eventTimestamp); + + @Test + public void summarizerCanBeCleared() { + EventSummarizer es = new EventSummarizer(); + assertTrue(es.snapshot().isEmpty()); + + DataModel.FeatureFlag flag = flagBuilder("key").build(); + Event event = eventFactory.newFeatureRequestEvent(flag, user, null, null); + es.summarizeEvent(event); + + assertFalse(es.snapshot().isEmpty()); + + es.clear(); + + assertTrue(es.snapshot().isEmpty()); + } @Test public void summarizeEventDoesNothingForIdentifyEvent() { @@ -93,4 +115,109 @@ public void summarizeEventIncrementsCounters() { new EventSummarizer.CounterValue(1, LDValue.of("default3"), LDValue.of("default3"))); assertThat(data.counters, equalTo(expected)); } + + @Test + public void counterKeyEquality() { + // This must be correct in order for CounterKey to be used as a map key. + CounterKey key1 = new CounterKey("a", 1, 10); + CounterKey key2 = new CounterKey("a", 1, 10); + assertEquals(key1, key2); + assertEquals(key2, key1); + assertEquals(key1.hashCode(), key2.hashCode()); + + for (CounterKey notEqualValue: new CounterKey[] { + new CounterKey("b", 1, 10), + new CounterKey("a", 2, 10), + new CounterKey("a", 1, 11) + }) { + assertNotEquals(key1, notEqualValue); + assertNotEquals(notEqualValue, key1); + assertNotEquals(key1.hashCode(), notEqualValue.hashCode()); + } + + assertNotEquals(key1, null); + assertNotEquals(key1, "x"); + } + + // The following implementations are used only in debug/test code, but may as well test them + + @Test + public void counterKeyToString() { + assertEquals("(a,1,10)", new CounterKey("a", 1, 10).toString()); + } + + @Test + public void counterValueEquality() { + CounterValue value1 = new CounterValue(1, LDValue.of("a"), LDValue.of("d")); + CounterValue value2 = new CounterValue(1, LDValue.of("a"), LDValue.of("d")); + assertEquals(value1, value2); + assertEquals(value2, value1); + + for (CounterValue notEqualValue: new CounterValue[] { + new CounterValue(2, LDValue.of("a"), LDValue.of("d")), + new CounterValue(1, LDValue.of("b"), LDValue.of("d")), + new CounterValue(1, LDValue.of("a"), LDValue.of("e")) + }) { + assertNotEquals(value1, notEqualValue); + assertNotEquals(notEqualValue, value1); + + assertNotEquals(value1, null); + assertNotEquals(value1, "x"); + } + } + + @Test + public void counterValueToString() { + assertEquals("(1,\"a\",\"d\")", new CounterValue(1, LDValue.of("a"), LDValue.of("d")).toString()); + } + + @Test + public void eventSummaryEquality() { + String key1 = "key1", key2 = "key2"; + int variation1 = 0, variation2 = 1, variation3 = 2, version1 = 10, version2 = 20; + LDValue value1 = LDValue.of(1), value2 = LDValue.of(2), value3 = LDValue.of(3), + default1 = LDValue.of(-1), default2 = LDValue.of(-2); + EventSummary es1 = new EventSummary(); + es1.noteTimestamp(1000); + es1.incrementCounter(key1, variation1, version1, value1, default1); + es1.incrementCounter(key1, variation1, version1, value1, default1); + es1.incrementCounter(key1, variation2, version2, value2, default1); + es1.incrementCounter(key2, variation3, version2, value3, default2); + es1.noteTimestamp(2000); + + EventSummary es2 = new EventSummary(); // same operations in different order + es2.noteTimestamp(1000); + es2.incrementCounter(key2, variation3, version2, value3, default2); + es2.incrementCounter(key1, variation1, version1, value1, default1); + es2.incrementCounter(key1, variation2, version2, value2, default1); + es2.incrementCounter(key1, variation1, version1, value1, default1); + es2.noteTimestamp(2000); + + EventSummary es3 = new EventSummary(); // same operations with different start time + es3.noteTimestamp(1100); + es3.incrementCounter(key2, variation3, version2, value3, default2); + es3.incrementCounter(key1, variation1, version1, value1, default1); + es3.incrementCounter(key1, variation2, version2, value2, default1); + es3.incrementCounter(key1, variation1, version1, value1, default1); + es3.noteTimestamp(2000); + + EventSummary es4 = new EventSummary(); // same operations with different end time + es4.noteTimestamp(1000); + es4.incrementCounter(key2, variation3, version2, value3, default2); + es4.incrementCounter(key1, variation1, version1, value1, default1); + es4.incrementCounter(key1, variation2, version2, value2, default1); + es4.incrementCounter(key1, variation1, version1, value1, default1); + es4.noteTimestamp(1900); + + assertEquals(es1, es2); + assertEquals(es2, es1); + + assertEquals(0, es1.hashCode()); // see comment on hashCode + + assertNotEquals(es1, es3); + assertNotEquals(es1, es4); + + assertNotEquals(es1, null); + assertNotEquals(es1, "x"); + } } diff --git a/src/test/java/com/launchdarkly/sdk/server/EventUserSerializationTest.java b/src/test/java/com/launchdarkly/sdk/server/EventUserSerializationTest.java index 3fcb78e39..49a8fd242 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EventUserSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EventUserSerializationTest.java @@ -141,6 +141,13 @@ public void privateAttributeEncodingWorksForMinimalUser() { assertEquals(expected, o); } + @Test + public void cannotDeserializeEventUser() { + String json = "{}"; + LDUser user = gsonInstanceForEventsSerialization(defaultEventsConfig()).fromJson(json, LDUser.class); + assertNull(user); + } + private Set getPrivateAttrs(JsonObject o) { Type type = new TypeToken>(){}.getType(); return TEST_GSON_INSTANCE.>fromJson(o.get("privateAttrs"), type); diff --git a/src/test/java/com/launchdarkly/sdk/server/InMemoryDataStoreTest.java b/src/test/java/com/launchdarkly/sdk/server/InMemoryDataStoreTest.java index 9e2b0d1ff..13400cf1d 100644 --- a/src/test/java/com/launchdarkly/sdk/server/InMemoryDataStoreTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/InMemoryDataStoreTest.java @@ -1,8 +1,11 @@ package com.launchdarkly.sdk.server; -import com.launchdarkly.sdk.server.InMemoryDataStore; import com.launchdarkly.sdk.server.interfaces.DataStore; +import org.junit.Test; + +import static org.junit.Assert.assertNull; + @SuppressWarnings("javadoc") public class InMemoryDataStoreTest extends DataStoreTestBase { @@ -10,4 +13,9 @@ public class InMemoryDataStoreTest extends DataStoreTestBase { protected DataStore makeStore() { return new InMemoryDataStore(); } + + @Test + public void cacheStatsAreNull() { + assertNull(makeStore().getCacheStats()); + } } diff --git a/src/test/java/com/launchdarkly/sdk/server/JsonHelpersTest.java b/src/test/java/com/launchdarkly/sdk/server/JsonHelpersTest.java new file mode 100644 index 000000000..9bbeade54 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/JsonHelpersTest.java @@ -0,0 +1,121 @@ +package com.launchdarkly.sdk.server; + +import com.google.gson.JsonElement; +import com.google.gson.annotations.JsonAdapter; +import com.launchdarkly.sdk.server.interfaces.SerializationException; +import com.launchdarkly.sdk.server.DataModel.FeatureFlag; +import com.launchdarkly.sdk.server.DataModel.Segment; +import com.launchdarkly.sdk.server.DataModel.VersionedData; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; + +import org.junit.Assert; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +@SuppressWarnings("javadoc") +public class JsonHelpersTest { + @Test + public void serialize() { + MySerializableClass instance = new MySerializableClass(); + instance.value = 3; + assertEquals("{\"value\":3}", JsonHelpers.serialize(instance)); + } + + @Test + public void deserialize() { + MySerializableClass instance = JsonHelpers.deserialize("{\"value\":3}", MySerializableClass.class); + assertNotNull(instance); + assertEquals(3, instance.value); + } + + @Test(expected=SerializationException.class) + public void deserializeInvalidJson() { + JsonHelpers.deserialize("{\"value", MySerializableClass.class); + } + + @Test + public void deserializeFlagFromParsedJson() { + String json = "{\"key\":\"flagkey\",\"version\":1}"; + JsonElement element = JsonHelpers.gsonInstance().fromJson(json, JsonElement.class); + VersionedData flag = JsonHelpers.deserializeFromParsedJson(DataModel.FEATURES, element); + assertEquals(FeatureFlag.class, flag.getClass()); + assertEquals("flagkey", flag.getKey()); + assertEquals(1, flag.getVersion()); + } + + @Test(expected=SerializationException.class) + public void deserializeInvalidFlagFromParsedJson() { + String json = "{\"key\":[3]}"; + JsonElement element = JsonHelpers.gsonInstance().fromJson(json, JsonElement.class); + JsonHelpers.deserializeFromParsedJson(DataModel.FEATURES, element); + } + + @Test + public void deserializeSegmentFromParsedJson() { + String json = "{\"key\":\"segkey\",\"version\":1}"; + JsonElement element = JsonHelpers.gsonInstance().fromJson(json, JsonElement.class); + VersionedData segment = JsonHelpers.deserializeFromParsedJson(DataModel.SEGMENTS, element); + assertEquals(Segment.class, segment.getClass()); + assertEquals("segkey", segment.getKey()); + assertEquals(1, segment.getVersion()); + } + + @Test(expected=SerializationException.class) + public void deserializeInvalidSegmentFromParsedJson() { + String json = "{\"key\":[3]}"; + JsonElement element = JsonHelpers.gsonInstance().fromJson(json, JsonElement.class); + JsonHelpers.deserializeFromParsedJson(DataModel.SEGMENTS, element); + } + + @Test(expected=IllegalArgumentException.class) + public void deserializeInvalidDataKindFromParsedJson() { + String json = "{\"key\":\"something\",\"version\":1}"; + JsonElement element = JsonHelpers.gsonInstance().fromJson(json, JsonElement.class); + DataKind mysteryKind = new DataKind("incorrect", null, null); + JsonHelpers.deserializeFromParsedJson(mysteryKind, element); + } + + @Test + public void postProcessingTypeAdapterFactoryCallsAfterDeserializedIfApplicable() { + // This tests the mechanism that ensures afterDeserialize() is called on every FeatureFlag or + // Segment that we deserialize. + MyClassWithAnAfterDeserializeMethod instance = + JsonHelpers.gsonInstance().fromJson("{}", MyClassWithAnAfterDeserializeMethod.class); + assertNotNull(instance); + assertTrue(instance.wasCalled); + } + + @Test + public void postProcessingTypeAdapterFactoryDoesNothingIfClassDoesNotImplementInterface() { + // If we accidentally apply this type adapter to something inapplicable, it's a no-op. + SomeOtherClass instance = JsonHelpers.gsonInstance().fromJson("{}", SomeOtherClass.class); + assertNotNull(instance); + } + + @Test + public void postProcessingTypeAdapterFactoryDoesNotAffectSerialization() { + MyClassWithAnAfterDeserializeMethod instance = new MyClassWithAnAfterDeserializeMethod(); + String json = JsonHelpers.gsonInstance().toJson(instance); + assertEquals("{\"wasCalled\":false}", json); + } + + static class MySerializableClass { + int value; + } + + @JsonAdapter(JsonHelpers.PostProcessingDeserializableTypeAdapterFactory.class) + static class MyClassWithAnAfterDeserializeMethod implements JsonHelpers.PostProcessingDeserializable { + boolean wasCalled = false; + + @Override + public void afterDeserialized() { + wasCalled = true; + } + } + + @JsonAdapter(JsonHelpers.PostProcessingDeserializableTypeAdapterFactory.class) + static class SomeOtherClass {} +} diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientEventTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientEventTest.java index cdb07a85a..14d474958 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientEventTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientEventTest.java @@ -493,6 +493,39 @@ public void canFlush() { assertEquals(1, eventSink.flushCount); } + @Test + public void identifyWithEventsDisabledDoesNotCauseError() throws Exception { + LDConfig config = new LDConfig.Builder() + .events(Components.noEvents()) + .dataSource(Components.externalUpdatesOnly()) + .build(); + try (LDClient client = new LDClient("SDK_KEY", config)) { + client.identify(user); + } + } + + @Test + public void trackWithEventsDisabledDoesNotCauseError() throws Exception { + LDConfig config = new LDConfig.Builder() + .events(Components.noEvents()) + .dataSource(Components.externalUpdatesOnly()) + .build(); + try (LDClient client = new LDClient("SDK_KEY", config)) { + client.track("event", user); + } + } + + @Test + public void flushWithEventsDisabledDoesNotCauseError() throws Exception { + LDConfig config = new LDConfig.Builder() + .events(Components.noEvents()) + .dataSource(Components.externalUpdatesOnly()) + .build(); + try (LDClient client = new LDClient("SDK_KEY", config)) { + client.flush(); + } + } + private void checkFeatureEvent(Event e, DataModel.FeatureFlag flag, LDValue value, LDValue defaultVal, String prereqOf, EvaluationReason reason) { assertEquals(Event.FeatureRequest.class, e.getClass()); diff --git a/src/test/java/com/launchdarkly/sdk/server/LDConfigTest.java b/src/test/java/com/launchdarkly/sdk/server/LDConfigTest.java index b0649573c..b04dea852 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDConfigTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDConfigTest.java @@ -1,28 +1,113 @@ package com.launchdarkly.sdk.server; +import com.launchdarkly.sdk.server.integrations.HttpConfigurationBuilder; +import com.launchdarkly.sdk.server.integrations.LoggingConfigurationBuilder; +import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; +import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; +import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; import org.junit.Test; +import java.time.Duration; + 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 { @Test - public void testDefaultDiagnosticOptOut() { + public void defaults() { LDConfig config = new LDConfig.Builder().build(); + assertNull(config.dataSourceFactory); + assertNull(config.dataStoreFactory); assertFalse(config.diagnosticOptOut); + assertNull(config.eventProcessorFactory); + assertFalse(config.offline); + + assertNotNull(config.httpConfig); + assertEquals(HttpConfigurationBuilder.DEFAULT_CONNECT_TIMEOUT, config.httpConfig.getConnectTimeout()); + + assertNotNull(config.loggingConfig); + assertEquals(LoggingConfigurationBuilder.DEFAULT_LOG_DATA_SOURCE_OUTAGE_AS_ERROR_AFTER, + config.loggingConfig.getLogDataSourceOutageAsErrorAfter()); + + assertEquals(LDConfig.DEFAULT_START_WAIT, config.startWait); + assertEquals(Thread.MIN_PRIORITY, config.threadPriority); + } + + @Test + public void dataSourceFactory() { + DataSourceFactory f = TestComponents.specificDataSource(null); + LDConfig config = new LDConfig.Builder().dataSource(f).build(); + assertSame(f, config.dataSourceFactory); + } + + @Test + public void dataStoreFactory() { + DataStoreFactory f = TestComponents.specificDataStore(null); + LDConfig config = new LDConfig.Builder().dataStore(f).build(); + assertSame(f, config.dataStoreFactory); } @Test - public void testDiagnosticOptOut() { + public void diagnosticOptOut() { LDConfig config = new LDConfig.Builder().diagnosticOptOut(true).build(); assertTrue(config.diagnosticOptOut); + + LDConfig config1 = new LDConfig.Builder().diagnosticOptOut(true).diagnosticOptOut(false).build(); + assertFalse(config1.diagnosticOptOut); + } + + @Test + public void eventProcessorFactory() { + EventProcessorFactory f = TestComponents.specificEventProcessor(null); + LDConfig config = new LDConfig.Builder().events(f).build(); + assertSame(f, config.eventProcessorFactory); + } + + @Test + public void offline() { + LDConfig config = new LDConfig.Builder().offline(true).build(); + assertTrue(config.offline); + + LDConfig config1 = new LDConfig.Builder().offline(true).offline(false).build(); + assertFalse(config1.offline); } + @Test + public void http() { + HttpConfigurationBuilder b = Components.httpConfiguration().connectTimeout(Duration.ofSeconds(9)); + LDConfig config = new LDConfig.Builder().http(b).build(); + assertEquals(Duration.ofSeconds(9), config.httpConfig.getConnectTimeout()); + } + + @Test + public void logging() { + LoggingConfigurationBuilder b = Components.logging().logDataSourceOutageAsErrorAfter(Duration.ofSeconds(9)); + LDConfig config = new LDConfig.Builder().logging(b).build(); + assertEquals(Duration.ofSeconds(9), config.loggingConfig.getLogDataSourceOutageAsErrorAfter()); + } + + @Test + public void startWait() { + LDConfig config = new LDConfig.Builder().startWait(Duration.ZERO).build(); + assertEquals(Duration.ZERO, config.startWait); + + LDConfig config1 = new LDConfig.Builder().startWait(Duration.ZERO).startWait(null).build(); + assertEquals(LDConfig.DEFAULT_START_WAIT, config1.startWait); + } + + @Test + public void threadPriority() { + LDConfig config = new LDConfig.Builder().threadPriority(Thread.MAX_PRIORITY).build(); + assertEquals(Thread.MAX_PRIORITY, config.threadPriority); + } + @Test public void testWrapperNotConfigured() { LDConfig config = new LDConfig.Builder().build(); diff --git a/src/test/java/com/launchdarkly/sdk/server/SemanticVersionTest.java b/src/test/java/com/launchdarkly/sdk/server/SemanticVersionTest.java index 41ceb972b..9c7b9377b 100644 --- a/src/test/java/com/launchdarkly/sdk/server/SemanticVersionTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/SemanticVersionTest.java @@ -141,11 +141,30 @@ public void canParseVersionWithMajorMinorAndBuildOnly() throws Exception { assertEquals("build1", sv.getBuild()); } + @Test(expected=SemanticVersion.InvalidVersionException.class) + public void majorVersionMustBeNumeric() throws Exception { + SemanticVersion.parse("x.0.0"); + } + + @Test(expected=SemanticVersion.InvalidVersionException.class) + public void minorVersionMustBeNumeric() throws Exception { + SemanticVersion.parse("0.x.0"); + } + + @Test(expected=SemanticVersion.InvalidVersionException.class) + public void patchVersionMustBeNumeric() throws Exception { + SemanticVersion.parse("0.0.x"); + } + @Test public void equalVersionsHaveEqualPrecedence() throws Exception { SemanticVersion sv1 = SemanticVersion.parse("2.3.4-beta1"); SemanticVersion sv2 = SemanticVersion.parse("2.3.4-beta1"); assertEquals(0, sv1.comparePrecedence(sv2)); + + SemanticVersion sv3 = SemanticVersion.parse("2.3.4"); + SemanticVersion sv4 = SemanticVersion.parse("2.3.4"); + assertEquals(0, sv3.comparePrecedence(sv4)); } @Test @@ -204,6 +223,14 @@ public void nonNumericPrereleaseIdentifiersAreSortedAsStrings() throws Exception assertEquals(-1, sv2.comparePrecedence(sv1)); } + @Test + public void numericPrereleaseIdentifiersAreLowerThanStrings() throws Exception { + SemanticVersion sv1 = SemanticVersion.parse("2.3.4-beta1.x.100"); + SemanticVersion sv2 = SemanticVersion.parse("2.3.4-beta1.3.100"); + assertEquals(1, sv1.comparePrecedence(sv2)); + assertEquals(-1, sv2.comparePrecedence(sv1)); + } + @Test public void buildIdentifierDoesNotAffectPrecedence() throws Exception { SemanticVersion sv1 = SemanticVersion.parse("2.3.4-beta1+build1"); @@ -211,4 +238,11 @@ public void buildIdentifierDoesNotAffectPrecedence() throws Exception { assertEquals(0, sv1.comparePrecedence(sv2)); assertEquals(0, sv2.comparePrecedence(sv1)); } + + @Test + public void anyVersionIsGreaterThanNull() throws Exception { + SemanticVersion sv = SemanticVersion.parse("0.0.0"); + assertEquals(1, sv.comparePrecedence(null)); + assertEquals(1, sv.compareTo(null)); + } } diff --git a/src/test/java/com/launchdarkly/sdk/server/UtilTest.java b/src/test/java/com/launchdarkly/sdk/server/UtilTest.java index 93c097c23..b2add8b04 100644 --- a/src/test/java/com/launchdarkly/sdk/server/UtilTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/UtilTest.java @@ -1,5 +1,7 @@ package com.launchdarkly.sdk.server; +import com.launchdarkly.sdk.server.interfaces.HttpAuthentication; + import org.junit.Test; import java.time.Duration; @@ -7,8 +9,13 @@ import static com.launchdarkly.sdk.server.Util.configureHttpClientBuilder; import static com.launchdarkly.sdk.server.Util.shutdownHttpClient; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import okhttp3.Authenticator; import okhttp3.OkHttpClient; +import okhttp3.Protocol; +import okhttp3.Request; +import okhttp3.Response; @SuppressWarnings("javadoc") public class UtilTest { @@ -38,6 +45,37 @@ public void testSocketTimeout() { } } + @Test + public void useOurBasicAuthenticatorAsOkhttpProxyAuthenticator() throws Exception { + HttpAuthentication ourAuth = Components.httpBasicAuthentication("user", "pass"); + Authenticator okhttpAuth = Util.okhttpAuthenticatorFromHttpAuthStrategy(ourAuth, + "Proxy-Authentication", "Proxy-Authorization"); + + Request originalRequest = new Request.Builder().url("http://proxy").build(); + Response resp1 = new Response.Builder() + .request(originalRequest) + .message("") + .protocol(Protocol.HTTP_1_1) + .header("Proxy-Authentication", "Basic realm=x") + .code(407) + .build(); + + Request newRequest = okhttpAuth.authenticate(null, resp1); + + assertEquals("Basic dXNlcjpwYXNz", newRequest.header("Proxy-Authorization")); + + // simulate the proxy rejecting us again + Response resp2 = new Response.Builder() + .request(newRequest) + .message("") + .protocol(Protocol.HTTP_1_1) + .header("Proxy-Authentication", "Basic realm=x") + .code(407) + .build(); + + assertNull(okhttpAuth.authenticate(null, resp2)); // null tells OkHttp to give up + } + @Test public void describeDuration() { assertEquals("15 milliseconds", Util.describeDuration(Duration.ofMillis(15))); From 7c75b3a6a6fd3ecbc866a874ce590039f7c82dc7 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Sun, 24 May 2020 11:14:51 -0700 Subject: [PATCH 487/673] better temp file handling in file data source tests --- .../integrations/FileDataSourceTest.java | 148 +++++++++++------- 1 file changed, 92 insertions(+), 56 deletions(-) diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java index 249badc61..240b5f1ab 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java @@ -9,7 +9,7 @@ import org.junit.Test; -import java.io.File; +import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -143,19 +143,18 @@ public void statusIsInitializingAfterUnsuccessfulLoad() throws Exception { @Test public void modifiedFileIsNotReloadedIfAutoUpdateIsOff() throws Exception { - File file = makeTempFlagFile(); - FileDataSourceBuilder factory1 = makeFactoryWithFile(file.toPath()); - try { - setFileContents(file, getResourceContents("flag-only.json")); - try (DataSource fp = makeDataSource(factory1)) { - fp.start(); - setFileContents(file, getResourceContents("segment-only.json")); - Thread.sleep(400); - assertThat(toItemsMap(store.getAll(FEATURES)).size(), equalTo(1)); - assertThat(toItemsMap(store.getAll(SEGMENTS)).size(), equalTo(0)); + try (TempDir dir = TempDir.create()) { + try (TempFile file = dir.tempFile(".json")) { + file.setContents(getResourceContents("flag-only.json")); + FileDataSourceBuilder factory1 = makeFactoryWithFile(file.path); + try (DataSource fp = makeDataSource(factory1)) { + fp.start(); + file.setContents(getResourceContents("segment-only.json")); + Thread.sleep(400); + assertThat(toItemsMap(store.getAll(FEATURES)).size(), equalTo(1)); + assertThat(toItemsMap(store.getAll(SEGMENTS)).size(), equalTo(0)); + } } - } finally { - file.delete(); } } @@ -164,24 +163,24 @@ public void modifiedFileIsNotReloadedIfAutoUpdateIsOff() throws Exception { // 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(); - FileDataSourceBuilder factory1 = makeFactoryWithFile(file.toPath()).autoUpdate(true); - try { - setFileContents(file, getResourceContents("flag-only.json")); // this file has 1 flag - try (DataSource fp = makeDataSource(factory1)) { - fp.start(); - Thread.sleep(1000); - setFileContents(file, getResourceContents("all-properties.json")); // this file has all the flags - repeatWithTimeout(Duration.ofSeconds(10), Duration.ofMillis(500), () -> { - if (toItemsMap(store.getAll(FEATURES)).size() == ALL_FLAG_KEYS.size()) { - // success - return a non-null value to make repeatWithTimeout end - return fp; - } - return null; - }); + try (TempDir dir = TempDir.create()) { + try (TempFile file = dir.tempFile(".json")) { + System.out.println("dir = " + dir.path + ", file = " + file.path); + FileDataSourceBuilder factory1 = makeFactoryWithFile(file.path).autoUpdate(true); + file.setContents(getResourceContents("flag-only.json")); // this file has 1 flag + try (DataSource fp = makeDataSource(factory1)) { + fp.start(); + Thread.sleep(1000); + file.setContents(getResourceContents("all-properties.json")); // this file has all the flags + repeatWithTimeout(Duration.ofSeconds(10), Duration.ofMillis(500), () -> { + if (toItemsMap(store.getAll(FEATURES)).size() == ALL_FLAG_KEYS.size()) { + // success - return a non-null value to make repeatWithTimeout end + return fp; + } + return null; + }); + } } - } finally { - file.delete(); } } @@ -190,37 +189,74 @@ public void ifFilesAreBadAtStartTimeAutoUpdateCanStillLoadGoodDataLater() throws BlockingQueue statuses = new LinkedBlockingQueue<>(); dataSourceUpdates.register(statuses::add); - File file = makeTempFlagFile(); - setFileContents(file, "not valid"); - FileDataSourceBuilder factory1 = makeFactoryWithFile(file.toPath()).autoUpdate(true); - try { - try (DataSource fp = makeDataSource(factory1)) { - fp.start(); - Thread.sleep(1000); - setFileContents(file, getResourceContents("flag-only.json")); // this file has 1 flag - repeatWithTimeout(Duration.ofSeconds(10), Duration.ofMillis(500), () -> { - if (toItemsMap(store.getAll(FEATURES)).size() > 0) { - // success - status is now VALID, after having first been INITIALIZING - can still see that an error occurred - DataSourceStatusProvider.Status status = requireDataSourceStatusEventually(statuses, - DataSourceStatusProvider.State.VALID, DataSourceStatusProvider.State.INITIALIZING); - assertNotNull(status.getLastError()); - assertEquals(DataSourceStatusProvider.ErrorKind.INVALID_DATA, status.getLastError().getKind()); - - return status; - } - return null; - }); + try (TempDir dir = TempDir.create()) { + try (TempFile file = dir.tempFile(".json")) { + file.setContents("not valid"); + FileDataSourceBuilder factory1 = makeFactoryWithFile(file.path).autoUpdate(true); + try (DataSource fp = makeDataSource(factory1)) { + fp.start(); + Thread.sleep(1000); + file.setContents(getResourceContents("flag-only.json")); // this file has 1 flag + repeatWithTimeout(Duration.ofSeconds(10), Duration.ofMillis(500), () -> { + if (toItemsMap(store.getAll(FEATURES)).size() > 0) { + // success - status is now VALID, after having first been INITIALIZING - can still see that an error occurred + DataSourceStatusProvider.Status status = requireDataSourceStatusEventually(statuses, + DataSourceStatusProvider.State.VALID, DataSourceStatusProvider.State.INITIALIZING); + assertNotNull(status.getLastError()); + assertEquals(DataSourceStatusProvider.ErrorKind.INVALID_DATA, status.getLastError().getKind()); + + return status; + } + return null; + }); + } } - } finally { - file.delete(); } } - private File makeTempFlagFile() throws Exception { - return File.createTempFile("flags", ".json"); + // These helpers ensure that we clean up all temporary files, and also that we only create temporary + // files within our own temporary directories - since creating a file within a shared system temp + // directory might mean there are thousands of other files there, which could be a problem if the + // filesystem watcher implementation has to traverse the directory. + + private static class TempDir implements AutoCloseable { + final Path path; + + private TempDir(Path path) { + this.path = path; + } + + public void close() throws IOException { + Files.delete(path); + } + + public static TempDir create() throws IOException { + return new TempDir(Files.createTempDirectory("java-sdk-tests")); + } + + public TempFile tempFile(String suffix) throws IOException { + return new TempFile(Files.createTempFile(path, "java-sdk-tests", suffix)); + } } - private void setFileContents(File file, String content) throws Exception { - Files.write(file.toPath(), content.getBytes("UTF-8")); + private static class TempFile implements AutoCloseable { + final Path path; + + private TempFile(Path path) { + this.path = path; + } + + @Override + public void close() throws IOException { + delete(); + } + + public void delete() throws IOException { + Files.delete(path); + } + + public void setContents(String content) throws IOException { + Files.write(path, content.getBytes("UTF-8")); + } } } From 8da3e2736d1bffad0e71b016b1dd00b224d3e63f Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Sun, 24 May 2020 11:15:30 -0700 Subject: [PATCH 488/673] revert file data source implementation change for now --- .../integrations/FileDataSourceImpl.java | 23 +------------------ 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java index 251b5e747..02883af0b 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java @@ -124,8 +124,6 @@ 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 final WatchEvent.Kind[] WATCH_KINDS = new WatchEvent.Kind[] { ENTRY_CREATE, ENTRY_MODIFY, ENTRY_DELETE }; - private final WatchService watchService; private final Set watchedFilePaths; private Runnable fileModifiedAction; @@ -143,32 +141,13 @@ private static FileWatcher create(Iterable files) throws IOException { absoluteFilePaths.add(p); directoryPaths.add(p.getParent()); } - for (Path d: directoryPaths) { - registerWatch(d, ws); + d.register(ws, ENTRY_CREATE, ENTRY_MODIFY, ENTRY_DELETE); } return new FileWatcher(ws, absoluteFilePaths); } - @SuppressWarnings({ "rawtypes", "unchecked" }) - private static void registerWatch(Path dirPath, WatchService watchService) throws IOException { - // The following is a workaround for a known issue with Mac JDK implementations which do not have a - // native file watcher, and are based instead on sun.nio.fs.PollingWatchService. Without passing an - // extra parameter of type com.sun.nio.file.SensitivityWatchEventModifier (which does not exist in - // other JDKs), this polling file watcher will only detect changes every 10 seconds. - // See: https://bugs.openjdk.java.net/browse/JDK-7133447 - WatchEvent.Modifier[] modifiers = new WatchEvent.Modifier[0]; - if (watchService.getClass().getName().equals("sun.nio.fs.PollingWatchService")) { - try { - Class modifierClass = (Class)Class.forName("com.sun.nio.file.SensitivityWatchEventModifier"); - Enum mod = Enum.valueOf(modifierClass, "HIGH"); - modifiers = new WatchEvent.Modifier[] { (WatchEvent.Modifier)mod }; - } catch (Exception e) {} - } - dirPath.register(watchService, WATCH_KINDS, modifiers); - } - private FileWatcher(WatchService watchService, Set watchedFilePaths) { this.watchService = watchService; this.watchedFilePaths = watchedFilePaths; From 306797edfa7d81ab165201e3ae8e0bd657f2180e Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Sun, 24 May 2020 11:16:12 -0700 Subject: [PATCH 489/673] revert unnecessary change --- .../com/launchdarkly/sdk/server/DataSourceUpdatesImplTest.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/test/java/com/launchdarkly/sdk/server/DataSourceUpdatesImplTest.java b/src/test/java/com/launchdarkly/sdk/server/DataSourceUpdatesImplTest.java index 233ab1d66..3474e112d 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataSourceUpdatesImplTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataSourceUpdatesImplTest.java @@ -47,8 +47,7 @@ public class DataSourceUpdatesImplTest extends EasyMockSupport { EventBroadcasterImpl.forFlagChangeEvents(TestComponents.sharedExecutor); private DataSourceUpdatesImpl makeInstance(DataStore store) { - return new DataSourceUpdatesImpl(store, null, flagChangeBroadcaster, null, sharedExecutor, - Components.logging().logDataSourceOutageAsErrorAfter(null).createLoggingConfiguration()); + return new DataSourceUpdatesImpl(store, null, flagChangeBroadcaster, null, sharedExecutor, null); } @Test From e07643f35f34b32a5fba63c8410cf85c5dc9a24c Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Sun, 24 May 2020 11:21:24 -0700 Subject: [PATCH 490/673] comment about file watching on Mac --- .../sdk/server/integrations/FileDataSourceBuilder.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBuilder.java b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBuilder.java index 77ba1f215..0c4228a4b 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBuilder.java @@ -63,7 +63,12 @@ public FileDataSourceBuilder filePaths(Path... filePaths) { * 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. + * startup time; if a directory does not exist, creating it later will not result in files being loaded from it. + *

    + * The performance of this feature depends on what implementation of {@code java.nio.file.WatchService} is + * available in the Java runtime. On Linux and Windows, an implementation based on native filesystem APIs + * should be available. On MacOS, there is a long-standing known issue where due to the lack of such an + * implementation, it must use a file polling approach that can take up to 10 seconds to detect a change. * * @param autoUpdate true if flags should be reloaded whenever a source file changes * @return the same factory object From 1d5715650851187a915e9c10344a5e7f5c844289 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 26 May 2020 10:10:20 -0700 Subject: [PATCH 491/673] add slight delay to avoid timing-dependent test flakiness --- .../launchdarkly/sdk/server/DefaultEventProcessorTest.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java index f35302491..f0f82e841 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java @@ -259,6 +259,10 @@ public void eventCapacityIsEnforced() throws Exception { try (DefaultEventProcessor ep = makeEventProcessor(config)) { for (int i = 0; i < capacity + 2; i++) { ep.sendEvent(EventFactory.DEFAULT.newIdentifyEvent(user)); + + // Using such a tiny buffer means there's also a tiny inbox queue, so we'll add a slight + // delay to keep EventDispatcher from being overwhelmed + Thread.sleep(1); } ep.flush(); assertThat(es.getEventsFromLastRequest(), Matchers.iterableWithSize(capacity)); From 3fad450315cf15d48b3a750e7925e0c5097bfb57 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 26 May 2020 10:12:37 -0700 Subject: [PATCH 492/673] test fixes/comments --- .../sdk/server/DefaultEventProcessorTest.java | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java index f0f82e841..a7c2fd831 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java @@ -254,7 +254,11 @@ public void customBaseUriIsPassedToEventSenderForAnalyticsEvents() throws Except public void eventCapacityIsEnforced() throws Exception { int capacity = 10; MockEventSender es = new MockEventSender(); - EventProcessorBuilder config = baseConfig(es).capacity(capacity); + EventProcessorBuilder config = baseConfig(es).capacity(capacity) + .flushInterval(Duration.ofSeconds(1)); + // The flush interval setting is a failsafe in case we do get a queue overflow due to the tiny buffer size - + // that might cause the special message that's generated by ep.flush() to be missed, so we just want to make + // sure a flush will happen within a few seconds so getEventsFromLastRequest() won't time out. try (DefaultEventProcessor ep = makeEventProcessor(config)) { for (int i = 0; i < capacity + 2; i++) { @@ -273,7 +277,12 @@ public void eventCapacityIsEnforced() throws Exception { public void eventCapacityDoesNotPreventSummaryEventFromBeingSent() throws Exception { int capacity = 10; MockEventSender es = new MockEventSender(); - EventProcessorBuilder config = baseConfig(es).capacity(capacity).inlineUsersInEvents(true); + EventProcessorBuilder config = baseConfig(es).capacity(capacity).inlineUsersInEvents(true) + .flushInterval(Duration.ofSeconds(1)); + // The flush interval setting is a failsafe in case we do get a queue overflow due to the tiny buffer size - + // that might cause the special message that's generated by ep.flush() to be missed, so we just want to make + // sure a flush will happen within a few seconds so getEventsFromLastRequest() won't time out. + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); try (DefaultEventProcessor ep = makeEventProcessor(config)) { From 07bb8f51a2f0a8eff2a838f41a2c8949fbc46f80 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 26 May 2020 11:58:01 -0700 Subject: [PATCH 493/673] (5.0) use simpler and more stable logger names --- CONTRIBUTING.md | 12 ++++ build.gradle | 2 +- .../launchdarkly/sdk/server/Components.java | 10 +-- .../sdk/server/DataSourceUpdatesImpl.java | 6 +- .../sdk/server/DefaultEventProcessor.java | 3 +- .../sdk/server/DefaultEventSender.java | 3 +- .../sdk/server/DefaultFeatureRequestor.java | 3 +- .../launchdarkly/sdk/server/Evaluator.java | 3 +- .../sdk/server/EventBroadcasterImpl.java | 4 +- .../com/launchdarkly/sdk/server/LDClient.java | 71 +++++++++---------- .../com/launchdarkly/sdk/server/Loggers.java | 49 +++++++++++++ .../PersistentDataStoreStatusManager.java | 3 +- .../server/PersistentDataStoreWrapper.java | 3 +- .../sdk/server/PollingProcessor.java | 3 +- .../sdk/server/StreamProcessor.java | 34 ++++++++- .../integrations/FileDataSourceImpl.java | 3 +- 16 files changed, 146 insertions(+), 66 deletions(-) create mode 100644 src/main/java/com/launchdarkly/sdk/server/Loggers.java diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 229d7cad7..8e8f118c7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,3 +42,15 @@ To build the SDK and run all unit tests: ``` ./gradlew test ``` + +## Coding best practices + +### Logging + +Currently the SDK uses SLF4J for all log output. Here some things to keep in mind for good logging behavior: + +1. Stick to the standardized logger name scheme defined in `Loggers.java`, preferably for all log output, but definitely for all log output above `DEBUG` level. Logger names can be useful for filtering log output, so it is desirable for users to be able to reference a clear, stable logger name like `com.launchdarkly.sdk.server.LDClient.Events` rather than a class name like `com.launchdarkly.sdk.server.EventSummarizer` which is an implementation detail. The text of a log message should be distinctive enough that we can easily find which class generated the message. + +2. Use parameterized messages (`Logger.MAIN.info("The value is {}", someValue)`) rather than string concatenation (`Logger.MAIN.info("The value is " + someValue)`). This avoids the overhead of string concatenation if the logger is not enabled for that level. If computing the value is an expensive operation, and it is _only_ relevant for logging, consider implementing that computation via a custom `toString()` method on some wrapper type so that it will be done lazily only if the log level is enabled. + +3. Exception stacktraces should only be logged at debug level. For instance: `Logger.MAIN.warn("An error happened: {}", ex.toString()); Logger.MAIN.debug(ex.toString(), ex)`. Also, consider whether the stacktrace would be at all meaningful in this particular context; for instance, in a `try` block around a network I/O operation, the stacktrace would only tell us (a) some internal location in Java standard libraries and (b) the location in our own code where we tried to do the operation; (a) is very unlikely to tell us anything that the exception's type and message doesn't already tell us, and (b) could be more clearly communicated by just writing a specific log message. diff --git a/build.gradle b/build.gradle index ca071191e..cd3a7d4f2 100644 --- a/build.gradle +++ b/build.gradle @@ -72,7 +72,7 @@ ext.versions = [ "guava": "28.2-jre", "jackson": "2.10.0", "launchdarklyJavaSdkCommon": "1.0.0-rc1", - "okhttpEventsource": "2.2.0", + "okhttpEventsource": "2.3.0-SNAPSHOT", "slf4j": "1.7.21", "snakeyaml": "1.19", "jedis": "2.9.0" diff --git a/src/main/java/com/launchdarkly/sdk/server/Components.java b/src/main/java/com/launchdarkly/sdk/server/Components.java index b61203327..89d641c4c 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Components.java +++ b/src/main/java/com/launchdarkly/sdk/server/Components.java @@ -332,9 +332,9 @@ public DataSource createDataSource(ClientContext context, DataSourceUpdates data if (context.isOffline()) { // 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"); + Loggers.MAIN.info("Starting LaunchDarkly client in offline mode"); } else { - LDClient.logger.info("LaunchDarkly client will not connect to Launchdarkly for feature flag data"); + Loggers.MAIN.info("LaunchDarkly client will not connect to Launchdarkly for feature flag data"); } dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.VALID, null); return NullDataSource.INSTANCE; @@ -381,7 +381,7 @@ public DataSource createDataSource(ClientContext context, DataSourceUpdates data return Components.externalUpdatesOnly().createDataSource(context, dataSourceUpdates); } - LDClient.logger.info("Enabling streaming API"); + Loggers.DATA_SOURCE.info("Enabling streaming API"); URI streamUri = baseURI == null ? LDConfig.DEFAULT_STREAM_URI : baseURI; URI pollUri; @@ -440,8 +440,8 @@ public DataSource createDataSource(ClientContext context, DataSourceUpdates data return Components.externalUpdatesOnly().createDataSource(context, dataSourceUpdates); } - LDClient.logger.info("Disabling streaming API"); - LDClient.logger.warn("You should only disable the streaming API if instructed to do so by LaunchDarkly support"); + Loggers.DATA_SOURCE.info("Disabling streaming API"); + Loggers.DATA_SOURCE.warn("You should only disable the streaming API if instructed to do so by LaunchDarkly support"); DefaultFeatureRequestor requestor = new DefaultFeatureRequestor( context.getSdkKey(), diff --git a/src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java b/src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java index 28da505a7..616e54581 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java @@ -263,10 +263,10 @@ private Set computeChangedItemsForFullDataSet(Map inbox; diff --git a/src/main/java/com/launchdarkly/sdk/server/DefaultEventSender.java b/src/main/java/com/launchdarkly/sdk/server/DefaultEventSender.java index 8ccba212e..86702d59d 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DefaultEventSender.java +++ b/src/main/java/com/launchdarkly/sdk/server/DefaultEventSender.java @@ -5,7 +5,6 @@ import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.io.IOException; import java.net.URI; @@ -30,7 +29,7 @@ import okhttp3.Response; final class DefaultEventSender implements EventSender { - private static final Logger logger = LoggerFactory.getLogger(DefaultEventProcessor.class); + private static final Logger logger = Loggers.EVENTS; static final Duration DEFAULT_RETRY_DELAY = Duration.ofSeconds(1); private static final String EVENT_SCHEMA_HEADER = "X-LaunchDarkly-Event-Schema"; diff --git a/src/main/java/com/launchdarkly/sdk/server/DefaultFeatureRequestor.java b/src/main/java/com/launchdarkly/sdk/server/DefaultFeatureRequestor.java index efcfd2d7a..b28cb5fb3 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DefaultFeatureRequestor.java +++ b/src/main/java/com/launchdarkly/sdk/server/DefaultFeatureRequestor.java @@ -13,7 +13,6 @@ import com.launchdarkly.sdk.server.interfaces.SerializationException; import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.io.File; import java.io.IOException; @@ -36,7 +35,7 @@ * Implementation of getting flag data via a polling request. Used by both streaming and polling components. */ final class DefaultFeatureRequestor implements FeatureRequestor { - private static final Logger logger = LoggerFactory.getLogger(DefaultFeatureRequestor.class); + private static final Logger logger = Loggers.DATA_SOURCE; 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"; diff --git a/src/main/java/com/launchdarkly/sdk/server/Evaluator.java b/src/main/java/com/launchdarkly/sdk/server/Evaluator.java index 4e3136dd4..4a9812ad8 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Evaluator.java +++ b/src/main/java/com/launchdarkly/sdk/server/Evaluator.java @@ -9,7 +9,6 @@ import com.launchdarkly.sdk.server.interfaces.Event; import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.util.ArrayList; import java.util.List; @@ -23,7 +22,7 @@ * flags, but does not send them. */ class Evaluator { - private final static Logger logger = LoggerFactory.getLogger(Evaluator.class); + private final static Logger logger = Loggers.EVALUATION; private final Getters getters; diff --git a/src/main/java/com/launchdarkly/sdk/server/EventBroadcasterImpl.java b/src/main/java/com/launchdarkly/sdk/server/EventBroadcasterImpl.java index e280184d1..d59aa1e1a 100644 --- a/src/main/java/com/launchdarkly/sdk/server/EventBroadcasterImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/EventBroadcasterImpl.java @@ -90,8 +90,8 @@ void broadcast(EventT event) { try { broadcastAction.accept(l, event); } catch (Exception e) { - LDClient.logger.warn("Unexpected error from listener ({0}): {1}", l.getClass(), e.toString()); - LDClient.logger.debug(e.toString(), e); + Loggers.MAIN.warn("Unexpected error from listener ({0}): {1}", l.getClass(), e.toString()); + Loggers.MAIN.debug(e.toString(), e); } }); } diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClient.java b/src/main/java/com/launchdarkly/sdk/server/LDClient.java index d797ddfd2..437c7b869 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClient.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClient.java @@ -24,8 +24,6 @@ import com.launchdarkly.sdk.server.interfaces.LDClientInterface; import org.apache.commons.codec.binary.Hex; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.io.IOException; import java.io.UnsupportedEncodingException; @@ -55,9 +53,6 @@ * 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(); @@ -161,9 +156,9 @@ public LDClient(String sdkKey, LDConfig config) { if (config.httpConfig.getProxy() != null) { if (config.httpConfig.getProxyAuthentication() != null) { - logger.info("Using proxy: {} with authentication.", config.httpConfig.getProxy()); + Loggers.MAIN.info("Using proxy: {} with authentication.", config.httpConfig.getProxy()); } else { - logger.info("Using proxy: {} without authentication.", config.httpConfig.getProxy()); + Loggers.MAIN.info("Using proxy: {} without authentication.", config.httpConfig.getProxy()); } } @@ -221,18 +216,18 @@ public DataModel.Segment getSegment(String key) { Future startFuture = dataSource.start(); if (!config.startWait.isZero() && !config.startWait.isNegative()) { if (!(dataSource instanceof Components.NullDataSource)) { - logger.info("Waiting up to " + config.startWait.toMillis() + " milliseconds for LaunchDarkly client to start..."); + Loggers.MAIN.info("Waiting up to " + config.startWait.toMillis() + " milliseconds for LaunchDarkly client to start..."); } try { startFuture.get(config.startWait.toMillis(), TimeUnit.MILLISECONDS); } catch (TimeoutException e) { - logger.error("Timeout encountered waiting for LaunchDarkly client initialization"); + Loggers.MAIN.error("Timeout encountered waiting for LaunchDarkly client initialization"); } catch (Exception e) { - logger.error("Exception encountered waiting for LaunchDarkly client initialization: {}", e.toString()); - logger.debug(e.toString(), e); + Loggers.MAIN.error("Exception encountered waiting for LaunchDarkly client initialization: {}", e.toString()); + Loggers.MAIN.debug(e.toString(), e); } if (!dataSource.isInitialized()) { - logger.warn("LaunchDarkly client was not successfully initialized"); + Loggers.MAIN.warn("LaunchDarkly client was not successfully initialized"); } } } @@ -250,7 +245,7 @@ public void track(String eventName, LDUser user) { @Override public void trackData(String eventName, LDUser user, LDValue data) { if (user == null || user.getKey() == null) { - logger.warn("Track called with null user or null user key!"); + Loggers.MAIN.warn("Track called with null user or null user key!"); } else { eventProcessor.sendEvent(EventFactory.DEFAULT.newCustomEvent(eventName, user, data, null)); } @@ -259,7 +254,7 @@ public void trackData(String eventName, LDUser user, LDValue data) { @Override public void trackMetric(String eventName, LDUser user, LDValue data, double metricValue) { if (user == null || user.getKey() == null) { - logger.warn("Track called with null user or null user key!"); + Loggers.MAIN.warn("Track called with null user or null user key!"); } else { eventProcessor.sendEvent(EventFactory.DEFAULT.newCustomEvent(eventName, user, data, metricValue)); } @@ -268,7 +263,7 @@ public void trackMetric(String eventName, LDUser user, LDValue data, double metr @Override public void identify(LDUser user) { if (user == null || user.getKey() == null) { - logger.warn("Identify called with null user or null user key!"); + Loggers.MAIN.warn("Identify called with null user or null user key!"); } else { eventProcessor.sendEvent(EventFactory.DEFAULT.newIdentifyEvent(user)); } @@ -283,20 +278,20 @@ public FeatureFlagsState allFlagsState(LDUser user, FlagsStateOption... options) FeatureFlagsState.Builder builder = new FeatureFlagsState.Builder(options); if (isOffline()) { - logger.debug("allFlagsState() was called when client is in offline mode."); + Loggers.EVALUATION.debug("allFlagsState() was called when client is in offline mode."); } if (!isInitialized()) { if (dataStore.isInitialized()) { - logger.warn("allFlagsState() was called before client initialized; using last known values from data store"); + Loggers.EVALUATION.warn("allFlagsState() was called before client initialized; using last known values from data store"); } else { - logger.warn("allFlagsState() was called before client initialized; data store unavailable, returning no data"); + Loggers.EVALUATION.warn("allFlagsState() was called before client initialized; data store unavailable, returning no data"); return builder.valid(false).build(); } } if (user == null || user.getKey() == null) { - logger.warn("allFlagsState() was called with null user or null user key! returning no data"); + Loggers.EVALUATION.warn("allFlagsState() was called with null user or null user key! returning no data"); return builder.valid(false).build(); } @@ -314,8 +309,8 @@ public FeatureFlagsState allFlagsState(LDUser user, FlagsStateOption... options) Evaluator.EvalResult result = evaluator.evaluate(flag, user, EventFactory.DEFAULT); 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); + Loggers.EVALUATION.error("Exception caught for feature flag \"{}\" when evaluating all flags: {}", entry.getKey(), e.toString()); + Loggers.EVALUATION.debug(e.toString(), e); builder.addFlag(flag, new Evaluator.EvalResult(LDValue.ofNull(), NO_VARIATION, EvaluationReason.exception(e))); } } @@ -389,9 +384,9 @@ public EvaluationDetail jsonValueVariationDetail(String featureKey, LDU public boolean isFlagKnown(String featureKey) { if (!isInitialized()) { if (dataStore.isInitialized()) { - logger.warn("isFlagKnown called before client initialized for feature flag \"{}\"; using last known values from data store", featureKey); + Loggers.MAIN.warn("isFlagKnown called before client initialized for feature flag \"{}\"; using last known values from data store", featureKey); } else { - logger.warn("isFlagKnown called before client initialized for feature flag \"{}\"; data store unavailable, returning false", featureKey); + Loggers.MAIN.warn("isFlagKnown called before client initialized for feature flag \"{}\"; data store unavailable, returning false", featureKey); return false; } } @@ -401,8 +396,8 @@ public boolean isFlagKnown(String featureKey) { return true; } } catch (Exception e) { - logger.error("Encountered exception while calling isFlagKnown for feature flag \"{}\": {}", e.toString()); - logger.debug(e.toString(), e); + Loggers.MAIN.error("Encountered exception while calling isFlagKnown for feature flag \"{}\": {}", e.toString()); + Loggers.MAIN.debug(e.toString(), e); } return false; @@ -420,9 +415,9 @@ private Evaluator.EvalResult evaluateInternal(String featureKey, LDUser user, LD EventFactory eventFactory) { if (!isInitialized()) { if (dataStore.isInitialized()) { - logger.warn("Evaluation called before client initialized for feature flag \"{}\"; using last known values from data store", featureKey); + Loggers.EVALUATION.warn("Evaluation called before client initialized for feature flag \"{}\"; using last known values from data store", featureKey); } else { - logger.warn("Evaluation called before client initialized for feature flag \"{}\"; data store unavailable, returning default value", featureKey); + Loggers.EVALUATION.warn("Evaluation called before client initialized for feature flag \"{}\"; data store unavailable, returning default value", featureKey); sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue, EvaluationReason.ErrorKind.CLIENT_NOT_READY)); return errorResult(EvaluationReason.ErrorKind.CLIENT_NOT_READY, defaultValue); @@ -433,19 +428,19 @@ private Evaluator.EvalResult evaluateInternal(String featureKey, LDUser user, LD try { featureFlag = getFlag(dataStore, featureKey); if (featureFlag == null) { - logger.info("Unknown feature flag \"{}\"; returning default value", featureKey); + Loggers.EVALUATION.info("Unknown feature flag \"{}\"; returning default value", featureKey); sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue, EvaluationReason.ErrorKind.FLAG_NOT_FOUND)); return errorResult(EvaluationReason.ErrorKind.FLAG_NOT_FOUND, defaultValue); } if (user == null || user.getKey() == null) { - logger.warn("Null user or null user key when evaluating flag \"{}\"; returning default value", featureKey); + Loggers.EVALUATION.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)); return errorResult(EvaluationReason.ErrorKind.USER_NOT_SPECIFIED, defaultValue); } if (user.getKey().isEmpty()) { - logger.warn("User key is blank. Flag evaluation will proceed, but the user will not be stored in LaunchDarkly"); + Loggers.EVALUATION.warn("User key is blank. Flag evaluation will proceed, but the user will not be stored in LaunchDarkly"); } Evaluator.EvalResult evalResult = evaluator.evaluate(featureFlag, user, eventFactory); for (Event.FeatureRequest event : evalResult.getPrerequisiteEvents()) { @@ -456,7 +451,7 @@ private Evaluator.EvalResult evaluateInternal(String featureKey, LDUser user, LD } else { LDValue value = evalResult.getValue(); // guaranteed not to be an actual Java null, but can be LDValue.ofNull() if (checkType && !value.isNull() && !defaultValue.isNull() && defaultValue.getType() != value.getType()) { - logger.error("Feature flag evaluation expected result as {}, but got {}", defaultValue.getType(), value.getType()); + Loggers.EVALUATION.error("Feature flag evaluation expected result as {}, but got {}", defaultValue.getType(), value.getType()); sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue, EvaluationReason.ErrorKind.WRONG_TYPE)); return errorResult(EvaluationReason.ErrorKind.WRONG_TYPE, defaultValue); @@ -465,8 +460,8 @@ private Evaluator.EvalResult evaluateInternal(String featureKey, LDUser user, LD sendFlagRequestEvent(eventFactory.newFeatureRequestEvent(featureFlag, user, evalResult, defaultValue)); return evalResult; } catch (Exception e) { - logger.error("Encountered exception while evaluating feature flag \"{}\": {}", featureKey, e.toString()); - logger.debug(e.toString(), e); + Loggers.EVALUATION.error("Encountered exception while evaluating feature flag \"{}\": {}", featureKey, e.toString()); + Loggers.EVALUATION.debug(e.toString(), e); if (featureFlag == null) { sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue, EvaluationReason.ErrorKind.EXCEPTION)); @@ -495,7 +490,7 @@ public DataSourceStatusProvider getDataSourceStatusProvider() { @Override public void close() throws IOException { - logger.info("Closing LaunchDarkly Client"); + Loggers.MAIN.info("Closing LaunchDarkly Client"); this.dataStore.close(); this.eventProcessor.close(); this.dataSource.close(); @@ -523,8 +518,8 @@ public String secureModeHash(LDUser user) { mac.init(new SecretKeySpec(sdkKey.getBytes(), HMAC_ALGORITHM)); return Hex.encodeHexString(mac.doFinal(user.getKey().getBytes("UTF8"))); } catch (InvalidKeyException | UnsupportedEncodingException | NoSuchAlgorithmException e) { - logger.error("Could not generate secure mode hash: {}", e.toString()); - logger.debug(e.toString(), e); + Loggers.MAIN.error("Could not generate secure mode hash: {}", e.toString()); + Loggers.MAIN.debug(e.toString(), e); } return null; } @@ -569,8 +564,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.toString()); - logger.debug(e.toString(), e); + Loggers.MAIN.warn("Unable to determine LaunchDarkly client library version: {}", e.toString()); + Loggers.MAIN.debug(e.toString(), e); return "Unknown"; } } diff --git a/src/main/java/com/launchdarkly/sdk/server/Loggers.java b/src/main/java/com/launchdarkly/sdk/server/Loggers.java new file mode 100644 index 000000000..07fee1441 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/Loggers.java @@ -0,0 +1,49 @@ +package com.launchdarkly.sdk.server; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Static logger instances to be shared by implementation code in the main {@code com.launchdarkly.sdk.server} + * package. + *

    + * The goal here is 1. to centralize logger references rather than having many calls to + * {@code LoggerFactory.getLogger()} all over the code, and 2. to encourage usage of a basic set of + * logger names that are not tied to class names besides the main LDClient class. Most class names in + * the SDK are package-private implementation details that are not meaningful to users, so in terms of + * both being able to see the relevant area of functionality at a glance when reading a log and also + * convenience in defining SLF4J logger name filters, it is preferable to use these stable names. + *

    + * Code in other packages such as {@code com.launchdarkly.sdk.server.integrations} cannot use these + * package-private fields, but should still use equivalent logger names as appropriate. + */ +abstract class Loggers { + private Loggers() {} + + private static final String BASE_NAME = LDClient.class.getName(); + + /** + * The default logger instance to use for SDK messages: "com.launchdarkly.sdk.server.LDClient" + */ + static final Logger MAIN = LoggerFactory.getLogger(BASE_NAME); + + /** + * The logger instance to use for messages related to polling, streaming, etc.: "com.launchdarkly.sdk.server.LDClient.DataSource" + */ + static final Logger DATA_SOURCE = LoggerFactory.getLogger(BASE_NAME + ".DataSource"); + + /** + * The logger instance to use for messages related to data store problems: "com.launchdarkly.sdk.server.LDClient.DataStore" + */ + static final Logger DATA_STORE = LoggerFactory.getLogger(BASE_NAME + ".DataStore"); + + /** + * The logger instance to use for messages related to flag evaluation: "com.launchdarkly.sdk.server.LDClient.Evaluation" + */ + static final Logger EVALUATION = LoggerFactory.getLogger(BASE_NAME + ".Evaluation"); + + /** + * The logger instance to use for messages from the event processor: "com.launchdarkly.sdk.server.LDClient.Events" + */ + static final Logger EVENTS = LoggerFactory.getLogger(BASE_NAME + ".Events"); +} diff --git a/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreStatusManager.java b/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreStatusManager.java index ffb30daab..c0518bd35 100644 --- a/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreStatusManager.java +++ b/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreStatusManager.java @@ -4,7 +4,6 @@ import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider.Status; import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.util.concurrent.Callable; import java.util.concurrent.ScheduledExecutorService; @@ -19,7 +18,7 @@ * clarity and also lets us reuse this logic in tests. */ final class PersistentDataStoreStatusManager { - private static final Logger logger = LoggerFactory.getLogger(PersistentDataStoreStatusManager.class); + private static final Logger logger = Loggers.DATA_STORE; static final int POLL_INTERVAL_MS = 500; // visible for testing private final Consumer statusUpdater; diff --git a/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapper.java b/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapper.java index b202dd453..746aa097f 100644 --- a/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapper.java +++ b/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapper.java @@ -20,7 +20,6 @@ import com.launchdarkly.sdk.server.interfaces.PersistentDataStore; import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.io.IOException; import java.time.Duration; @@ -45,7 +44,7 @@ * This class is only constructed by {@link PersistentDataStoreBuilder}. */ final class PersistentDataStoreWrapper implements DataStore { - private static final Logger logger = LoggerFactory.getLogger(PersistentDataStoreWrapper.class); + private static final Logger logger = Loggers.DATA_STORE; private final PersistentDataStore core; private final LoadingCache> itemCache; diff --git a/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java b/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java index 76fccaa00..80e7488e7 100644 --- a/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java @@ -9,7 +9,6 @@ import com.launchdarkly.sdk.server.interfaces.SerializationException; import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.io.IOException; import java.time.Duration; @@ -24,7 +23,7 @@ import static com.launchdarkly.sdk.server.Util.httpErrorDescription; final class PollingProcessor implements DataSource { - private static final Logger logger = LoggerFactory.getLogger(PollingProcessor.class); + private static final Logger logger = Loggers.DATA_SOURCE; private static final String ERROR_CONTEXT_MESSAGE = "on polling request"; private static final String WILL_RETRY_MESSAGE = "will retry at next scheduled poll interval"; diff --git a/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java index ea9d80a12..e7060230b 100644 --- a/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java @@ -22,7 +22,6 @@ import com.launchdarkly.sdk.server.interfaces.SerializationException; import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.io.IOException; import java.net.URI; @@ -74,7 +73,7 @@ final class StreamProcessor implements DataSource { private static final String DELETE = "delete"; private static final String INDIRECT_PUT = "indirect/put"; private static final String INDIRECT_PATCH = "indirect/patch"; - private static final Logger logger = LoggerFactory.getLogger(StreamProcessor.class); + private static final Logger logger = Loggers.DATA_SOURCE; private static final Duration DEAD_CONNECTION_INTERVAL = Duration.ofSeconds(300); private static final String ERROR_CONTEXT_MESSAGE = "in stream connection"; private static final String WILL_RETRY_MESSAGE = "will retry"; @@ -408,6 +407,7 @@ public void onError(Throwable throwable) { private EventSource defaultEventSourceCreator(EventSourceParams params) { EventSource.Builder builder = new EventSource.Builder(params.handler, params.streamUri) .threadPriority(threadPriority) + .logger(new EventSourceLogger()) .clientBuilderActions(new EventSource.Builder.ClientConfigurer() { public void configure(OkHttpClient.Builder builder) { configureHttpClientBuilder(params.httpConfig, builder); @@ -494,4 +494,34 @@ private static final class DeleteData { @SuppressWarnings("unused") // used by Gson public DeleteData() { } } + + // We use this adapter so that EventSource's logging will go to the same logger name we use for other + // stream-related things (Loggers.DATA_SOURCE), rather than using "com.launchdarkly.eventsource.EventSource" + // which is an implementation detail SDK users shouldn't need to know about. + private static final class EventSourceLogger implements com.launchdarkly.eventsource.Logger { + @Override + public void debug(String format, Object param) { + Loggers.DATA_SOURCE.debug(format, param); + } + + @Override + public void debug(String format, Object param1, Object param2) { + Loggers.DATA_SOURCE.debug(format, param1, param2); + } + + @Override + public void info(String message) { + Loggers.DATA_SOURCE.info(message); + } + + @Override + public void warn(String message) { + Loggers.DATA_SOURCE.warn(message); + } + + @Override + public void error(String message) { + Loggers.DATA_SOURCE.error(message); + } + } } diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java index 02883af0b..e36d0e6b0 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java @@ -2,6 +2,7 @@ import com.google.common.collect.ImmutableList; import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.LDClient; import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FileDataException; import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FlagFactory; import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FlagFileParser; @@ -52,7 +53,7 @@ * optionally whenever files change. */ final class FileDataSourceImpl implements DataSource { - private static final Logger logger = LoggerFactory.getLogger(FileDataSourceImpl.class); + private static final Logger logger = LoggerFactory.getLogger(LDClient.class.getName() + ".DataSource"); private final DataSourceUpdates dataSourceUpdates; private final DataLoader dataLoader; From 2d0628d5b6e86be0fe01b295ce9f6381d83be7de Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 26 May 2020 19:44:27 -0700 Subject: [PATCH 494/673] better instructions --- CONTRIBUTING.md | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fae89e6f5..2501c2bf1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,10 +45,6 @@ To build the SDK and run all unit tests: ## Code coverage -It is important to keep unit test coverage as close to 100% as possible in this project. +It is important to keep unit test coverage as close to 100% as possible in this project. You can view the latest code coverage report in CircleCI, as `coverage/html/index.html` in the artifacts for the "Java 11 - Linux - OpenJDK" job. You can also run the report locally with `./gradlew jacocoTestCoverage` and view `./build/reports/jacoco/test`. -Sometimes a gap in coverage is unavoidable, usually because the compiler requires us to provide a code path for some condition that in practice can't happen and can't be tested, or because of a known issue with the code coverage tool. Please handle all such cases as follows: - -* Mark the code with an explanatory comment beginning with "COVERAGE:". - -The current coverage report can be observed by running `./gradlew jacocoTestReport` and viewing `build/reports/jacoco/test/html/index.html`. This report is also produced as an artifact of the CircleCI build for the most recent Java version. +Sometimes a gap in coverage is unavoidable, usually because the compiler requires us to provide a code path for some condition that in practice can't happen and can't be tested, or because of a known issue with the code coverage tool. In all such cases, please mark the code with an explanatory comment beginning with "COVERAGE:". From 427a87e9b1327bff21871f664314b89824044cbb Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 26 May 2020 19:49:35 -0700 Subject: [PATCH 495/673] more convenient way to set EventSource logger name --- .../com/launchdarkly/sdk/server/Loggers.java | 18 +++++++---- .../sdk/server/StreamProcessor.java | 32 +------------------ 2 files changed, 12 insertions(+), 38 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/Loggers.java b/src/main/java/com/launchdarkly/sdk/server/Loggers.java index 07fee1441..3571a6366 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Loggers.java +++ b/src/main/java/com/launchdarkly/sdk/server/Loggers.java @@ -20,30 +20,34 @@ abstract class Loggers { private Loggers() {} - private static final String BASE_NAME = LDClient.class.getName(); - + static final String BASE_LOGGER_NAME = LDClient.class.getName(); + static final String DATA_SOURCE_LOGGER_NAME = BASE_LOGGER_NAME + ".DataSource"; + static final String DATA_STORE_LOGGER_NAME = BASE_LOGGER_NAME + ".DataStore"; + static final String EVALUATION_LOGGER_NAME = BASE_LOGGER_NAME + ".Evaluation"; + static final String EVENTS_LOGGER_NAME = BASE_LOGGER_NAME + ".Events"; + /** * The default logger instance to use for SDK messages: "com.launchdarkly.sdk.server.LDClient" */ - static final Logger MAIN = LoggerFactory.getLogger(BASE_NAME); + static final Logger MAIN = LoggerFactory.getLogger(BASE_LOGGER_NAME); /** * The logger instance to use for messages related to polling, streaming, etc.: "com.launchdarkly.sdk.server.LDClient.DataSource" */ - static final Logger DATA_SOURCE = LoggerFactory.getLogger(BASE_NAME + ".DataSource"); + static final Logger DATA_SOURCE = LoggerFactory.getLogger(DATA_SOURCE_LOGGER_NAME); /** * The logger instance to use for messages related to data store problems: "com.launchdarkly.sdk.server.LDClient.DataStore" */ - static final Logger DATA_STORE = LoggerFactory.getLogger(BASE_NAME + ".DataStore"); + static final Logger DATA_STORE = LoggerFactory.getLogger(DATA_STORE_LOGGER_NAME); /** * The logger instance to use for messages related to flag evaluation: "com.launchdarkly.sdk.server.LDClient.Evaluation" */ - static final Logger EVALUATION = LoggerFactory.getLogger(BASE_NAME + ".Evaluation"); + static final Logger EVALUATION = LoggerFactory.getLogger(EVALUATION_LOGGER_NAME); /** * The logger instance to use for messages from the event processor: "com.launchdarkly.sdk.server.LDClient.Events" */ - static final Logger EVENTS = LoggerFactory.getLogger(BASE_NAME + ".Events"); + static final Logger EVENTS = LoggerFactory.getLogger(EVENTS_LOGGER_NAME); } diff --git a/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java index e7060230b..0fd02fdfb 100644 --- a/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java @@ -407,7 +407,7 @@ public void onError(Throwable throwable) { private EventSource defaultEventSourceCreator(EventSourceParams params) { EventSource.Builder builder = new EventSource.Builder(params.handler, params.streamUri) .threadPriority(threadPriority) - .logger(new EventSourceLogger()) + .loggerBaseName(Loggers.DATA_SOURCE_LOGGER_NAME) .clientBuilderActions(new EventSource.Builder.ClientConfigurer() { public void configure(OkHttpClient.Builder builder) { configureHttpClientBuilder(params.httpConfig, builder); @@ -494,34 +494,4 @@ private static final class DeleteData { @SuppressWarnings("unused") // used by Gson public DeleteData() { } } - - // We use this adapter so that EventSource's logging will go to the same logger name we use for other - // stream-related things (Loggers.DATA_SOURCE), rather than using "com.launchdarkly.eventsource.EventSource" - // which is an implementation detail SDK users shouldn't need to know about. - private static final class EventSourceLogger implements com.launchdarkly.eventsource.Logger { - @Override - public void debug(String format, Object param) { - Loggers.DATA_SOURCE.debug(format, param); - } - - @Override - public void debug(String format, Object param1, Object param2) { - Loggers.DATA_SOURCE.debug(format, param1, param2); - } - - @Override - public void info(String message) { - Loggers.DATA_SOURCE.info(message); - } - - @Override - public void warn(String message) { - Loggers.DATA_SOURCE.warn(message); - } - - @Override - public void error(String message) { - Loggers.DATA_SOURCE.error(message); - } - } } From a89504ed2d51ffa70525ebb1641bb0e06dac8de5 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 28 May 2020 11:28:10 -0700 Subject: [PATCH 496/673] (5.0) add HTTP default headers method + some component refactoring --- .../sdk/server/ClientContextImpl.java | 57 ++- .../launchdarkly/sdk/server/Components.java | 331 +---------------- .../sdk/server/ComponentsImpl.java | 351 ++++++++++++++++++ .../sdk/server/DefaultEventSender.java | 8 +- .../sdk/server/DefaultFeatureRequestor.java | 4 +- .../sdk/server/DiagnosticEvent.java | 44 ++- .../sdk/server/HttpConfigurationImpl.java | 12 +- .../com/launchdarkly/sdk/server/LDClient.java | 39 +- .../com/launchdarkly/sdk/server/LDConfig.java | 24 +- .../sdk/server/StreamProcessor.java | 3 +- .../com/launchdarkly/sdk/server/Util.java | 13 +- .../server/interfaces/BasicConfiguration.java | 54 +++ .../sdk/server/interfaces/ClientContext.java | 25 +- .../server/interfaces/EventSenderFactory.java | 4 +- .../server/interfaces/HttpConfiguration.java | 16 +- .../interfaces/HttpConfigurationFactory.java | 4 +- .../LoggingConfigurationFactory.java | 4 +- .../sdk/server/ClientContextImplTest.java | 45 +-- .../DefaultEventProcessorDiagnosticsTest.java | 4 +- .../server/DefaultEventProcessorTestBase.java | 3 +- .../sdk/server/DefaultEventSenderTest.java | 65 ++-- .../sdk/server/DiagnosticEventTest.java | 29 +- .../sdk/server/DiagnosticSdkTest.java | 15 +- .../sdk/server/FeatureRequestorTest.java | 11 +- .../sdk/server/JsonHelpersTest.java | 3 +- .../LDClientExternalUpdatesOnlyTest.java | 2 +- .../sdk/server/LDClientOfflineTest.java | 4 +- .../launchdarkly/sdk/server/LDClientTest.java | 2 +- .../launchdarkly/sdk/server/LDConfigTest.java | 64 ++-- .../sdk/server/StreamProcessorTest.java | 9 +- .../com/launchdarkly/sdk/server/TestUtil.java | 4 + .../com/launchdarkly/sdk/server/UtilTest.java | 8 +- .../HttpConfigurationBuilderTest.java | 36 +- .../LoggingConfigurationBuilderTest.java | 10 +- 34 files changed, 697 insertions(+), 610 deletions(-) create mode 100644 src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/interfaces/BasicConfiguration.java diff --git a/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java b/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java index dac5b6d34..2eeee8a81 100644 --- a/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java @@ -1,5 +1,6 @@ package com.launchdarkly.sdk.server; +import com.launchdarkly.sdk.server.interfaces.BasicConfiguration; import com.launchdarkly.sdk.server.interfaces.ClientContext; import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; import com.launchdarkly.sdk.server.interfaces.LoggingConfiguration; @@ -21,30 +22,24 @@ final class ClientContextImpl implements ClientContext { private static volatile ScheduledExecutorService fallbackSharedExecutor = null; - private final String sdkKey; + private final BasicConfiguration basicConfiguration; private final HttpConfiguration httpConfiguration; private final LoggingConfiguration loggingConfiguration; - private final boolean offline; - private final int threadPriority; final ScheduledExecutorService sharedExecutor; final DiagnosticAccumulator diagnosticAccumulator; final DiagnosticEvent.Init diagnosticInitEvent; private ClientContextImpl( - String sdkKey, + BasicConfiguration basicConfiguration, HttpConfiguration httpConfiguration, LoggingConfiguration loggingConfiguration, - boolean offline, - int threadPriority, ScheduledExecutorService sharedExecutor, DiagnosticAccumulator diagnosticAccumulator, DiagnosticEvent.Init diagnosticInitEvent ) { - this.sdkKey = sdkKey; + this.basicConfiguration = basicConfiguration; this.httpConfiguration = httpConfiguration; this.loggingConfiguration = loggingConfiguration; - this.offline = offline; - this.threadPriority = threadPriority; this.sharedExecutor = sharedExecutor; this.diagnosticAccumulator = diagnosticAccumulator; this.diagnosticInitEvent = diagnosticInitEvent; @@ -56,15 +51,21 @@ private ClientContextImpl( ScheduledExecutorService sharedExecutor, DiagnosticAccumulator diagnosticAccumulator ) { - this.sdkKey = sdkKey; - this.httpConfiguration = configuration.httpConfig; - this.loggingConfiguration = configuration.loggingConfig; - this.offline = configuration.offline; - this.threadPriority = configuration.threadPriority; + this.basicConfiguration = new BasicConfiguration(sdkKey, configuration.offline, configuration.threadPriority); + + this.httpConfiguration = configuration.httpConfigFactory.createHttpConfiguration(basicConfiguration); + this.loggingConfiguration = configuration.loggingConfigFactory.createLoggingConfiguration(basicConfiguration); + this.sharedExecutor = sharedExecutor; + if (!configuration.diagnosticOptOut && diagnosticAccumulator != null) { this.diagnosticAccumulator = diagnosticAccumulator; - this.diagnosticInitEvent = new DiagnosticEvent.Init(diagnosticAccumulator.dataSinceDate, diagnosticAccumulator.diagnosticId, configuration); + this.diagnosticInitEvent = new DiagnosticEvent.Init( + diagnosticAccumulator.dataSinceDate, + diagnosticAccumulator.diagnosticId, + configuration, + httpConfiguration + ); } else { this.diagnosticAccumulator = null; this.diagnosticInitEvent = null; @@ -72,30 +73,20 @@ private ClientContextImpl( } @Override - public String getSdkKey() { - return sdkKey; - } - - @Override - public boolean isOffline() { - return offline; + public BasicConfiguration getBasic() { + return basicConfiguration; } @Override - public HttpConfiguration getHttpConfiguration() { + public HttpConfiguration getHttp() { return httpConfiguration; } @Override - public LoggingConfiguration getLoggingConfiguration() { + public LoggingConfiguration getLogging() { return loggingConfiguration; } - @Override - public int getThreadPriority() { - return threadPriority; - } - /** * This mechanism is a convenience for internal components to access the package-private fields of the * context if it is a ClientContextImpl, and to receive null values for those fields if it is not. @@ -113,11 +104,9 @@ static ClientContextImpl get(ClientContext context) { } } return new ClientContextImpl( - context.getSdkKey(), - context.getHttpConfiguration(), - context.getLoggingConfiguration(), - context.isOffline(), - context.getThreadPriority(), + context.getBasic(), + context.getHttp(), + context.getLogging(), fallbackSharedExecutor, null, null diff --git a/src/main/java/com/launchdarkly/sdk/server/Components.java b/src/main/java/com/launchdarkly/sdk/server/Components.java index 3a8a7d922..d82cb8893 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Components.java +++ b/src/main/java/com/launchdarkly/sdk/server/Components.java @@ -1,40 +1,27 @@ package com.launchdarkly.sdk.server; -import com.launchdarkly.sdk.LDValue; -import com.launchdarkly.sdk.server.DiagnosticEvent.ConfigProperty; +import com.launchdarkly.sdk.server.ComponentsImpl.EventProcessorBuilderImpl; +import com.launchdarkly.sdk.server.ComponentsImpl.HttpBasicAuthentication; +import com.launchdarkly.sdk.server.ComponentsImpl.HttpConfigurationBuilderImpl; +import com.launchdarkly.sdk.server.ComponentsImpl.InMemoryDataStoreFactory; +import com.launchdarkly.sdk.server.ComponentsImpl.LoggingConfigurationBuilderImpl; +import com.launchdarkly.sdk.server.ComponentsImpl.NullDataSourceFactory; +import com.launchdarkly.sdk.server.ComponentsImpl.PersistentDataStoreBuilderImpl; +import com.launchdarkly.sdk.server.ComponentsImpl.PollingDataSourceBuilderImpl; +import com.launchdarkly.sdk.server.ComponentsImpl.StreamingDataSourceBuilderImpl; import com.launchdarkly.sdk.server.integrations.EventProcessorBuilder; import com.launchdarkly.sdk.server.integrations.HttpConfigurationBuilder; import com.launchdarkly.sdk.server.integrations.LoggingConfigurationBuilder; import com.launchdarkly.sdk.server.integrations.PersistentDataStoreBuilder; import com.launchdarkly.sdk.server.integrations.PollingDataSourceBuilder; import com.launchdarkly.sdk.server.integrations.StreamingDataSourceBuilder; -import com.launchdarkly.sdk.server.interfaces.ClientContext; -import com.launchdarkly.sdk.server.interfaces.DataSource; import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; -import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; -import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; -import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; -import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; -import com.launchdarkly.sdk.server.interfaces.DiagnosticDescription; -import com.launchdarkly.sdk.server.interfaces.Event; -import com.launchdarkly.sdk.server.interfaces.EventProcessor; import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; -import com.launchdarkly.sdk.server.interfaces.EventSender; import com.launchdarkly.sdk.server.interfaces.HttpAuthentication; -import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; -import com.launchdarkly.sdk.server.interfaces.LoggingConfiguration; -import com.launchdarkly.sdk.server.interfaces.PersistentDataStore; import com.launchdarkly.sdk.server.interfaces.PersistentDataStoreFactory; -import java.io.IOException; -import java.net.InetSocketAddress; -import java.net.Proxy; -import java.net.URI; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Future; - -import okhttp3.Credentials; +import static com.launchdarkly.sdk.server.ComponentsImpl.NULL_EVENT_PROCESSOR_FACTORY; /** * Provides configurable factories for the standard implementations of LaunchDarkly component interfaces. @@ -295,302 +282,4 @@ public static HttpAuthentication httpBasicAuthentication(String username, String public static LoggingConfigurationBuilder logging() { return new LoggingConfigurationBuilderImpl(); } - - private static final class InMemoryDataStoreFactory implements DataStoreFactory, DiagnosticDescription { - static final DataStoreFactory INSTANCE = new InMemoryDataStoreFactory(); - @Override - public DataStore createDataStore(ClientContext context, DataStoreUpdates dataStoreUpdates) { - return new InMemoryDataStore(); - } - - @Override - public LDValue describeConfiguration(LDConfig config) { - return LDValue.of("memory"); - } - } - - private static final EventProcessorFactory NULL_EVENT_PROCESSOR_FACTORY = context -> NullEventProcessor.INSTANCE; - - /** - * Stub implementation of {@link EventProcessor} for when we don't want to send any events. - */ - static final class NullEventProcessor implements EventProcessor { - static final NullEventProcessor INSTANCE = new NullEventProcessor(); - - private NullEventProcessor() {} - - @Override - public void sendEvent(Event e) { - } - - @Override - public void flush() { - } - - @Override - public void close() { - } - } - - private static final class NullDataSourceFactory implements DataSourceFactory, DiagnosticDescription { - static final NullDataSourceFactory INSTANCE = new NullDataSourceFactory(); - - @Override - public DataSource createDataSource(ClientContext context, DataSourceUpdates dataSourceUpdates) { - if (context.isOffline()) { - // 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"); - } - dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.VALID, null); - return NullDataSource.INSTANCE; - } - - @Override - public LDValue describeConfiguration(LDConfig config) { - // We can assume that if they don't have a data source, and they *do* have a persistent data store, then - // they're using Relay in daemon mode. - return LDValue.buildObject() - .put(ConfigProperty.CUSTOM_BASE_URI.name, false) - .put(ConfigProperty.CUSTOM_STREAM_URI.name, false) - .put(ConfigProperty.STREAMING_DISABLED.name, false) - .put(ConfigProperty.USING_RELAY_DAEMON.name, - config.dataStoreFactory != null && config.dataStoreFactory != Components.inMemoryDataStore()) - .build(); - } - } - - // Package-private for visibility in tests - static final class NullDataSource implements DataSource { - static final DataSource INSTANCE = new NullDataSource(); - @Override - public Future start() { - return CompletableFuture.completedFuture(null); - } - - @Override - public boolean isInitialized() { - return true; - } - - @Override - public void close() throws IOException {} - } - - private static final class StreamingDataSourceBuilderImpl extends StreamingDataSourceBuilder - implements DiagnosticDescription { - @Override - public DataSource createDataSource(ClientContext context, DataSourceUpdates dataSourceUpdates) { - // Note, we log startup messages under the LDClient class to keep logs more readable - - if (context.isOffline()) { - return Components.externalUpdatesOnly().createDataSource(context, dataSourceUpdates); - } - - 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( - context.getSdkKey(), - context.getHttpConfiguration(), - pollUri, - false - ); - - return new StreamProcessor( - context.getSdkKey(), - context.getHttpConfiguration(), - requestor, - dataSourceUpdates, - null, - context.getThreadPriority(), - ClientContextImpl.get(context).diagnosticAccumulator, - streamUri, - initialReconnectDelay - ); - } - - @Override - public LDValue describeConfiguration(LDConfig config) { - if (config.offline) { - return NullDataSourceFactory.INSTANCE.describeConfiguration(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))) - .put(ConfigProperty.CUSTOM_STREAM_URI.name, - baseURI != null && !baseURI.equals(LDConfig.DEFAULT_STREAM_URI)) - .put(ConfigProperty.RECONNECT_TIME_MILLIS.name, initialReconnectDelay.toMillis()) - .put(ConfigProperty.USING_RELAY_DAEMON.name, false) - .build(); - } - } - - private static final class PollingDataSourceBuilderImpl extends PollingDataSourceBuilder implements DiagnosticDescription { - @Override - public DataSource createDataSource(ClientContext context, DataSourceUpdates dataSourceUpdates) { - // Note, we log startup messages under the LDClient class to keep logs more readable - - if (context.isOffline()) { - return Components.externalUpdatesOnly().createDataSource(context, dataSourceUpdates); - } - - 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( - context.getSdkKey(), - context.getHttpConfiguration(), - baseURI == null ? LDConfig.DEFAULT_BASE_URI : baseURI, - true - ); - return new PollingProcessor( - requestor, - dataSourceUpdates, - ClientContextImpl.get(context).sharedExecutor, - pollInterval - ); - } - - @Override - public LDValue describeConfiguration(LDConfig config) { - if (config.offline) { - return NullDataSourceFactory.INSTANCE.describeConfiguration(config); - } - return LDValue.buildObject() - .put(ConfigProperty.STREAMING_DISABLED.name, true) - .put(ConfigProperty.CUSTOM_BASE_URI.name, - baseURI != null && !baseURI.equals(LDConfig.DEFAULT_BASE_URI)) - .put(ConfigProperty.CUSTOM_STREAM_URI.name, false) - .put(ConfigProperty.POLLING_INTERVAL_MILLIS.name, pollInterval.toMillis()) - .put(ConfigProperty.USING_RELAY_DAEMON.name, false) - .build(); - } - } - - private static final class EventProcessorBuilderImpl extends EventProcessorBuilder - implements DiagnosticDescription { - @Override - public EventProcessor createEventProcessor(ClientContext context) { - if (context.isOffline()) { - return new NullEventProcessor(); - } - EventSender eventSender = - (eventSenderFactory == null ? new DefaultEventSender.Factory() : eventSenderFactory) - .createEventSender(context.getSdkKey(), context.getHttpConfiguration()); - return new DefaultEventProcessor( - new EventsConfiguration( - allAttributesPrivate, - capacity, - eventSender, - baseURI == null ? LDConfig.DEFAULT_EVENTS_URI : baseURI, - flushInterval, - inlineUsersInEvents, - privateAttributes, - userKeysCapacity, - userKeysFlushInterval, - diagnosticRecordingInterval - ), - ClientContextImpl.get(context).sharedExecutor, - context.getThreadPriority(), - ClientContextImpl.get(context).diagnosticAccumulator, - ClientContextImpl.get(context).diagnosticInitEvent - ); - } - - @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, diagnosticRecordingInterval.toMillis()) - .put(ConfigProperty.EVENTS_CAPACITY.name, capacity) - .put(ConfigProperty.EVENTS_FLUSH_INTERVAL_MILLIS.name, flushInterval.toMillis()) - .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, userKeysFlushInterval.toMillis()) - .build(); - } - } - - private static final class HttpConfigurationBuilderImpl extends HttpConfigurationBuilder { - @Override - public HttpConfiguration createHttpConfiguration() { - return new HttpConfigurationImpl( - connectTimeout, - proxyHost == null ? null : new Proxy(Proxy.Type.HTTP, new InetSocketAddress(proxyHost, proxyPort)), - proxyAuth, - socketTimeout, - 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); - } - - @Override - public LDValue describeConfiguration(LDConfig config) { - if (persistentDataStoreFactory instanceof DiagnosticDescription) { - return ((DiagnosticDescription)persistentDataStoreFactory).describeConfiguration(config); - } - return LDValue.of("custom"); - } - - /** - * Called by the SDK to create the data store instance. - */ - @Override - public DataStore createDataStore(ClientContext context, DataStoreUpdates dataStoreUpdates) { - PersistentDataStore core = persistentDataStoreFactory.createPersistentDataStore(context); - return new PersistentDataStoreWrapper( - core, - cacheTime, - staleValuesPolicy, - recordCacheStats, - dataStoreUpdates, - ClientContextImpl.get(context).sharedExecutor - ); - } - } - - private static final class LoggingConfigurationBuilderImpl extends LoggingConfigurationBuilder { - @Override - public LoggingConfiguration createLoggingConfiguration() { - return new LoggingConfigurationImpl(logDataSourceOutageAsErrorAfter); - } - } } diff --git a/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java b/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java new file mode 100644 index 000000000..2cea4b416 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java @@ -0,0 +1,351 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.collect.ImmutableMap; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.DiagnosticEvent.ConfigProperty; +import com.launchdarkly.sdk.server.integrations.EventProcessorBuilder; +import com.launchdarkly.sdk.server.integrations.HttpConfigurationBuilder; +import com.launchdarkly.sdk.server.integrations.LoggingConfigurationBuilder; +import com.launchdarkly.sdk.server.integrations.PersistentDataStoreBuilder; +import com.launchdarkly.sdk.server.integrations.PollingDataSourceBuilder; +import com.launchdarkly.sdk.server.integrations.StreamingDataSourceBuilder; +import com.launchdarkly.sdk.server.interfaces.BasicConfiguration; +import com.launchdarkly.sdk.server.interfaces.ClientContext; +import com.launchdarkly.sdk.server.interfaces.DataSource; +import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; +import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; +import com.launchdarkly.sdk.server.interfaces.DiagnosticDescription; +import com.launchdarkly.sdk.server.interfaces.Event; +import com.launchdarkly.sdk.server.interfaces.EventProcessor; +import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; +import com.launchdarkly.sdk.server.interfaces.EventSender; +import com.launchdarkly.sdk.server.interfaces.HttpAuthentication; +import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; +import com.launchdarkly.sdk.server.interfaces.LoggingConfiguration; +import com.launchdarkly.sdk.server.interfaces.PersistentDataStore; +import com.launchdarkly.sdk.server.interfaces.PersistentDataStoreFactory; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.net.URI; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Future; + +import okhttp3.Credentials; + +/** + * This class contains the package-private implementations of component factories and builders whose + * public factory methods are in {@link Components}. + */ +abstract class ComponentsImpl { + private ComponentsImpl() {} + + static final class InMemoryDataStoreFactory implements DataStoreFactory, DiagnosticDescription { + static final DataStoreFactory INSTANCE = new InMemoryDataStoreFactory(); + @Override + public DataStore createDataStore(ClientContext context, DataStoreUpdates dataStoreUpdates) { + return new InMemoryDataStore(); + } + + @Override + public LDValue describeConfiguration(LDConfig config) { + return LDValue.of("memory"); + } + } + + static final EventProcessorFactory NULL_EVENT_PROCESSOR_FACTORY = context -> NullEventProcessor.INSTANCE; + + /** + * Stub implementation of {@link EventProcessor} for when we don't want to send any events. + */ + static final class NullEventProcessor implements EventProcessor { + static final NullEventProcessor INSTANCE = new NullEventProcessor(); + + private NullEventProcessor() {} + + @Override + public void sendEvent(Event e) { + } + + @Override + public void flush() { + } + + @Override + public void close() { + } + } + + static final class NullDataSourceFactory implements DataSourceFactory, DiagnosticDescription { + static final NullDataSourceFactory INSTANCE = new NullDataSourceFactory(); + + @Override + public DataSource createDataSource(ClientContext context, DataSourceUpdates dataSourceUpdates) { + if (context.getBasic().isOffline()) { + // 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"); + } + dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.VALID, null); + return NullDataSource.INSTANCE; + } + + @Override + public LDValue describeConfiguration(LDConfig config) { + // We can assume that if they don't have a data source, and they *do* have a persistent data store, then + // they're using Relay in daemon mode. + return LDValue.buildObject() + .put(ConfigProperty.CUSTOM_BASE_URI.name, false) + .put(ConfigProperty.CUSTOM_STREAM_URI.name, false) + .put(ConfigProperty.STREAMING_DISABLED.name, false) + .put(ConfigProperty.USING_RELAY_DAEMON.name, + config.dataStoreFactory != null && config.dataStoreFactory != Components.inMemoryDataStore()) + .build(); + } + } + + // Package-private for visibility in tests + static final class NullDataSource implements DataSource { + static final DataSource INSTANCE = new NullDataSource(); + @Override + public Future start() { + return CompletableFuture.completedFuture(null); + } + + @Override + public boolean isInitialized() { + return true; + } + + @Override + public void close() throws IOException {} + } + + static final class StreamingDataSourceBuilderImpl extends StreamingDataSourceBuilder + implements DiagnosticDescription { + @Override + public DataSource createDataSource(ClientContext context, DataSourceUpdates dataSourceUpdates) { + // Note, we log startup messages under the LDClient class to keep logs more readable + + if (context.getBasic().isOffline()) { + return Components.externalUpdatesOnly().createDataSource(context, dataSourceUpdates); + } + + 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( + context.getHttp(), + pollUri, + false + ); + + return new StreamProcessor( + context.getHttp(), + requestor, + dataSourceUpdates, + null, + context.getBasic().getThreadPriority(), + ClientContextImpl.get(context).diagnosticAccumulator, + streamUri, + initialReconnectDelay + ); + } + + @Override + public LDValue describeConfiguration(LDConfig config) { + if (config.offline) { + return NullDataSourceFactory.INSTANCE.describeConfiguration(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))) + .put(ConfigProperty.CUSTOM_STREAM_URI.name, + baseURI != null && !baseURI.equals(LDConfig.DEFAULT_STREAM_URI)) + .put(ConfigProperty.RECONNECT_TIME_MILLIS.name, initialReconnectDelay.toMillis()) + .put(ConfigProperty.USING_RELAY_DAEMON.name, false) + .build(); + } + } + + static final class PollingDataSourceBuilderImpl extends PollingDataSourceBuilder implements DiagnosticDescription { + @Override + public DataSource createDataSource(ClientContext context, DataSourceUpdates dataSourceUpdates) { + // Note, we log startup messages under the LDClient class to keep logs more readable + + if (context.getBasic().isOffline()) { + return Components.externalUpdatesOnly().createDataSource(context, dataSourceUpdates); + } + + 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( + context.getHttp(), + baseURI == null ? LDConfig.DEFAULT_BASE_URI : baseURI, + true + ); + return new PollingProcessor( + requestor, + dataSourceUpdates, + ClientContextImpl.get(context).sharedExecutor, + pollInterval + ); + } + + @Override + public LDValue describeConfiguration(LDConfig config) { + if (config.offline) { + return NullDataSourceFactory.INSTANCE.describeConfiguration(config); + } + return LDValue.buildObject() + .put(ConfigProperty.STREAMING_DISABLED.name, true) + .put(ConfigProperty.CUSTOM_BASE_URI.name, + baseURI != null && !baseURI.equals(LDConfig.DEFAULT_BASE_URI)) + .put(ConfigProperty.CUSTOM_STREAM_URI.name, false) + .put(ConfigProperty.POLLING_INTERVAL_MILLIS.name, pollInterval.toMillis()) + .put(ConfigProperty.USING_RELAY_DAEMON.name, false) + .build(); + } + } + + static final class EventProcessorBuilderImpl extends EventProcessorBuilder + implements DiagnosticDescription { + @Override + public EventProcessor createEventProcessor(ClientContext context) { + if (context.getBasic().isOffline()) { + return new NullEventProcessor(); + } + EventSender eventSender = + (eventSenderFactory == null ? new DefaultEventSender.Factory() : eventSenderFactory) + .createEventSender(context.getBasic(), context.getHttp()); + return new DefaultEventProcessor( + new EventsConfiguration( + allAttributesPrivate, + capacity, + eventSender, + baseURI == null ? LDConfig.DEFAULT_EVENTS_URI : baseURI, + flushInterval, + inlineUsersInEvents, + privateAttributes, + userKeysCapacity, + userKeysFlushInterval, + diagnosticRecordingInterval + ), + ClientContextImpl.get(context).sharedExecutor, + context.getBasic().getThreadPriority(), + ClientContextImpl.get(context).diagnosticAccumulator, + ClientContextImpl.get(context).diagnosticInitEvent + ); + } + + @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, diagnosticRecordingInterval.toMillis()) + .put(ConfigProperty.EVENTS_CAPACITY.name, capacity) + .put(ConfigProperty.EVENTS_FLUSH_INTERVAL_MILLIS.name, flushInterval.toMillis()) + .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, userKeysFlushInterval.toMillis()) + .build(); + } + } + + static final class HttpConfigurationBuilderImpl extends HttpConfigurationBuilder { + @Override + public HttpConfiguration createHttpConfiguration(BasicConfiguration basicConfiguration) { + // Build the default headers + ImmutableMap.Builder headers = ImmutableMap.builder(); + headers.put("Authorization", basicConfiguration.getSdkKey()); + headers.put("User-Agent", "JavaClient/" + Version.SDK_VERSION); + if (wrapperName != null) { + String wrapperId = wrapperVersion == null ? wrapperName : (wrapperName + "/" + wrapperVersion); + headers.put("X-LaunchDarkly-Wrapper", wrapperId); + } + + return new HttpConfigurationImpl( + connectTimeout, + proxyHost == null ? null : new Proxy(Proxy.Type.HTTP, new InetSocketAddress(proxyHost, proxyPort)), + proxyAuth, + socketTimeout, + sslSocketFactory, + trustManager, + headers.build() + ); + } + } + + 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); + } + } + + static final class PersistentDataStoreBuilderImpl extends PersistentDataStoreBuilder implements DiagnosticDescription { + public PersistentDataStoreBuilderImpl(PersistentDataStoreFactory persistentDataStoreFactory) { + super(persistentDataStoreFactory); + } + + @Override + public LDValue describeConfiguration(LDConfig config) { + if (persistentDataStoreFactory instanceof DiagnosticDescription) { + return ((DiagnosticDescription)persistentDataStoreFactory).describeConfiguration(config); + } + return LDValue.of("custom"); + } + + /** + * Called by the SDK to create the data store instance. + */ + @Override + public DataStore createDataStore(ClientContext context, DataStoreUpdates dataStoreUpdates) { + PersistentDataStore core = persistentDataStoreFactory.createPersistentDataStore(context); + return new PersistentDataStoreWrapper( + core, + cacheTime, + staleValuesPolicy, + recordCacheStats, + dataStoreUpdates, + ClientContextImpl.get(context).sharedExecutor + ); + } + } + + static final class LoggingConfigurationBuilderImpl extends LoggingConfigurationBuilder { + @Override + public LoggingConfiguration createLoggingConfiguration(BasicConfiguration basicConfiguration) { + return new LoggingConfigurationImpl(logDataSourceOutageAsErrorAfter); + } + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/DefaultEventSender.java b/src/main/java/com/launchdarkly/sdk/server/DefaultEventSender.java index 6ea5b9453..5b9108c49 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DefaultEventSender.java +++ b/src/main/java/com/launchdarkly/sdk/server/DefaultEventSender.java @@ -1,5 +1,6 @@ package com.launchdarkly.sdk.server; +import com.launchdarkly.sdk.server.interfaces.BasicConfiguration; import com.launchdarkly.sdk.server.interfaces.EventSender; import com.launchdarkly.sdk.server.interfaces.EventSenderFactory; import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; @@ -45,7 +46,6 @@ final class DefaultEventSender implements EventSender { final Duration retryDelay; // visible for testing DefaultEventSender( - String sdkKey, HttpConfiguration httpConfiguration, Duration retryDelay ) { @@ -53,7 +53,7 @@ final class DefaultEventSender implements EventSender { configureHttpClientBuilder(httpConfiguration, httpBuilder); this.httpClient = httpBuilder.build(); - this.baseHeaders = getHeadersBuilderFor(sdkKey, httpConfiguration) + this.baseHeaders = getHeadersBuilderFor(httpConfiguration) .add("Content-Type", "application/json") .build(); @@ -163,8 +163,8 @@ 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, DefaultEventSender.DEFAULT_RETRY_DELAY); + public EventSender createEventSender(BasicConfiguration basicConfiguration, HttpConfiguration httpConfiguration) { + return new DefaultEventSender(httpConfiguration, DefaultEventSender.DEFAULT_RETRY_DELAY); } } } diff --git a/src/main/java/com/launchdarkly/sdk/server/DefaultFeatureRequestor.java b/src/main/java/com/launchdarkly/sdk/server/DefaultFeatureRequestor.java index efcfd2d7a..aef2b9aab 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DefaultFeatureRequestor.java +++ b/src/main/java/com/launchdarkly/sdk/server/DefaultFeatureRequestor.java @@ -47,13 +47,13 @@ final class DefaultFeatureRequestor implements FeatureRequestor { private final Headers headers; private final boolean useCache; - DefaultFeatureRequestor(String sdkKey, HttpConfiguration httpConfig, URI baseUri, boolean useCache) { + DefaultFeatureRequestor(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(); + this.headers = getHeadersBuilderFor(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. diff --git a/src/main/java/com/launchdarkly/sdk/server/DiagnosticEvent.java b/src/main/java/com/launchdarkly/sdk/server/DiagnosticEvent.java index ed8f5db2e..71b40e2ad 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DiagnosticEvent.java +++ b/src/main/java/com/launchdarkly/sdk/server/DiagnosticEvent.java @@ -4,8 +4,10 @@ import com.launchdarkly.sdk.LDValueType; import com.launchdarkly.sdk.ObjectBuilder; import com.launchdarkly.sdk.server.interfaces.DiagnosticDescription; +import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; import java.util.List; +import java.util.Map; class DiagnosticEvent { static enum ConfigProperty { @@ -80,20 +82,20 @@ static class Init extends DiagnosticEvent { final LDValue configuration; final DiagnosticPlatform platform = new DiagnosticPlatform(); - Init(long creationDate, DiagnosticId diagnosticId, LDConfig config) { + Init(long creationDate, DiagnosticId diagnosticId, LDConfig config, HttpConfiguration httpConfig) { super("diagnostic-init", creationDate, diagnosticId); - this.sdk = new DiagnosticSdk(config); - this.configuration = getConfigurationData(config); + this.sdk = new DiagnosticSdk(httpConfig); + this.configuration = getConfigurationData(config, httpConfig); } - static LDValue getConfigurationData(LDConfig config) { + static LDValue getConfigurationData(LDConfig config, HttpConfiguration httpConfig) { ObjectBuilder builder = LDValue.buildObject(); // Add the top-level properties that are not specific to a particular component type. - builder.put("connectTimeoutMillis", config.httpConfig.getConnectTimeout().toMillis()); - builder.put("socketTimeoutMillis", config.httpConfig.getSocketTimeout().toMillis()); - builder.put("usingProxy", config.httpConfig.getProxy() != null); - builder.put("usingProxyAuthenticator", config.httpConfig.getProxyAuthentication() != null); + builder.put("connectTimeoutMillis", httpConfig.getConnectTimeout().toMillis()); + builder.put("socketTimeoutMillis", httpConfig.getSocketTimeout().toMillis()); + builder.put("usingProxy", httpConfig.getProxy() != null); + builder.put("usingProxyAuthenticator", httpConfig.getProxyAuthentication() != null); builder.put("offline", config.offline); builder.put("startWaitMillis", config.startWait.toMillis()); @@ -152,20 +154,22 @@ static class DiagnosticSdk { final String wrapperName; final String wrapperVersion; - DiagnosticSdk(LDConfig config) { - 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; + DiagnosticSdk(HttpConfiguration httpConfig) { + for (Map.Entry headers: httpConfig.getDefaultHeaders()) { + if (headers.getKey().equalsIgnoreCase("X-LaunchDarkly-Wrapper") ) { + String id = headers.getValue(); + 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; + } + return; } } + this.wrapperName = null; + this.wrapperVersion = null; } } diff --git a/src/main/java/com/launchdarkly/sdk/server/HttpConfigurationImpl.java b/src/main/java/com/launchdarkly/sdk/server/HttpConfigurationImpl.java index 7a616c58a..f110eb19c 100644 --- a/src/main/java/com/launchdarkly/sdk/server/HttpConfigurationImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/HttpConfigurationImpl.java @@ -1,10 +1,12 @@ package com.launchdarkly.sdk.server; +import com.google.common.collect.ImmutableMap; import com.launchdarkly.sdk.server.interfaces.HttpAuthentication; import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; import java.net.Proxy; import java.time.Duration; +import java.util.Map; import javax.net.ssl.SSLSocketFactory; import javax.net.ssl.X509TrustManager; @@ -16,18 +18,18 @@ final class HttpConfigurationImpl implements HttpConfiguration { final Duration socketTimeout; final SSLSocketFactory sslSocketFactory; final X509TrustManager trustManager; - final String wrapper; + final ImmutableMap defaultHeaders; HttpConfigurationImpl(Duration connectTimeout, Proxy proxy, HttpAuthentication proxyAuth, Duration socketTimeout, SSLSocketFactory sslSocketFactory, X509TrustManager trustManager, - String wrapper) { + ImmutableMap defaultHeaders) { this.connectTimeout = connectTimeout; this.proxy = proxy; this.proxyAuth = proxyAuth; this.socketTimeout = socketTimeout; this.sslSocketFactory = sslSocketFactory; this.trustManager = trustManager; - this.wrapper = wrapper; + this.defaultHeaders = defaultHeaders; } @Override @@ -61,7 +63,7 @@ public X509TrustManager getTrustManager() { } @Override - public String getWrapperIdentifier() { - return wrapper; + public Iterable> getDefaultHeaders() { + return defaultHeaders.entrySet(); } } diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClient.java b/src/main/java/com/launchdarkly/sdk/server/LDClient.java index b6667d847..1d6273e96 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClient.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClient.java @@ -7,17 +7,14 @@ import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.server.integrations.EventProcessorBuilder; import com.launchdarkly.sdk.server.interfaces.DataSource; -import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; import com.launchdarkly.sdk.server.interfaces.DataStore; -import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; import com.launchdarkly.sdk.server.interfaces.Event; import com.launchdarkly.sdk.server.interfaces.EventProcessor; -import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; import com.launchdarkly.sdk.server.interfaces.FlagTracker; @@ -156,9 +153,7 @@ public LDClient(String sdkKey, LDConfig config) { this.sharedExecutor = createSharedExecutor(config); - final EventProcessorFactory epFactory = config.eventProcessorFactory == null ? - Components.sendEvents() : config.eventProcessorFactory; - boolean eventsDisabled = Components.isNullImplementation(epFactory); + boolean eventsDisabled = Components.isNullImplementation(config.eventProcessorFactory); if (eventsDisabled) { this.eventFactoryDefault = EventFactory.Disabled.INSTANCE; this.eventFactoryWithReasons = EventFactory.Disabled.INSTANCE; @@ -167,17 +162,9 @@ public LDClient(String sdkKey, LDConfig config) { this.eventFactoryWithReasons = EventFactory.DEFAULT_WITH_REASONS; } - 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()); - } - } - // Do not create diagnostic accumulator if config has specified is opted out, or if we're not using the // standard event processor - final boolean useDiagnostics = !config.diagnosticOptOut && epFactory instanceof EventProcessorBuilder; + final boolean useDiagnostics = !config.diagnosticOptOut && config.eventProcessorFactory instanceof EventProcessorBuilder; final ClientContextImpl context = new ClientContextImpl( sdkKey, config, @@ -185,14 +172,20 @@ public LDClient(String sdkKey, LDConfig config) { useDiagnostics ? new DiagnosticAccumulator(new DiagnosticId(sdkKey)) : null ); - this.eventProcessor = epFactory.createEventProcessor(context); + if (context.getHttp().getProxy() != null) { + if (context.getHttp().getProxyAuthentication() != null) { + logger.info("Using proxy: {} with authentication.", context.getHttp().getProxy()); + } else { + logger.info("Using proxy: {} without authentication.", context.getHttp().getProxy()); + } + } + + this.eventProcessor = config.eventProcessorFactory.createEventProcessor(context); - DataStoreFactory factory = config.dataStoreFactory == null ? - Components.inMemoryDataStore() : config.dataStoreFactory; EventBroadcasterImpl dataStoreStatusNotifier = EventBroadcasterImpl.forDataStoreStatus(sharedExecutor); DataStoreUpdatesImpl dataStoreUpdates = new DataStoreUpdatesImpl(dataStoreStatusNotifier); - this.dataStore = factory.createDataStore(context, dataStoreUpdates); + this.dataStore = config.dataStoreFactory.createDataStore(context, dataStoreUpdates); this.evaluator = new Evaluator(new Evaluator.Getters() { public DataModel.FeatureFlag getFlag(String key) { @@ -210,8 +203,6 @@ public DataModel.Segment getSegment(String key) { this.dataStoreStatusProvider = new DataStoreStatusProviderImpl(this.dataStore, dataStoreUpdates); - DataSourceFactory dataSourceFactory = config.dataSourceFactory == null ? - Components.streamingDataSource() : config.dataSourceFactory; EventBroadcasterImpl dataSourceStatusNotifier = EventBroadcasterImpl.forDataSourceStatus(sharedExecutor); DataSourceUpdatesImpl dataSourceUpdates = new DataSourceUpdatesImpl( @@ -220,15 +211,15 @@ public DataModel.Segment getSegment(String key) { flagChangeBroadcaster, dataSourceStatusNotifier, sharedExecutor, - config.loggingConfig.getLogDataSourceOutageAsErrorAfter() + context.getLogging().getLogDataSourceOutageAsErrorAfter() ); this.dataSourceUpdates = dataSourceUpdates; - this.dataSource = dataSourceFactory.createDataSource(context, dataSourceUpdates); + this.dataSource = config.dataSourceFactory.createDataSource(context, dataSourceUpdates); this.dataSourceStatusProvider = new DataSourceStatusProviderImpl(dataSourceStatusNotifier, dataSourceUpdates); Future startFuture = dataSource.start(); if (!config.startWait.isZero() && !config.startWait.isNegative()) { - if (!(dataSource instanceof Components.NullDataSource)) { + if (!(dataSource instanceof ComponentsImpl.NullDataSource)) { logger.info("Waiting up to " + config.startWait.toMillis() + " milliseconds for LaunchDarkly client to start..."); } try { diff --git a/src/main/java/com/launchdarkly/sdk/server/LDConfig.java b/src/main/java/com/launchdarkly/sdk/server/LDConfig.java index 665174d1a..88ae9b158 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDConfig.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDConfig.java @@ -4,9 +4,7 @@ import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; import com.launchdarkly.sdk.server.interfaces.EventProcessor; import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; -import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; import com.launchdarkly.sdk.server.interfaces.HttpConfigurationFactory; -import com.launchdarkly.sdk.server.interfaces.LoggingConfiguration; import com.launchdarkly.sdk.server.interfaces.LoggingConfigurationFactory; import java.net.URI; @@ -28,22 +26,24 @@ public final class LDConfig { final DataStoreFactory dataStoreFactory; final boolean diagnosticOptOut; final EventProcessorFactory eventProcessorFactory; - final HttpConfiguration httpConfig; - final LoggingConfiguration loggingConfig; + final HttpConfigurationFactory httpConfigFactory; + final LoggingConfigurationFactory loggingConfigFactory; final boolean offline; final Duration startWait; final int threadPriority; protected LDConfig(Builder builder) { - this.dataStoreFactory = builder.dataStoreFactory; - this.eventProcessorFactory = builder.eventProcessorFactory; - this.dataSourceFactory = builder.dataSourceFactory; + this.dataStoreFactory = builder.dataStoreFactory == null ? Components.inMemoryDataStore() : + builder.dataStoreFactory; + this.eventProcessorFactory = builder.eventProcessorFactory == null ? Components.sendEvents() : + builder.eventProcessorFactory; + this.dataSourceFactory = builder.dataSourceFactory == null ? Components.streamingDataSource() : + builder.dataSourceFactory; this.diagnosticOptOut = builder.diagnosticOptOut; - this.httpConfig = builder.httpConfigFactory == null ? - Components.httpConfiguration().createHttpConfiguration() : - builder.httpConfigFactory.createHttpConfiguration(); - this.loggingConfig = (builder.loggingConfigFactory == null ? Components.logging() : builder.loggingConfigFactory). - createLoggingConfiguration(); + this.httpConfigFactory = builder.httpConfigFactory == null ? Components.httpConfiguration() : + builder.httpConfigFactory; + this.loggingConfigFactory = builder.loggingConfigFactory == null ? Components.logging() : + builder.loggingConfigFactory; this.offline = builder.offline; this.startWait = builder.startWait; this.threadPriority = builder.threadPriority; diff --git a/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java index ea9d80a12..1b4ee2fe0 100644 --- a/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java @@ -121,7 +121,6 @@ static interface EventSourceCreator { } StreamProcessor( - String sdkKey, HttpConfiguration httpConfig, FeatureRequestor requestor, DataSourceUpdates dataSourceUpdates, @@ -140,7 +139,7 @@ static interface EventSourceCreator { this.streamUri = streamUri; this.initialReconnectDelay = initialReconnectDelay; - this.headers = getHeadersBuilderFor(sdkKey, httpConfig) + this.headers = getHeadersBuilderFor(httpConfig) .add("Accept", "text/event-stream") .build(); diff --git a/src/main/java/com/launchdarkly/sdk/server/Util.java b/src/main/java/com/launchdarkly/sdk/server/Util.java index c462768b0..79971630c 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Util.java +++ b/src/main/java/com/launchdarkly/sdk/server/Util.java @@ -7,6 +7,7 @@ import java.io.IOException; import java.time.Duration; +import java.util.Map; import java.util.concurrent.TimeUnit; import static com.google.common.collect.Iterables.transform; @@ -22,15 +23,11 @@ abstract class Util { private Util() {} - static Headers.Builder getHeadersBuilderFor(String sdkKey, HttpConfiguration config) { - Headers.Builder builder = new Headers.Builder() - .add("Authorization", sdkKey) - .add("User-Agent", "JavaClient/" + Version.SDK_VERSION); - - if (config.getWrapperIdentifier() != null) { - builder.add("X-LaunchDarkly-Wrapper", config.getWrapperIdentifier()); + static Headers.Builder getHeadersBuilderFor(HttpConfiguration config) { + Headers.Builder builder = new Headers.Builder(); + for (Map.Entry kv: config.getDefaultHeaders()) { + builder.add(kv.getKey(), kv.getValue()); } - return builder; } diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/BasicConfiguration.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/BasicConfiguration.java new file mode 100644 index 000000000..926cf3f13 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/BasicConfiguration.java @@ -0,0 +1,54 @@ +package com.launchdarkly.sdk.server.interfaces; + +/** + * The most basic properties of the SDK client that are available to all SDK component factories. + * + * @since 5.0.0 + */ +public final class BasicConfiguration { + private final String sdkKey; + private final boolean offline; + private final int threadPriority; + + /** + * Constructs an instance. + * + * @param sdkKey the SDK key + * @param offline true if the SDK was configured to be completely offline + * @param threadPriority the thread priority that should be used for any worker threads created by SDK components + */ + public BasicConfiguration(String sdkKey, boolean offline, int threadPriority) { + this.sdkKey = sdkKey; + this.offline = offline; + this.threadPriority = threadPriority; + } + + /** + * Returns the configured SDK key. + * + * @return the SDK key + */ + public String getSdkKey() { + return sdkKey; + } + + /** + * Returns true if the client was configured to be completely offline. + * + * @return true if offline + * @see com.launchdarkly.sdk.server.LDConfig.Builder#offline(boolean) + */ + public boolean isOffline() { + return offline; + } + + /** + * The thread priority that should be used for any worker threads created by SDK components. + * + * @return the thread priority + * @see com.launchdarkly.sdk.server.LDConfig.Builder#threadPriority(int) + */ + public int getThreadPriority() { + return threadPriority; + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/ClientContext.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/ClientContext.java index 5320a9eb5..558777047 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/ClientContext.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/ClientContext.java @@ -16,38 +16,23 @@ */ public interface ClientContext { /** - * The current {@link com.launchdarkly.sdk.server.LDClient} instance's SDK key. + * The SDK's basic global properties. * - * @return the SDK key + * @return the basic configuration */ - public String getSdkKey(); - - /** - * True if the SDK was configured to be completely offline. - * - * @return the offline status - * @see com.launchdarkly.sdk.server.LDConfig.Builder#offline(boolean) - */ - public boolean isOffline(); + public BasicConfiguration getBasic(); /** * The configured networking properties that apply to all components. * * @return the HTTP configuration */ - public HttpConfiguration getHttpConfiguration(); + public HttpConfiguration getHttp(); /** * The configured logging properties that apply to all components. * @return the logging configuration */ - public LoggingConfiguration getLoggingConfiguration(); + public LoggingConfiguration getLogging(); - /** - * The thread priority that should be used for any worker threads created by SDK components. - * - * @return the thread priority - * @see com.launchdarkly.sdk.server.LDConfig.Builder#threadPriority(int) - */ - public int getThreadPriority(); } diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/EventSenderFactory.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/EventSenderFactory.java index 7320c4593..3625b0d0b 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/EventSenderFactory.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/EventSenderFactory.java @@ -10,9 +10,9 @@ public interface EventSenderFactory { /** * Called by the SDK to create the implementation object. * - * @param sdkKey the configured SDK key + * @param basicConfiguration the basic global SDK configuration properties * @param httpConfiguration HTTP configuration properties * @return an {@link EventSender} */ - EventSender createEventSender(String sdkKey, HttpConfiguration httpConfiguration); + EventSender createEventSender(BasicConfiguration basicConfiguration, HttpConfiguration httpConfiguration); } diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/HttpConfiguration.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/HttpConfiguration.java index abda924f5..1d47867a8 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/HttpConfiguration.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/HttpConfiguration.java @@ -4,6 +4,7 @@ import java.net.Proxy; import java.time.Duration; +import java.util.Map; import javax.net.ssl.SSLSocketFactory; import javax.net.ssl.X509TrustManager; @@ -12,6 +13,11 @@ * Encapsulates top-level HTTP configuration that applies to all SDK components. *

    * Use {@link HttpConfigurationBuilder} to construct an instance. + *

    + * The SDK's built-in components use OkHttp as the HTTP client implementation, but since OkHttp types + * are not surfaced in the public API and custom components might use some other implementation, this + * class only provides the properties that would be used to create an HTTP client; it does not create + * the client itself. SDK implementation code uses its own helper methods to do so. * * @since 4.13.0 */ @@ -63,12 +69,10 @@ public interface HttpConfiguration { 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. + * Returns the basic headers that should be added to all HTTP requests from SDK components to + * LaunchDarkly services, based on the current SDK configuration. * - * @return a wrapper identifier string or null + * @return a list of HTTP header names and values */ - String getWrapperIdentifier(); + Iterable> getDefaultHeaders(); } diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/HttpConfigurationFactory.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/HttpConfigurationFactory.java index 58e912a61..9a5082af8 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/HttpConfigurationFactory.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/HttpConfigurationFactory.java @@ -10,7 +10,9 @@ public interface HttpConfigurationFactory { /** * Creates the configuration object. + * + * @param basicConfiguration provides the basic SDK configuration properties * @return an {@link HttpConfiguration} */ - public HttpConfiguration createHttpConfiguration(); + public HttpConfiguration createHttpConfiguration(BasicConfiguration basicConfiguration); } diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/LoggingConfigurationFactory.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/LoggingConfigurationFactory.java index e2bd3e635..25677e019 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/LoggingConfigurationFactory.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/LoggingConfigurationFactory.java @@ -10,7 +10,9 @@ public interface LoggingConfigurationFactory { /** * Creates the configuration object. + * + * @param basicConfiguration provides the basic SDK configuration properties * @return a {@link LoggingConfiguration} */ - public LoggingConfiguration createLoggingConfiguration(); + public LoggingConfiguration createLoggingConfiguration(BasicConfiguration basicConfiguration); } diff --git a/src/test/java/com/launchdarkly/sdk/server/ClientContextImplTest.java b/src/test/java/com/launchdarkly/sdk/server/ClientContextImplTest.java index ea91abc33..1268e5378 100644 --- a/src/test/java/com/launchdarkly/sdk/server/ClientContextImplTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/ClientContextImplTest.java @@ -2,6 +2,7 @@ import com.launchdarkly.sdk.server.integrations.HttpConfigurationBuilder; import com.launchdarkly.sdk.server.integrations.LoggingConfigurationBuilder; +import com.launchdarkly.sdk.server.interfaces.BasicConfiguration; import com.launchdarkly.sdk.server.interfaces.ClientContext; import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; import com.launchdarkly.sdk.server.interfaces.LoggingConfiguration; @@ -28,17 +29,17 @@ public void getBasicDefaultProperties() { ClientContext c = new ClientContextImpl(SDK_KEY, config, null, null); - assertEquals(SDK_KEY, c.getSdkKey()); - assertFalse(c.isOffline()); + BasicConfiguration basicConfig = c.getBasic(); + assertEquals(SDK_KEY, basicConfig.getSdkKey()); + assertFalse(basicConfig.isOffline()); + assertEquals(Thread.MIN_PRIORITY, basicConfig.getThreadPriority()); - HttpConfiguration httpConfig = c.getHttpConfiguration(); + HttpConfiguration httpConfig = c.getHttp(); assertEquals(HttpConfigurationBuilder.DEFAULT_CONNECT_TIMEOUT, httpConfig.getConnectTimeout()); - LoggingConfiguration loggingConfig = c.getLoggingConfiguration(); + LoggingConfiguration loggingConfig = c.getLogging(); assertEquals(LoggingConfigurationBuilder.DEFAULT_LOG_DATA_SOURCE_OUTAGE_AS_ERROR_AFTER, loggingConfig.getLogDataSourceOutageAsErrorAfter()); - - assertEquals(Thread.MIN_PRIORITY, c.getThreadPriority()); } @Test @@ -52,16 +53,16 @@ public void getBasicPropertiesWithCustomConfig() { ClientContext c = new ClientContextImpl(SDK_KEY, config, sharedExecutor, null); - assertEquals(SDK_KEY, c.getSdkKey()); - assertTrue(c.isOffline()); - - HttpConfiguration httpConfig = c.getHttpConfiguration(); + BasicConfiguration basicConfig = c.getBasic(); + assertEquals(SDK_KEY, basicConfig.getSdkKey()); + assertTrue(basicConfig.isOffline()); + assertEquals(Thread.MAX_PRIORITY, basicConfig.getThreadPriority()); + + HttpConfiguration httpConfig = c.getHttp(); assertEquals(Duration.ofSeconds(10), httpConfig.getConnectTimeout()); - LoggingConfiguration loggingConfig = c.getLoggingConfiguration(); + LoggingConfiguration loggingConfig = c.getLogging(); assertEquals(Duration.ofMinutes(20), loggingConfig.getLogDataSourceOutageAsErrorAfter()); - - assertEquals(Thread.MAX_PRIORITY, c.getThreadPriority()); } @Test @@ -131,24 +132,16 @@ public void packagePrivatePropertiesHaveDefaultsIfContextIsNotOurImplementation( } private static final class SomeOtherContextImpl implements ClientContext { - public String getSdkKey() { - return null; + public BasicConfiguration getBasic() { + return new BasicConfiguration(SDK_KEY, false, Thread.MIN_PRIORITY); } - public boolean isOffline() { - return false; - } - - public HttpConfiguration getHttpConfiguration() { + public HttpConfiguration getHttp() { return null; } - - public LoggingConfiguration getLoggingConfiguration() { + + public LoggingConfiguration getLogging() { return null; } - - public int getThreadPriority() { - return 0; - } } } diff --git a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorDiagnosticsTest.java b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorDiagnosticsTest.java index 358562d97..8684e2d02 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorDiagnosticsTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorDiagnosticsTest.java @@ -11,6 +11,7 @@ import java.time.Duration; import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.TestComponents.clientContext; import static com.launchdarkly.sdk.server.TestComponents.sharedExecutor; import static com.launchdarkly.sdk.server.TestUtil.simpleEvaluation; import static org.hamcrest.MatcherAssert.assertThat; @@ -125,7 +126,8 @@ public void periodicDiagnosticEventsAreSentAutomatically() throws Exception { // at least one periodic event without having to force a send via ep.postDiagnostic(). MockEventSender es = new MockEventSender(); DiagnosticId diagnosticId = new DiagnosticId(SDK_KEY); - DiagnosticEvent.Init initEvent = new DiagnosticEvent.Init(0, diagnosticId, LDConfig.DEFAULT); + DiagnosticEvent.Init initEvent = new DiagnosticEvent.Init(0, diagnosticId, LDConfig.DEFAULT, + clientContext(SDK_KEY, LDConfig.DEFAULT).getHttp()); DiagnosticAccumulator diagnosticAccumulator = new DiagnosticAccumulator(diagnosticId); Duration briefPeriodicInterval = Duration.ofMillis(50); diff --git a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTestBase.java b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTestBase.java index 904edf66a..649b9472b 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTestBase.java +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTestBase.java @@ -5,6 +5,7 @@ import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.server.integrations.EventProcessorBuilder; +import com.launchdarkly.sdk.server.interfaces.BasicConfiguration; import com.launchdarkly.sdk.server.interfaces.Event; import com.launchdarkly.sdk.server.interfaces.EventSender; import com.launchdarkly.sdk.server.interfaces.EventSenderFactory; @@ -64,7 +65,7 @@ public static DefaultEventProcessor makeEventProcessor(EventProcessorBuilder ec, public static EventSenderFactory senderFactory(final MockEventSender es) { return new EventSenderFactory() { @Override - public EventSender createEventSender(String sdkKey, HttpConfiguration httpConfiguration) { + public EventSender createEventSender(BasicConfiguration basicConfiguration, HttpConfiguration httpConfiguration) { return es; } }; diff --git a/src/test/java/com/launchdarkly/sdk/server/DefaultEventSenderTest.java b/src/test/java/com/launchdarkly/sdk/server/DefaultEventSenderTest.java index d5fda59fe..1e8d3401c 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DefaultEventSenderTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultEventSenderTest.java @@ -1,5 +1,6 @@ package com.launchdarkly.sdk.server; +import com.launchdarkly.sdk.server.interfaces.ClientContext; import com.launchdarkly.sdk.server.interfaces.EventSender; import com.launchdarkly.sdk.server.interfaces.EventSenderFactory; import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; @@ -10,9 +11,11 @@ import java.text.SimpleDateFormat; import java.time.Duration; import java.util.Date; +import java.util.Map; import java.util.UUID; import java.util.concurrent.TimeUnit; +import static com.launchdarkly.sdk.server.TestComponents.clientContext; import static com.launchdarkly.sdk.server.TestHttpUtil.httpsServerWithSelfSignedCert; import static com.launchdarkly.sdk.server.TestHttpUtil.makeStartedServer; import static com.launchdarkly.sdk.server.interfaces.EventSender.EventDataKind.ANALYTICS; @@ -41,13 +44,12 @@ public class DefaultEventSenderTest { private static final Duration BRIEF_RETRY_DELAY = Duration.ofMillis(50); private static EventSender makeEventSender() { - return makeEventSender(Components.httpConfiguration().createHttpConfiguration()); + return makeEventSender(LDConfig.DEFAULT); } - private static EventSender makeEventSender(HttpConfiguration httpConfiguration) { + private static EventSender makeEventSender(LDConfig config) { return new DefaultEventSender( - SDK_KEY, - httpConfiguration, + clientContext(SDK_KEY, config).getHttp(), BRIEF_RETRY_DELAY ); } @@ -59,7 +61,8 @@ private static URI getBaseUri(MockWebServer server) { @Test public void factoryCreatesDefaultSenderWithDefaultRetryDelay() throws Exception { EventSenderFactory f = new DefaultEventSender.Factory(); - try (EventSender es = f.createEventSender(SDK_KEY, Components.httpConfiguration().createHttpConfiguration())) { + ClientContext context = clientContext(SDK_KEY, LDConfig.DEFAULT); + try (EventSender es = f.createEventSender(context.getBasic(), context.getHttp())) { assertThat(es, isA(EventSender.class)); assertThat(((DefaultEventSender)es).retryDelay, equalTo(DefaultEventSender.DEFAULT_RETRY_DELAY)); } @@ -67,10 +70,8 @@ public void factoryCreatesDefaultSenderWithDefaultRetryDelay() throws Exception @Test public void constructorUsesDefaultRetryDelayIfNotSpecified() throws Exception { - try (EventSender es = new DefaultEventSender( - SDK_KEY, - Components.httpConfiguration().createHttpConfiguration(), - null)) { + ClientContext context = clientContext(SDK_KEY, LDConfig.DEFAULT); + try (EventSender es = new DefaultEventSender(context.getHttp(), null)) { assertThat(((DefaultEventSender)es).retryDelay, equalTo(DefaultEventSender.DEFAULT_RETRY_DELAY)); } } @@ -110,26 +111,34 @@ public void diagnosticDataIsDelivered() throws Exception { } @Test - public void sdkKeyIsSentForAnalytics() throws Exception { + public void defaultHeadersAreSentForAnalytics() throws Exception { + HttpConfiguration httpConfig = clientContext(SDK_KEY, LDConfig.DEFAULT).getHttp(); + 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)); + RecordedRequest req = server.takeRequest(); + for (Map.Entry kv: httpConfig.getDefaultHeaders()) { + assertThat(req.getHeader(kv.getKey()), equalTo(kv.getValue())); + } } } @Test - public void sdkKeyIsSentForDiagnostics() throws Exception { + public void defaultHeadersAreSentForDiagnostics() throws Exception { + HttpConfiguration httpConfig = clientContext(SDK_KEY, LDConfig.DEFAULT).getHttp(); + 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)); + for (Map.Entry kv: httpConfig.getDefaultHeaders()) { + assertThat(req.getHeader(kv.getKey()), equalTo(kv.getValue())); + } } } @@ -195,22 +204,6 @@ public void eventSchemaNotSetOnDiagnosticEvents() throws Exception { } } - @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); @@ -289,12 +282,14 @@ public void httpClientDoesNotAllowSelfSignedCertByDefault() throws Exception { @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(); + LDConfig config = new LDConfig.Builder() + .http(Components.httpConfiguration() + .sslSocketFactory(serverWithCert.socketFactory, serverWithCert.trustManager) + // allows us to trust the self-signed cert + ) + .build(); - try (EventSender es = makeEventSender(httpConfig)) { + try (EventSender es = makeEventSender(config)) { EventSender.Result result = es.sendEventData(ANALYTICS, FAKE_DATA, 1, serverWithCert.uri()); assertTrue(result.isSuccess()); diff --git a/src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java b/src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java index ae5717f6d..ecf87e025 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java @@ -10,6 +10,7 @@ import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; import com.launchdarkly.sdk.server.interfaces.DiagnosticDescription; +import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; import org.junit.Test; @@ -19,6 +20,7 @@ import java.util.List; import java.util.UUID; +import static com.launchdarkly.sdk.server.TestComponents.clientContext; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; @@ -82,10 +84,15 @@ private ObjectBuilder expectedDefaultPropertiesWithoutStreaming() { .put("usingRelayDaemon", false); } + private static HttpConfiguration makeHttpConfig(LDConfig config) { + // the SDK key doesn't matter for these tests + return clientContext("SDK_KEY", config).getHttp(); + } + @Test public void testDefaultDiagnosticConfiguration() { LDConfig ldConfig = new LDConfig.Builder().build(); - LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig); + LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig, makeHttpConfig(ldConfig)); LDValue expected = expectedDefaultProperties().build(); assertEquals(expected, diagnosticJson); @@ -97,7 +104,7 @@ public void testCustomDiagnosticConfigurationGeneralProperties() { .startWait(Duration.ofSeconds(10)) .build(); - LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig); + LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig, makeHttpConfig(ldConfig)); LDValue expected = expectedDefaultProperties() .put("startWaitMillis", 10_000) .build(); @@ -116,7 +123,7 @@ public void testCustomDiagnosticConfigurationForStreaming() { ) .build(); - LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig); + LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig, makeHttpConfig(ldConfig)); LDValue expected = expectedDefaultPropertiesWithoutStreaming() .put("customBaseURI", true) .put("customStreamURI", true) @@ -136,7 +143,7 @@ public void testCustomDiagnosticConfigurationForPolling() { ) .build(); - LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig); + LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig, makeHttpConfig(ldConfig)); LDValue expected = expectedDefaultPropertiesWithoutStreaming() .put("customBaseURI", true) .put("pollingIntervalMillis", 60_000) @@ -162,7 +169,7 @@ public void testCustomDiagnosticConfigurationForEvents() { ) .build(); - LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig); + LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig, makeHttpConfig(ldConfig)); LDValue expected = expectedDefaultProperties() .put("allAttributesPrivate", true) .put("customEventsURI", true) @@ -184,7 +191,7 @@ public void testCustomDiagnosticConfigurationForDaemonMode() { .dataStore(new DataStoreFactoryWithComponentName()) .build(); - LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig); + LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig, makeHttpConfig(ldConfig)); LDValue expected = expectedDefaultPropertiesWithoutStreaming() .put("dataStoreType", "my-test-store") .put("usingRelayDaemon", true) @@ -196,7 +203,7 @@ public void testCustomDiagnosticConfigurationForDaemonMode() { @Test public void testCustomDiagnosticConfigurationForOffline() { LDConfig ldConfig = new LDConfig.Builder().offline(true).build(); - LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig); + LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig, makeHttpConfig(ldConfig)); LDValue expected = expectedDefaultPropertiesWithoutStreaming() .put("offline", true) .build(); @@ -206,7 +213,7 @@ public void testCustomDiagnosticConfigurationForOffline() { LDConfig ldConfig2 = new LDConfig.Builder().offline(true) .dataStore(Components.inMemoryDataStore()) // just double-checking the logic in NullDataSourceFactory.describeConfiguration() .build(); - LDValue diagnosticJson2 = DiagnosticEvent.Init.getConfigurationData(ldConfig2); + LDValue diagnosticJson2 = DiagnosticEvent.Init.getConfigurationData(ldConfig2, makeHttpConfig(ldConfig2)); assertEquals(expected, diagnosticJson2); @@ -215,14 +222,14 @@ public void testCustomDiagnosticConfigurationForOffline() { LDConfig ldConfig3 = new LDConfig.Builder().offline(true) .dataSource(Components.streamingDataSource()) .build(); - LDValue diagnosticJson3 = DiagnosticEvent.Init.getConfigurationData(ldConfig3); + LDValue diagnosticJson3 = DiagnosticEvent.Init.getConfigurationData(ldConfig3, makeHttpConfig(ldConfig3)); assertEquals(expected, diagnosticJson3); LDConfig ldConfig4 = new LDConfig.Builder().offline(true) .dataSource(Components.pollingDataSource()) .build(); - LDValue diagnosticJson4 = DiagnosticEvent.Init.getConfigurationData(ldConfig4); + LDValue diagnosticJson4 = DiagnosticEvent.Init.getConfigurationData(ldConfig4, makeHttpConfig(ldConfig4)); assertEquals(expected, diagnosticJson4); } @@ -239,7 +246,7 @@ public void testCustomDiagnosticConfigurationHttpProperties() { ) .build(); - LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig); + LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig, makeHttpConfig(ldConfig)); LDValue expected = expectedDefaultProperties() .put("connectTimeoutMillis", 5_000) .put("socketTimeoutMillis", 20_000) diff --git a/src/test/java/com/launchdarkly/sdk/server/DiagnosticSdkTest.java b/src/test/java/com/launchdarkly/sdk/server/DiagnosticSdkTest.java index 35525ff27..de3d6e0be 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DiagnosticSdkTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DiagnosticSdkTest.java @@ -3,9 +3,11 @@ import com.google.gson.Gson; import com.google.gson.JsonObject; import com.launchdarkly.sdk.server.DiagnosticEvent.Init.DiagnosticSdk; +import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; import org.junit.Test; +import static com.launchdarkly.sdk.server.TestComponents.clientContext; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; @@ -13,9 +15,14 @@ public class DiagnosticSdkTest { private static final Gson gson = new Gson(); + private static HttpConfiguration makeHttpConfig(LDConfig config) { + // the SDK key doesn't matter for these tests + return clientContext("SDK_KEY", config).getHttp(); + } + @Test public void defaultFieldValues() { - DiagnosticSdk diagnosticSdk = new DiagnosticSdk(new LDConfig.Builder().build()); + DiagnosticSdk diagnosticSdk = new DiagnosticSdk(makeHttpConfig(LDConfig.DEFAULT)); assertEquals("java-server-sdk", diagnosticSdk.name); assertEquals(Version.SDK_VERSION, diagnosticSdk.version); assertNull(diagnosticSdk.wrapperName); @@ -27,7 +34,7 @@ public void getsWrapperValuesFromConfig() { LDConfig config = new LDConfig.Builder() .http(Components.httpConfiguration().wrapper("Scala", "0.1.0")) .build(); - DiagnosticSdk diagnosticSdk = new DiagnosticSdk(config); + DiagnosticSdk diagnosticSdk = new DiagnosticSdk(makeHttpConfig(config)); assertEquals("java-server-sdk", diagnosticSdk.name); assertEquals(Version.SDK_VERSION, diagnosticSdk.version); assertEquals(diagnosticSdk.wrapperName, "Scala"); @@ -36,7 +43,7 @@ public void getsWrapperValuesFromConfig() { @Test public void gsonSerializationNoWrapper() { - DiagnosticSdk diagnosticSdk = new DiagnosticSdk(new LDConfig.Builder().build()); + DiagnosticSdk diagnosticSdk = new DiagnosticSdk(makeHttpConfig(LDConfig.DEFAULT)); JsonObject jsonObject = gson.toJsonTree(diagnosticSdk).getAsJsonObject(); assertEquals(2, jsonObject.size()); assertEquals("java-server-sdk", jsonObject.getAsJsonPrimitive("name").getAsString()); @@ -48,7 +55,7 @@ public void gsonSerializationWithWrapper() { LDConfig config = new LDConfig.Builder() .http(Components.httpConfiguration().wrapper("Scala", "0.1.0")) .build(); - DiagnosticSdk diagnosticSdk = new DiagnosticSdk(config); + DiagnosticSdk diagnosticSdk = new DiagnosticSdk(makeHttpConfig(config)); JsonObject jsonObject = gson.toJsonTree(diagnosticSdk).getAsJsonObject(); assertEquals(4, jsonObject.size()); assertEquals("java-server-sdk", jsonObject.getAsJsonPrimitive("name").getAsString()); diff --git a/src/test/java/com/launchdarkly/sdk/server/FeatureRequestorTest.java b/src/test/java/com/launchdarkly/sdk/server/FeatureRequestorTest.java index cce5e171d..a45745a3b 100644 --- a/src/test/java/com/launchdarkly/sdk/server/FeatureRequestorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/FeatureRequestorTest.java @@ -1,5 +1,8 @@ package com.launchdarkly.sdk.server; +import com.launchdarkly.sdk.server.interfaces.BasicConfiguration; +import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; + import org.junit.Assert; import org.junit.Test; @@ -40,7 +43,11 @@ private DefaultFeatureRequestor makeRequestor(MockWebServer server) { private DefaultFeatureRequestor makeRequestor(MockWebServer server, LDConfig config) { URI uri = server.url("").uri(); - return new DefaultFeatureRequestor(sdkKey, config.httpConfig, uri, true); + return new DefaultFeatureRequestor(makeHttpConfig(config), uri, true); + } + + private HttpConfiguration makeHttpConfig(LDConfig config) { + return config.httpConfigFactory.createHttpConfiguration(new BasicConfiguration(sdkKey, false, 0)); } @Test @@ -198,7 +205,7 @@ public void httpClientCanUseProxyConfig() throws Exception { .http(Components.httpConfiguration().proxyHostAndPort(serverUrl.host(), serverUrl.port())) .build(); - try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(sdkKey, config.httpConfig, fakeBaseUri, true)) { + try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(makeHttpConfig(config), fakeBaseUri, true)) { DataModel.FeatureFlag flag = r.getFlag(flag1Key); verifyFlag(flag, flag1Key); diff --git a/src/test/java/com/launchdarkly/sdk/server/JsonHelpersTest.java b/src/test/java/com/launchdarkly/sdk/server/JsonHelpersTest.java index 9bbeade54..28f767554 100644 --- a/src/test/java/com/launchdarkly/sdk/server/JsonHelpersTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/JsonHelpersTest.java @@ -2,13 +2,12 @@ import com.google.gson.JsonElement; import com.google.gson.annotations.JsonAdapter; -import com.launchdarkly.sdk.server.interfaces.SerializationException; import com.launchdarkly.sdk.server.DataModel.FeatureFlag; import com.launchdarkly.sdk.server.DataModel.Segment; import com.launchdarkly.sdk.server.DataModel.VersionedData; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.SerializationException; -import org.junit.Assert; import org.junit.Test; import static org.junit.Assert.assertEquals; diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientExternalUpdatesOnlyTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientExternalUpdatesOnlyTest.java index 5aaf34980..3e086d798 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientExternalUpdatesOnlyTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientExternalUpdatesOnlyTest.java @@ -24,7 +24,7 @@ public void externalUpdatesOnlyClientHasNullDataSource() throws Exception { .dataSource(Components.externalUpdatesOnly()) .build(); try (LDClient client = new LDClient("SDK_KEY", config)) { - assertEquals(Components.NullDataSource.class, client.dataSource.getClass()); + assertEquals(ComponentsImpl.NullDataSource.class, client.dataSource.getClass()); } } diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientOfflineTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientOfflineTest.java index 7e0eed761..80a7212d5 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientOfflineTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientOfflineTest.java @@ -27,7 +27,7 @@ public void offlineClientHasNullDataSource() throws IOException { .offline(true) .build(); try (LDClient client = new LDClient("SDK_KEY", config)) { - assertEquals(Components.NullDataSource.class, client.dataSource.getClass()); + assertEquals(ComponentsImpl.NullDataSource.class, client.dataSource.getClass()); } } @@ -37,7 +37,7 @@ public void offlineClientHasNullEventProcessor() throws IOException { .offline(true) .build(); try (LDClient client = new LDClient("SDK_KEY", config)) { - assertEquals(Components.NullEventProcessor.class, client.eventProcessor.getClass()); + assertEquals(ComponentsImpl.NullEventProcessor.class, client.eventProcessor.getClass()); } } diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java index 80c19dd6e..ee7e5a923 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java @@ -120,7 +120,7 @@ public void clientHasNullEventProcessorWithNoEvents() throws Exception { .events(Components.noEvents()) .build(); try (LDClient client = new LDClient("SDK_KEY", config)) { - assertEquals(Components.NullEventProcessor.class, client.eventProcessor.getClass()); + assertEquals(ComponentsImpl.NullEventProcessor.class, client.eventProcessor.getClass()); } } diff --git a/src/test/java/com/launchdarkly/sdk/server/LDConfigTest.java b/src/test/java/com/launchdarkly/sdk/server/LDConfigTest.java index b04dea852..61c96ce9c 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDConfigTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDConfigTest.java @@ -1,11 +1,14 @@ package com.launchdarkly.sdk.server; +import com.google.common.collect.ImmutableMap; import com.launchdarkly.sdk.server.integrations.HttpConfigurationBuilder; import com.launchdarkly.sdk.server.integrations.LoggingConfigurationBuilder; +import com.launchdarkly.sdk.server.interfaces.BasicConfiguration; import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; +import com.launchdarkly.sdk.server.interfaces.LoggingConfiguration; import org.junit.Test; @@ -20,21 +23,28 @@ @SuppressWarnings("javadoc") public class LDConfigTest { + private static final BasicConfiguration BASIC_CONFIG = new BasicConfiguration("", false, 0); + @Test public void defaults() { LDConfig config = new LDConfig.Builder().build(); - assertNull(config.dataSourceFactory); - assertNull(config.dataStoreFactory); + assertNotNull(config.dataSourceFactory); + assertEquals(Components.streamingDataSource().getClass(), config.dataSourceFactory.getClass()); + assertNotNull(config.dataStoreFactory); + assertEquals(Components.inMemoryDataStore().getClass(), config.dataStoreFactory.getClass()); assertFalse(config.diagnosticOptOut); - assertNull(config.eventProcessorFactory); + assertNotNull(config.eventProcessorFactory); + assertEquals(Components.sendEvents().getClass(), config.eventProcessorFactory.getClass()); assertFalse(config.offline); - assertNotNull(config.httpConfig); - assertEquals(HttpConfigurationBuilder.DEFAULT_CONNECT_TIMEOUT, config.httpConfig.getConnectTimeout()); + assertNotNull(config.httpConfigFactory); + HttpConfiguration httpConfig = config.httpConfigFactory.createHttpConfiguration(BASIC_CONFIG); + assertEquals(HttpConfigurationBuilder.DEFAULT_CONNECT_TIMEOUT, httpConfig.getConnectTimeout()); - assertNotNull(config.loggingConfig); + assertNotNull(config.loggingConfigFactory); + LoggingConfiguration loggingConfig = config.loggingConfigFactory.createLoggingConfiguration(BASIC_CONFIG); assertEquals(LoggingConfigurationBuilder.DEFAULT_LOG_DATA_SOURCE_OUTAGE_AS_ERROR_AFTER, - config.loggingConfig.getLogDataSourceOutageAsErrorAfter()); + loggingConfig.getLogDataSourceOutageAsErrorAfter()); assertEquals(LDConfig.DEFAULT_START_WAIT, config.startWait); assertEquals(Thread.MIN_PRIORITY, config.threadPriority); @@ -83,14 +93,16 @@ public void offline() { public void http() { HttpConfigurationBuilder b = Components.httpConfiguration().connectTimeout(Duration.ofSeconds(9)); LDConfig config = new LDConfig.Builder().http(b).build(); - assertEquals(Duration.ofSeconds(9), config.httpConfig.getConnectTimeout()); + assertEquals(Duration.ofSeconds(9), + config.httpConfigFactory.createHttpConfiguration(BASIC_CONFIG).getConnectTimeout()); } @Test public void logging() { LoggingConfigurationBuilder b = Components.logging().logDataSourceOutageAsErrorAfter(Duration.ofSeconds(9)); LDConfig config = new LDConfig.Builder().logging(b).build(); - assertEquals(Duration.ofSeconds(9), config.loggingConfig.getLogDataSourceOutageAsErrorAfter()); + assertEquals(Duration.ofSeconds(9), + config.loggingConfigFactory.createLoggingConfiguration(BASIC_CONFIG).getLogDataSourceOutageAsErrorAfter()); } @Test @@ -107,46 +119,18 @@ public void threadPriority() { LDConfig config = new LDConfig.Builder().threadPriority(Thread.MAX_PRIORITY).build(); assertEquals(Thread.MAX_PRIORITY, config.threadPriority); } - - @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 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(); - HttpConfiguration hc = config.httpConfig; - HttpConfiguration defaults = Components.httpConfiguration().createHttpConfiguration(); + HttpConfiguration hc = config.httpConfigFactory.createHttpConfiguration(BASIC_CONFIG); + HttpConfiguration defaults = Components.httpConfiguration().createHttpConfiguration(BASIC_CONFIG); assertEquals(defaults.getConnectTimeout(), hc.getConnectTimeout()); assertNull(hc.getProxy()); assertNull(hc.getProxyAuthentication()); assertEquals(defaults.getSocketTimeout(), hc.getSocketTimeout()); assertNull(hc.getSslSocketFactory()); assertNull(hc.getTrustManager()); - assertNull(hc.getWrapperIdentifier()); + assertEquals(ImmutableMap.copyOf(defaults.getDefaultHeaders()), ImmutableMap.copyOf(hc.getDefaultHeaders())); } } \ No newline at end of file diff --git a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java index 2584e7d51..f028b55d4 100644 --- a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java @@ -879,8 +879,7 @@ private StreamProcessor createStreamProcessor(LDConfig config, URI streamUri) { private StreamProcessor createStreamProcessor(LDConfig config, URI streamUri, DiagnosticAccumulator diagnosticAccumulator) { return new StreamProcessor( - SDK_KEY, - config.httpConfig, + clientContext(SDK_KEY, config).getHttp(), mockRequestor, dataSourceUpdates, mockEventSourceCreator, @@ -893,8 +892,7 @@ private StreamProcessor createStreamProcessor(LDConfig config, URI streamUri, Di private StreamProcessor createStreamProcessorWithRealHttp(LDConfig config, URI streamUri) { return new StreamProcessor( - SDK_KEY, - config.httpConfig, + clientContext(SDK_KEY, config).getHttp(), mockRequestor, dataSourceUpdates, null, @@ -907,8 +905,7 @@ private StreamProcessor createStreamProcessorWithRealHttp(LDConfig config, URI s private StreamProcessor createStreamProcessorWithStoreUpdates(DataSourceUpdates storeUpdates) { return new StreamProcessor( - SDK_KEY, - LDConfig.DEFAULT.httpConfig, + clientContext(SDK_KEY, LDConfig.DEFAULT).getHttp(), mockRequestor, storeUpdates, mockEventSourceCreator, diff --git a/src/test/java/com/launchdarkly/sdk/server/TestUtil.java b/src/test/java/com/launchdarkly/sdk/server/TestUtil.java index c01a18712..2f1851b72 100644 --- a/src/test/java/com/launchdarkly/sdk/server/TestUtil.java +++ b/src/test/java/com/launchdarkly/sdk/server/TestUtil.java @@ -48,6 +48,10 @@ public class TestUtil { */ public static final Gson TEST_GSON_INSTANCE = new Gson(); + public static String getSdkVersion() { + return Version.SDK_VERSION; + } + // repeats until action returns non-null value, throws exception on timeout public static T repeatWithTimeout(Duration timeout, Duration interval, Supplier action) { Instant deadline = Instant.now().plus(timeout); diff --git a/src/test/java/com/launchdarkly/sdk/server/UtilTest.java b/src/test/java/com/launchdarkly/sdk/server/UtilTest.java index b2add8b04..6cf5b6fb3 100644 --- a/src/test/java/com/launchdarkly/sdk/server/UtilTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/UtilTest.java @@ -1,11 +1,13 @@ package com.launchdarkly.sdk.server; import com.launchdarkly.sdk.server.interfaces.HttpAuthentication; +import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; import org.junit.Test; import java.time.Duration; +import static com.launchdarkly.sdk.server.TestComponents.clientContext; import static com.launchdarkly.sdk.server.Util.configureHttpClientBuilder; import static com.launchdarkly.sdk.server.Util.shutdownHttpClient; import static org.junit.Assert.assertEquals; @@ -22,8 +24,9 @@ public class UtilTest { @Test public void testConnectTimeout() { LDConfig config = new LDConfig.Builder().http(Components.httpConfiguration().connectTimeout(Duration.ofSeconds(3))).build(); + HttpConfiguration httpConfig = clientContext("", config).getHttp(); OkHttpClient.Builder httpBuilder = new OkHttpClient.Builder(); - configureHttpClientBuilder(config.httpConfig, httpBuilder); + configureHttpClientBuilder(httpConfig, httpBuilder); OkHttpClient httpClient = httpBuilder.build(); try { assertEquals(3000, httpClient.connectTimeoutMillis()); @@ -35,8 +38,9 @@ public void testConnectTimeout() { @Test public void testSocketTimeout() { LDConfig config = new LDConfig.Builder().http(Components.httpConfiguration().socketTimeout(Duration.ofSeconds(3))).build(); + HttpConfiguration httpConfig = clientContext("", config).getHttp(); OkHttpClient.Builder httpBuilder = new OkHttpClient.Builder(); - configureHttpClientBuilder(config.httpConfig, httpBuilder); + configureHttpClientBuilder(httpConfig, httpBuilder); OkHttpClient httpClient = httpBuilder.build(); try { assertEquals(3000, httpClient.readTimeoutMillis()); diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/HttpConfigurationBuilderTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/HttpConfigurationBuilderTest.java index 1bef491f7..fd0fdd60c 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/HttpConfigurationBuilderTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/HttpConfigurationBuilderTest.java @@ -1,6 +1,8 @@ package com.launchdarkly.sdk.server.integrations; +import com.google.common.collect.ImmutableMap; import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.interfaces.BasicConfiguration; import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; import org.junit.Test; @@ -18,6 +20,7 @@ import javax.net.ssl.SSLSocketFactory; import javax.net.ssl.X509TrustManager; +import static com.launchdarkly.sdk.server.TestUtil.getSdkVersion; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; @@ -25,23 +28,32 @@ @SuppressWarnings("javadoc") public class HttpConfigurationBuilderTest { + private static final String SDK_KEY = "sdk-key"; + private static final BasicConfiguration BASIC_CONFIG = new BasicConfiguration(SDK_KEY, false, 0); + + private static ImmutableMap.Builder buildBasicHeaders() { + return ImmutableMap.builder() + .put("Authorization", SDK_KEY) + .put("User-Agent", "JavaClient/" + getSdkVersion()); + } + @Test public void testDefaults() { - HttpConfiguration hc = Components.httpConfiguration().createHttpConfiguration(); + HttpConfiguration hc = Components.httpConfiguration().createHttpConfiguration(BASIC_CONFIG); assertEquals(HttpConfigurationBuilder.DEFAULT_CONNECT_TIMEOUT, hc.getConnectTimeout()); assertNull(hc.getProxy()); assertNull(hc.getProxyAuthentication()); assertEquals(HttpConfigurationBuilder.DEFAULT_SOCKET_TIMEOUT, hc.getSocketTimeout()); assertNull(hc.getSslSocketFactory()); assertNull(hc.getTrustManager()); - assertNull(hc.getWrapperIdentifier()); + assertEquals(buildBasicHeaders().build(), ImmutableMap.copyOf(hc.getDefaultHeaders())); } @Test public void testConnectTimeout() { HttpConfiguration hc = Components.httpConfiguration() .connectTimeout(Duration.ofMillis(999)) - .createHttpConfiguration(); + .createHttpConfiguration(BASIC_CONFIG); assertEquals(999, hc.getConnectTimeout().toMillis()); } @@ -49,7 +61,7 @@ public void testConnectTimeout() { public void testProxy() { HttpConfiguration hc = Components.httpConfiguration() .proxyHostAndPort("my-proxy", 1234) - .createHttpConfiguration(); + .createHttpConfiguration(BASIC_CONFIG); assertEquals(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("my-proxy", 1234)), hc.getProxy()); assertNull(hc.getProxyAuthentication()); } @@ -59,7 +71,7 @@ public void testProxyBasicAuth() { HttpConfiguration hc = Components.httpConfiguration() .proxyHostAndPort("my-proxy", 1234) .proxyAuth(Components.httpBasicAuthentication("user", "pass")) - .createHttpConfiguration(); + .createHttpConfiguration(BASIC_CONFIG); assertEquals(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("my-proxy", 1234)), hc.getProxy()); assertNotNull(hc.getProxyAuthentication()); assertEquals("Basic dXNlcjpwYXNz", hc.getProxyAuthentication().provideAuthorization(null)); @@ -69,7 +81,7 @@ public void testProxyBasicAuth() { public void testSocketTimeout() { HttpConfiguration hc = Components.httpConfiguration() .socketTimeout(Duration.ofMillis(999)) - .createHttpConfiguration(); + .createHttpConfiguration(BASIC_CONFIG); assertEquals(999, hc.getSocketTimeout().toMillis()); } @@ -77,7 +89,9 @@ public void testSocketTimeout() { public void testSslOptions() { SSLSocketFactory sf = new StubSSLSocketFactory(); X509TrustManager tm = new StubX509TrustManager(); - HttpConfiguration hc = Components.httpConfiguration().sslSocketFactory(sf, tm).createHttpConfiguration(); + HttpConfiguration hc = Components.httpConfiguration() + .sslSocketFactory(sf, tm) + .createHttpConfiguration(BASIC_CONFIG); assertSame(sf, hc.getSslSocketFactory()); assertSame(tm, hc.getTrustManager()); } @@ -86,16 +100,16 @@ public void testSslOptions() { public void testWrapperNameOnly() { HttpConfiguration hc = Components.httpConfiguration() .wrapper("Scala", null) - .createHttpConfiguration(); - assertEquals("Scala", hc.getWrapperIdentifier()); + .createHttpConfiguration(BASIC_CONFIG); + assertEquals("Scala", ImmutableMap.copyOf(hc.getDefaultHeaders()).get("X-LaunchDarkly-Wrapper")); } @Test public void testWrapperWithVersion() { HttpConfiguration hc = Components.httpConfiguration() .wrapper("Scala", "0.1.0") - .createHttpConfiguration(); - assertEquals("Scala/0.1.0", hc.getWrapperIdentifier()); + .createHttpConfiguration(BASIC_CONFIG); + assertEquals("Scala/0.1.0", ImmutableMap.copyOf(hc.getDefaultHeaders()).get("X-LaunchDarkly-Wrapper")); } public static class StubSSLSocketFactory extends SSLSocketFactory { diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/LoggingConfigurationBuilderTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/LoggingConfigurationBuilderTest.java index eef9e5dae..33d86a53a 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/LoggingConfigurationBuilderTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/LoggingConfigurationBuilderTest.java @@ -1,6 +1,7 @@ package com.launchdarkly.sdk.server.integrations; import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.interfaces.BasicConfiguration; import com.launchdarkly.sdk.server.interfaces.LoggingConfiguration; import org.junit.Test; @@ -12,9 +13,12 @@ @SuppressWarnings("javadoc") public class LoggingConfigurationBuilderTest { + private static final String SDK_KEY = "sdk-key"; + private static final BasicConfiguration BASIC_CONFIG = new BasicConfiguration(SDK_KEY, false, 0); + @Test public void testDefaults() { - LoggingConfiguration c = Components.logging().createLoggingConfiguration(); + LoggingConfiguration c = Components.logging().createLoggingConfiguration(BASIC_CONFIG); assertEquals(LoggingConfigurationBuilder.DEFAULT_LOG_DATA_SOURCE_OUTAGE_AS_ERROR_AFTER, c.getLogDataSourceOutageAsErrorAfter()); } @@ -23,12 +27,12 @@ public void testDefaults() { public void logDataSourceOutageAsErrorAfter() { LoggingConfiguration c1 = Components.logging() .logDataSourceOutageAsErrorAfter(Duration.ofMinutes(9)) - .createLoggingConfiguration(); + .createLoggingConfiguration(BASIC_CONFIG); assertEquals(Duration.ofMinutes(9), c1.getLogDataSourceOutageAsErrorAfter()); LoggingConfiguration c2 = Components.logging() .logDataSourceOutageAsErrorAfter(null) - .createLoggingConfiguration(); + .createLoggingConfiguration(BASIC_CONFIG); assertNull(c2.getLogDataSourceOutageAsErrorAfter()); } } From 6ce1d8ae5a0c8b00c1b292662a2fc5968d689b64 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 28 May 2020 12:11:31 -0700 Subject: [PATCH 497/673] don't need to pass the whole config object to describeConfiguration() --- .../sdk/server/ClientContextImpl.java | 1 + .../sdk/server/ComponentsImpl.java | 30 +++---- .../sdk/server/DiagnosticEvent.java | 38 ++++---- .../interfaces/DiagnosticDescription.java | 7 +- .../DefaultEventProcessorDiagnosticsTest.java | 4 +- .../sdk/server/DiagnosticEventTest.java | 87 +++++++++---------- 6 files changed, 80 insertions(+), 87 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java b/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java index 2eeee8a81..1cdf659fe 100644 --- a/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java @@ -64,6 +64,7 @@ private ClientContextImpl( diagnosticAccumulator.dataSinceDate, diagnosticAccumulator.diagnosticId, configuration, + basicConfiguration, httpConfiguration ); } else { diff --git a/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java b/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java index 2cea4b416..1bf4b61ff 100644 --- a/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java @@ -53,7 +53,7 @@ public DataStore createDataStore(ClientContext context, DataStoreUpdates dataSto } @Override - public LDValue describeConfiguration(LDConfig config) { + public LDValue describeConfiguration(BasicConfiguration basicConfiguration) { return LDValue.of("memory"); } } @@ -98,15 +98,17 @@ public DataSource createDataSource(ClientContext context, DataSourceUpdates data } @Override - public LDValue describeConfiguration(LDConfig config) { - // We can assume that if they don't have a data source, and they *do* have a persistent data store, then - // they're using Relay in daemon mode. + public LDValue describeConfiguration(BasicConfiguration basicConfiguration) { + // The difference between "offline" and "using the Relay daemon" is irrelevant from the data source's + // point of view, but we describe them differently in diagnostic events. This is easy because if we were + // configured to be completely offline... we wouldn't be sending any diagnostic events. Therefore, if + // Components.externalUpdatesOnly() was specified as the data source and we are sending a diagnostic + // event, we can assume usingRelayDaemon should be true. return LDValue.buildObject() .put(ConfigProperty.CUSTOM_BASE_URI.name, false) .put(ConfigProperty.CUSTOM_STREAM_URI.name, false) .put(ConfigProperty.STREAMING_DISABLED.name, false) - .put(ConfigProperty.USING_RELAY_DAEMON.name, - config.dataStoreFactory != null && config.dataStoreFactory != Components.inMemoryDataStore()) + .put(ConfigProperty.USING_RELAY_DAEMON.name, true) .build(); } } @@ -169,10 +171,7 @@ public DataSource createDataSource(ClientContext context, DataSourceUpdates data } @Override - public LDValue describeConfiguration(LDConfig config) { - if (config.offline) { - return NullDataSourceFactory.INSTANCE.describeConfiguration(config); - } + public LDValue describeConfiguration(BasicConfiguration basicConfiguration) { return LDValue.buildObject() .put(ConfigProperty.STREAMING_DISABLED.name, false) .put(ConfigProperty.CUSTOM_BASE_URI.name, @@ -212,10 +211,7 @@ public DataSource createDataSource(ClientContext context, DataSourceUpdates data } @Override - public LDValue describeConfiguration(LDConfig config) { - if (config.offline) { - return NullDataSourceFactory.INSTANCE.describeConfiguration(config); - } + public LDValue describeConfiguration(BasicConfiguration basicConfiguration) { return LDValue.buildObject() .put(ConfigProperty.STREAMING_DISABLED.name, true) .put(ConfigProperty.CUSTOM_BASE_URI.name, @@ -258,7 +254,7 @@ public EventProcessor createEventProcessor(ClientContext context) { } @Override - public LDValue describeConfiguration(LDConfig config) { + public LDValue describeConfiguration(BasicConfiguration basicConfiguration) { return LDValue.buildObject() .put(ConfigProperty.ALL_ATTRIBUTES_PRIVATE.name, allAttributesPrivate) .put(ConfigProperty.CUSTOM_EVENTS_URI.name, baseURI != null && !baseURI.equals(LDConfig.DEFAULT_EVENTS_URI)) @@ -318,9 +314,9 @@ public PersistentDataStoreBuilderImpl(PersistentDataStoreFactory persistentDataS } @Override - public LDValue describeConfiguration(LDConfig config) { + public LDValue describeConfiguration(BasicConfiguration basicConfiguration) { if (persistentDataStoreFactory instanceof DiagnosticDescription) { - return ((DiagnosticDescription)persistentDataStoreFactory).describeConfiguration(config); + return ((DiagnosticDescription)persistentDataStoreFactory).describeConfiguration(basicConfiguration); } return LDValue.of("custom"); } diff --git a/src/main/java/com/launchdarkly/sdk/server/DiagnosticEvent.java b/src/main/java/com/launchdarkly/sdk/server/DiagnosticEvent.java index 71b40e2ad..1fa6900a5 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DiagnosticEvent.java +++ b/src/main/java/com/launchdarkly/sdk/server/DiagnosticEvent.java @@ -3,6 +3,7 @@ import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.LDValueType; import com.launchdarkly.sdk.ObjectBuilder; +import com.launchdarkly.sdk.server.interfaces.BasicConfiguration; import com.launchdarkly.sdk.server.interfaces.DiagnosticDescription; import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; @@ -82,13 +83,19 @@ static class Init extends DiagnosticEvent { final LDValue configuration; final DiagnosticPlatform platform = new DiagnosticPlatform(); - Init(long creationDate, DiagnosticId diagnosticId, LDConfig config, HttpConfiguration httpConfig) { + Init( + long creationDate, + DiagnosticId diagnosticId, + LDConfig config, + BasicConfiguration basicConfig, + HttpConfiguration httpConfig + ) { super("diagnostic-init", creationDate, diagnosticId); this.sdk = new DiagnosticSdk(httpConfig); - this.configuration = getConfigurationData(config, httpConfig); + this.configuration = getConfigurationData(config, basicConfig, httpConfig); } - static LDValue getConfigurationData(LDConfig config, HttpConfiguration httpConfig) { + static LDValue getConfigurationData(LDConfig config, BasicConfiguration basicConfig, HttpConfiguration httpConfig) { ObjectBuilder builder = LDValue.buildObject(); // Add the top-level properties that are not specific to a particular component type. @@ -96,19 +103,12 @@ static LDValue getConfigurationData(LDConfig config, HttpConfiguration httpConfi builder.put("socketTimeoutMillis", httpConfig.getSocketTimeout().toMillis()); builder.put("usingProxy", httpConfig.getProxy() != null); builder.put("usingProxyAuthenticator", httpConfig.getProxyAuthentication() != null); - builder.put("offline", config.offline); builder.put("startWaitMillis", config.startWait.toMillis()); // Allow each pluggable component to describe its own relevant properties. - mergeComponentProperties(builder, - config.dataStoreFactory == null ? Components.inMemoryDataStore() : config.dataStoreFactory, - config, "dataStoreType"); - mergeComponentProperties(builder, - config.dataSourceFactory == null ? Components.streamingDataSource() : config.dataSourceFactory, - config, null); - mergeComponentProperties(builder, - config.eventProcessorFactory == null ? Components.sendEvents() : config.eventProcessorFactory, - config, null); + mergeComponentProperties(builder, config.dataStoreFactory, basicConfig, "dataStoreType"); + mergeComponentProperties(builder, config.dataSourceFactory, basicConfig, null); + mergeComponentProperties(builder, config.eventProcessorFactory, basicConfig, null); return builder.build(); } @@ -118,17 +118,19 @@ static LDValue getConfigurationData(LDConfig config, HttpConfiguration httpConfi // - If the value is a string, then set the defaultPropertyName property to that value. // - If the value is an object, then copy all of its properties as long as they are ones we recognize // and have the expected type. - private static void mergeComponentProperties(ObjectBuilder builder, Object component, LDConfig config, String defaultPropertyName) { - if (component == null) { - return; - } + private static void mergeComponentProperties( + ObjectBuilder builder, + Object component, + BasicConfiguration basicConfig, + String defaultPropertyName + ) { if (!(component instanceof DiagnosticDescription)) { if (defaultPropertyName != null) { builder.put(defaultPropertyName, LDValue.of(component.getClass().getSimpleName())); } return; } - LDValue componentDesc = ((DiagnosticDescription)component).describeConfiguration(config); + LDValue componentDesc = ((DiagnosticDescription)component).describeConfiguration(basicConfig); if (componentDesc == null || componentDesc.isNull()) { return; } diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DiagnosticDescription.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DiagnosticDescription.java index 5cbc0c832..4254c8e02 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/DiagnosticDescription.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DiagnosticDescription.java @@ -1,7 +1,6 @@ package com.launchdarkly.sdk.server.interfaces; import com.launchdarkly.sdk.LDValue; -import com.launchdarkly.sdk.server.LDConfig; /** * Optional interface for components to describe their own configuration. @@ -13,7 +12,7 @@ * 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(LDConfig)} method should return either null or a JSON value. For + * The {@link #describeConfiguration(BasicConfiguration)} method should return either null or a JSON value. For * custom components, the value must be a string that describes the basic nature of this component * implementation (e.g. "Redis"). Built-in LaunchDarkly components may instead return a JSON object * containing multiple properties specific to the LaunchDarkly diagnostic schema. @@ -23,8 +22,8 @@ public interface DiagnosticDescription { /** * Used internally by the SDK to inspect the configuration. - * @param config the full configuration, in case this component depends on properties outside itself + * @param basicConfiguration general SDK configuration properties that are not specific to this component * @return an {@link LDValue} or null */ - LDValue describeConfiguration(LDConfig config); + LDValue describeConfiguration(BasicConfiguration basicConfiguration); } diff --git a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorDiagnosticsTest.java b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorDiagnosticsTest.java index 8684e2d02..3d7c7b592 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorDiagnosticsTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorDiagnosticsTest.java @@ -2,6 +2,7 @@ import com.google.common.collect.ImmutableSet; import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.interfaces.ClientContext; import com.launchdarkly.sdk.server.interfaces.Event; import com.launchdarkly.sdk.server.interfaces.EventSender; @@ -126,8 +127,9 @@ public void periodicDiagnosticEventsAreSentAutomatically() throws Exception { // at least one periodic event without having to force a send via ep.postDiagnostic(). MockEventSender es = new MockEventSender(); DiagnosticId diagnosticId = new DiagnosticId(SDK_KEY); + ClientContext context = clientContext(SDK_KEY, LDConfig.DEFAULT); DiagnosticEvent.Init initEvent = new DiagnosticEvent.Init(0, diagnosticId, LDConfig.DEFAULT, - clientContext(SDK_KEY, LDConfig.DEFAULT).getHttp()); + context.getBasic(), context.getHttp()); DiagnosticAccumulator diagnosticAccumulator = new DiagnosticAccumulator(diagnosticId); Duration briefPeriodicInterval = Duration.ofMillis(50); diff --git a/src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java b/src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java index ecf87e025..0b713915b 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java @@ -5,12 +5,14 @@ import com.google.gson.JsonObject; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.ObjectBuilder; +import com.launchdarkly.sdk.server.interfaces.BasicConfiguration; import com.launchdarkly.sdk.server.interfaces.ClientContext; import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; import com.launchdarkly.sdk.server.interfaces.DiagnosticDescription; -import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; +import com.launchdarkly.sdk.server.interfaces.PersistentDataStore; +import com.launchdarkly.sdk.server.interfaces.PersistentDataStoreFactory; import org.junit.Test; @@ -72,7 +74,6 @@ private ObjectBuilder expectedDefaultPropertiesWithoutStreaming() { .put("eventsCapacity", 10_000) .put("eventsFlushIntervalMillis",5_000) .put("inlineUsersInEvents", false) - .put("offline", false) .put("samplingInterval", 0) .put("socketTimeoutMillis", 10_000) .put("startWaitMillis", 5_000) @@ -84,15 +85,15 @@ private ObjectBuilder expectedDefaultPropertiesWithoutStreaming() { .put("usingRelayDaemon", false); } - private static HttpConfiguration makeHttpConfig(LDConfig config) { - // the SDK key doesn't matter for these tests - return clientContext("SDK_KEY", config).getHttp(); + private static LDValue makeConfigData(LDConfig config) { + ClientContext context = clientContext("SDK_KEY", config); // the SDK key doesn't matter for these tests + return DiagnosticEvent.Init.getConfigurationData(config, context.getBasic(), context.getHttp()); } @Test public void testDefaultDiagnosticConfiguration() { LDConfig ldConfig = new LDConfig.Builder().build(); - LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig, makeHttpConfig(ldConfig)); + LDValue diagnosticJson = makeConfigData(ldConfig); LDValue expected = expectedDefaultProperties().build(); assertEquals(expected, diagnosticJson); @@ -104,7 +105,7 @@ public void testCustomDiagnosticConfigurationGeneralProperties() { .startWait(Duration.ofSeconds(10)) .build(); - LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig, makeHttpConfig(ldConfig)); + LDValue diagnosticJson = makeConfigData(ldConfig); LDValue expected = expectedDefaultProperties() .put("startWaitMillis", 10_000) .build(); @@ -123,7 +124,7 @@ public void testCustomDiagnosticConfigurationForStreaming() { ) .build(); - LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig, makeHttpConfig(ldConfig)); + LDValue diagnosticJson = makeConfigData(ldConfig); LDValue expected = expectedDefaultPropertiesWithoutStreaming() .put("customBaseURI", true) .put("customStreamURI", true) @@ -143,7 +144,7 @@ public void testCustomDiagnosticConfigurationForPolling() { ) .build(); - LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig, makeHttpConfig(ldConfig)); + LDValue diagnosticJson = makeConfigData(ldConfig); LDValue expected = expectedDefaultPropertiesWithoutStreaming() .put("customBaseURI", true) .put("pollingIntervalMillis", 60_000) @@ -153,6 +154,20 @@ public void testCustomDiagnosticConfigurationForPolling() { assertEquals(expected, diagnosticJson); } + @Test + public void testCustomDiagnosticConfigurationForPersistentDataStore() { + LDConfig ldConfig = new LDConfig.Builder() + .dataStore(Components.persistentDataStore(new PersistentDataStoreFactoryWithComponentName())) + .build(); + + LDValue diagnosticJson = makeConfigData(ldConfig); + LDValue expected = expectedDefaultProperties() + .put("dataStoreType", "my-test-store") + .build(); + + assertEquals(expected, diagnosticJson); + } + @Test public void testCustomDiagnosticConfigurationForEvents() { LDConfig ldConfig = new LDConfig.Builder() @@ -169,7 +184,7 @@ public void testCustomDiagnosticConfigurationForEvents() { ) .build(); - LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig, makeHttpConfig(ldConfig)); + LDValue diagnosticJson = makeConfigData(ldConfig); LDValue expected = expectedDefaultProperties() .put("allAttributesPrivate", true) .put("customEventsURI", true) @@ -191,7 +206,7 @@ public void testCustomDiagnosticConfigurationForDaemonMode() { .dataStore(new DataStoreFactoryWithComponentName()) .build(); - LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig, makeHttpConfig(ldConfig)); + LDValue diagnosticJson = makeConfigData(ldConfig); LDValue expected = expectedDefaultPropertiesWithoutStreaming() .put("dataStoreType", "my-test-store") .put("usingRelayDaemon", true) @@ -200,40 +215,6 @@ public void testCustomDiagnosticConfigurationForDaemonMode() { assertEquals(expected, diagnosticJson); } - @Test - public void testCustomDiagnosticConfigurationForOffline() { - LDConfig ldConfig = new LDConfig.Builder().offline(true).build(); - LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig, makeHttpConfig(ldConfig)); - LDValue expected = expectedDefaultPropertiesWithoutStreaming() - .put("offline", true) - .build(); - - assertEquals(expected, diagnosticJson); - - LDConfig ldConfig2 = new LDConfig.Builder().offline(true) - .dataStore(Components.inMemoryDataStore()) // just double-checking the logic in NullDataSourceFactory.describeConfiguration() - .build(); - LDValue diagnosticJson2 = DiagnosticEvent.Init.getConfigurationData(ldConfig2, makeHttpConfig(ldConfig2)); - - assertEquals(expected, diagnosticJson2); - - // streaming or polling + offline == offline - - LDConfig ldConfig3 = new LDConfig.Builder().offline(true) - .dataSource(Components.streamingDataSource()) - .build(); - LDValue diagnosticJson3 = DiagnosticEvent.Init.getConfigurationData(ldConfig3, makeHttpConfig(ldConfig3)); - - assertEquals(expected, diagnosticJson3); - - LDConfig ldConfig4 = new LDConfig.Builder().offline(true) - .dataSource(Components.pollingDataSource()) - .build(); - LDValue diagnosticJson4 = DiagnosticEvent.Init.getConfigurationData(ldConfig4, makeHttpConfig(ldConfig4)); - - assertEquals(expected, diagnosticJson4); - } - @Test public void testCustomDiagnosticConfigurationHttpProperties() { LDConfig ldConfig = new LDConfig.Builder() @@ -246,7 +227,7 @@ public void testCustomDiagnosticConfigurationHttpProperties() { ) .build(); - LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig, makeHttpConfig(ldConfig)); + LDValue diagnosticJson = makeConfigData(ldConfig); LDValue expected = expectedDefaultProperties() .put("connectTimeoutMillis", 5_000) .put("socketTimeoutMillis", 20_000) @@ -259,7 +240,7 @@ public void testCustomDiagnosticConfigurationHttpProperties() { private static class DataStoreFactoryWithComponentName implements DataStoreFactory, DiagnosticDescription { @Override - public LDValue describeConfiguration(LDConfig config) { + public LDValue describeConfiguration(BasicConfiguration basicConfig) { return LDValue.of("my-test-store"); } @@ -268,4 +249,16 @@ public DataStore createDataStore(ClientContext context, DataStoreUpdates dataSto return null; } } + + private static class PersistentDataStoreFactoryWithComponentName implements PersistentDataStoreFactory, DiagnosticDescription { + @Override + public LDValue describeConfiguration(BasicConfiguration basicConfig) { + return LDValue.of("my-test-store"); + } + + @Override + public PersistentDataStore createPersistentDataStore(ClientContext context) { + return null; + } + } } From 5d54f0aa1bc3d917fc080352b2ea60a24935ef6b Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 28 May 2020 12:26:00 -0700 Subject: [PATCH 498/673] simplify test logic for HTTP headers --- .../sdk/server/FeatureRequestorTest.java | 10 ++++-- .../sdk/server/StreamProcessorTest.java | 34 ++++++------------- 2 files changed, 18 insertions(+), 26 deletions(-) diff --git a/src/test/java/com/launchdarkly/sdk/server/FeatureRequestorTest.java b/src/test/java/com/launchdarkly/sdk/server/FeatureRequestorTest.java index a45745a3b..a39c6146a 100644 --- a/src/test/java/com/launchdarkly/sdk/server/FeatureRequestorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/FeatureRequestorTest.java @@ -7,13 +7,17 @@ import org.junit.Test; import java.net.URI; +import java.util.Map; import java.util.concurrent.TimeUnit; import javax.net.ssl.SSLHandshakeException; +import static com.launchdarkly.sdk.server.TestComponents.clientContext; import static com.launchdarkly.sdk.server.TestHttpUtil.httpsServerWithSelfSignedCert; import static com.launchdarkly.sdk.server.TestHttpUtil.jsonResponse; import static com.launchdarkly.sdk.server.TestHttpUtil.makeStartedServer; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; @@ -215,8 +219,10 @@ public void httpClientCanUseProxyConfig() throws Exception { } private void verifyHeaders(RecordedRequest req) { - assertEquals(sdkKey, req.getHeader("Authorization")); - assertEquals("JavaClient/" + Version.SDK_VERSION, req.getHeader("User-Agent")); + HttpConfiguration httpConfig = clientContext(sdkKey, LDConfig.DEFAULT).getHttp(); + for (Map.Entry kv: httpConfig.getDefaultHeaders()) { + assertThat(req.getHeader(kv.getKey()), equalTo(kv.getValue())); + } } private void verifyFlag(DataModel.FeatureFlag flag, String key) { diff --git a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java index f028b55d4..847826d10 100644 --- a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java @@ -18,6 +18,7 @@ import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; import org.easymock.EasyMockSupport; import org.junit.Before; @@ -27,6 +28,7 @@ import java.net.URI; import java.time.Duration; import java.util.Collections; +import java.util.Map; import java.util.concurrent.BlockingQueue; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Future; @@ -134,19 +136,17 @@ public void streamUriHasCorrectEndpoint() { } @Test - public void headersHaveAuthorization() { + public void basicHeadersAreSent() { + HttpConfiguration httpConfig = clientContext(SDK_KEY, LDConfig.DEFAULT).getHttp(); + createStreamProcessor(STREAM_URI).start(); - assertEquals(SDK_KEY, - mockEventSourceCreator.getNextReceivedParams().headers.get("Authorization")); + EventSourceParams params = mockEventSourceCreator.getNextReceivedParams(); + + for (Map.Entry kv: httpConfig.getDefaultHeaders()) { + assertThat(params.headers.get(kv.getKey()), equalTo(kv.getValue())); + } } - @Test - public void headersHaveUserAgent() { - createStreamProcessor(STREAM_URI).start(); - assertEquals("JavaClient/" + Version.SDK_VERSION, - mockEventSourceCreator.getNextReceivedParams().headers.get("User-Agent")); - } - @Test public void headersHaveAccept() { createStreamProcessor(STREAM_URI).start(); @@ -154,16 +154,6 @@ public void headersHaveAccept() { mockEventSourceCreator.getNextReceivedParams().headers.get("Accept")); } - @Test - public void headersHaveWrapperWhenSet() { - LDConfig config = new LDConfig.Builder() - .http(Components.httpConfiguration().wrapper("Scala", "0.1.0")) - .build(); - createStreamProcessor(config, STREAM_URI).start(); - assertEquals("Scala/0.1.0", - mockEventSourceCreator.getNextReceivedParams().headers.get("X-LaunchDarkly-Wrapper")); - } - @Test public void putCausesFeatureToBeStored() throws Exception { expectNoStreamRestart(); @@ -873,10 +863,6 @@ private StreamProcessor createStreamProcessor(URI streamUri) { return createStreamProcessor(LDConfig.DEFAULT, streamUri, null); } - private StreamProcessor createStreamProcessor(LDConfig config, URI streamUri) { - return createStreamProcessor(config, streamUri, null); - } - private StreamProcessor createStreamProcessor(LDConfig config, URI streamUri, DiagnosticAccumulator diagnosticAccumulator) { return new StreamProcessor( clientContext(SDK_KEY, config).getHttp(), From 8b24c8198ad8528433c6c6232910173f8fe1dcaa Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 28 May 2020 21:35:03 -0700 Subject: [PATCH 499/673] (5.0) final test coverage improvements, for now, with enforcement --- CONTRIBUTING.md | 6 +- build.gradle | 58 +++ .../sdk/json/SdkSerializationExtensions.java | 4 +- .../sdk/server/ComponentsImpl.java | 18 +- .../sdk/server/DataModelDependencies.java | 23 +- .../sdk/server/DataSourceUpdatesImpl.java | 20 +- .../server/DataStoreStatusProviderImpl.java | 2 +- .../sdk/server/DefaultEventProcessor.java | 46 ++- .../sdk/server/DefaultFeatureRequestor.java | 28 -- .../sdk/server/DiagnosticEvent.java | 17 +- .../com/launchdarkly/sdk/server/LDClient.java | 8 - .../com/launchdarkly/sdk/server/LDConfig.java | 13 +- .../PersistentDataStoreStatusManager.java | 4 - .../server/PersistentDataStoreWrapper.java | 13 +- .../sdk/server/PollingProcessor.java | 11 +- .../integrations/FileDataSourceImpl.java | 13 +- .../integrations/FileDataSourceParsing.java | 29 +- .../interfaces/DataStoreStatusProvider.java | 2 +- .../sdk/server/interfaces/DataStoreTypes.java | 12 + .../sdk/server/DataModelDependenciesTest.java | 363 ++++++++++++++++++ .../DataSourceStatusProviderImplTest.java | 112 ++++++ .../sdk/server/DataSourceUpdatesImplTest.java | 197 +++++++--- .../DataStoreStatusProviderImplTest.java | 123 ++++++ .../sdk/server/DataStoreTestTypes.java | 37 +- .../sdk/server/DataStoreUpdatesImplTest.java | 55 +++ .../sdk/server/DiagnosticEventTest.java | 239 ++++++++++-- .../sdk/server/DiagnosticSdkTest.java | 39 +- .../sdk/server/EventBroadcasterImplTest.java | 42 ++ .../sdk/server/FeatureFlagsStateTest.java | 23 +- .../sdk/server/LDClientListenersTest.java | 84 +--- .../launchdarkly/sdk/server/LDClientTest.java | 21 + .../launchdarkly/sdk/server/LDConfigTest.java | 15 +- .../PersistentDataStoreWrapperOtherTest.java | 115 ++++++ .../PersistentDataStoreWrapperTest.java | 14 +- .../sdk/server/PollingProcessorTest.java | 100 ++++- .../sdk/server/StreamProcessorTest.java | 115 ++++-- .../sdk/server/TestComponents.java | 2 +- .../com/launchdarkly/sdk/server/TestUtil.java | 32 ++ .../EventProcessorBuilderTest.java | 134 ++++++- .../FileDataSourceAutoUpdateTest.java | 123 ++++++ .../integrations/FileDataSourceTest.java | 204 ++-------- .../integrations/FileDataSourceTestData.java | 52 +++ .../integrations/FlagFileParserTestBase.java | 11 +- .../HttpConfigurationBuilderTest.java | 24 +- .../integrations/MockPersistentDataStore.java | 4 +- .../PersistentDataStoreBuilderTest.java | 63 +++ .../PollingDataSourceBuilderTest.java | 37 ++ .../StreamingDataSourceBuilderTest.java | 43 +++ .../DataSourceStatusProviderTypesTest.java | 111 ++++++ .../DataStoreStatusProviderTypesTest.java | 80 ++++ .../server/interfaces/DataStoreTypesTest.java | 165 ++++++++ .../HttpAuthenticationTypesTest.java | 18 + src/test/resources/filesource/no-data.json | 0 src/test/resources/filesource/value-only.json | 1 + 54 files changed, 2535 insertions(+), 590 deletions(-) create mode 100644 src/test/java/com/launchdarkly/sdk/server/DataModelDependenciesTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/DataSourceStatusProviderImplTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImplTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/DataStoreUpdatesImplTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperOtherTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceAutoUpdateTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreBuilderTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/integrations/PollingDataSourceBuilderTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/integrations/StreamingDataSourceBuilderTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProviderTypesTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProviderTypesTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypesTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/interfaces/HttpAuthenticationTypesTest.java create mode 100644 src/test/resources/filesource/no-data.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2501c2bf1..8b923d7b5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -47,4 +47,8 @@ To build the SDK and run all unit tests: It is important to keep unit test coverage as close to 100% as possible in this project. You can view the latest code coverage report in CircleCI, as `coverage/html/index.html` in the artifacts for the "Java 11 - Linux - OpenJDK" job. You can also run the report locally with `./gradlew jacocoTestCoverage` and view `./build/reports/jacoco/test`. -Sometimes a gap in coverage is unavoidable, usually because the compiler requires us to provide a code path for some condition that in practice can't happen and can't be tested, or because of a known issue with the code coverage tool. In all such cases, please mark the code with an explanatory comment beginning with "COVERAGE:". +Sometimes a gap in coverage is unavoidable, usually because the compiler requires us to provide a code path for some condition that in practice can't happen and can't be tested, or because of a known issue with the code coverage tool. Please handle all such cases as follows: + +* Mark the code with an explanatory comment beginning with "COVERAGE:". +* Run the code coverage task with `./gradlew jacocoTestCoverageVerification`. It should fail and indicate how many lines of missed coverage exist in the method you modified. +* Add an item in the `knownMissedLinesForMethods` map in `build.gradle` that specifies that number of missed lines for that method signature. diff --git a/build.gradle b/build.gradle index 60484d905..b6885fe6d 100644 --- a/build.gradle +++ b/build.gradle @@ -433,6 +433,64 @@ jacocoTestReport { // code coverage report } } +jacocoTestCoverageVerification { + // See notes in CONTRIBUTING.md on code coverage. Unfortunately we can't configure line-by-line code + // coverage overrides within the source code itself, because Jacoco operates on bytecode. + violationRules { rules -> + def knownMissedLinesForMethods = [ + // The key for each of these items is the complete method signature minus the "com.launchdarkly.sdk.server." prefix. + "DataSourceUpdatesImpl.OutageTracker.onTimeout()": 1, + "DataSourceUpdatesImpl.computeChangedItemsForFullDataSet(java.util.Map, java.util.Map)": 2, + "DefaultEventProcessor.EventProcessorMessage.waitForCompletion()": 3, + "DefaultEventProcessor.EventDispatcher.onUncaughtException(java.lang.Thread, java.lang.Throwable)": 8, + "DefaultEventProcessor.EventDispatcher.runMainLoop(java.util.concurrent.BlockingQueue, com.launchdarkly.sdk.server.DefaultEventProcessor.EventBuffer, com.launchdarkly.sdk.server.SimpleLRUCache, java.util.concurrent.BlockingQueue)": 4, + "DefaultEventProcessor.postToChannel(com.launchdarkly.sdk.server.DefaultEventProcessor.EventProcessorMessage)": 5, + "DefaultEventSender.sendEventData(com.launchdarkly.sdk.server.interfaces.EventSender.EventDataKind, java.lang.String, int, java.net.URI)": 1, + "EvaluatorOperators.ComparisonOp.test(int)": 1, + "EvaluatorOperators.apply(com.launchdarkly.sdk.server.DataModel.Operator, com.launchdarkly.sdk.LDValue, com.launchdarkly.sdk.LDValue, com.launchdarkly.sdk.server.EvaluatorPreprocessing.ClauseExtra.ValueExtra)": 1, + "LDClient.LDClient(java.lang.String)": 2, + "LDClient.secureModeHash(com.launchdarkly.sdk.LDUser)": 4, + "PersistentDataStoreStatusManager.1.run()": 2, + "PersistentDataStoreWrapper.PersistentDataStoreWrapper(com.launchdarkly.sdk.server.interfaces.PersistentDataStore, java.time.Duration, com.launchdarkly.sdk.server.integrations.PersistentDataStoreBuilder.StaleValuesPolicy, boolean, com.launchdarkly.sdk.server.interfaces.DataStoreUpdates, java.util.concurrent.ScheduledExecutorService)": 2, + "PersistentDataStoreWrapper.isInitialized()": 2, + "PersistentDataStoreWrapper.getAll(com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind)": 3, + "PersistentDataStoreWrapper.deserialize(com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind, com.launchdarkly.sdk.server.interfaces.DataStoreTypes.SerializedItemDescriptor)": 2, + "SemanticVersion.parse(java.lang.String, boolean)": 2, + "Util.1.lambda\$authenticate\$0(okhttp3.Challenge)": 1, + "integrations.FileDataSourceImpl.FileDataSourceImpl(com.launchdarkly.sdk.server.interfaces.DataSourceUpdates, java.util.List, boolean)": 3, + "integrations.FileDataSourceImpl.FileWatcher.run()": 3, + "integrations.FileDataSourceParsing.FlagFileParser.detectJson(java.io.Reader)": 2 + ] + + knownMissedLinesForMethods.each { partialSignature, maxMissedLines -> + if (maxMissedLines > 0) { // < 0 means skip entire method + rules.rule { + element = "METHOD" + includes = [ "com.launchdarkly.sdk.server." + partialSignature ] + limit { + counter = "LINE" + value = "MISSEDCOUNT" + maximum = maxMissedLines + } + } + } + } + + // General rule that we should expect 100% test coverage; exclude any methods that have overrides above + rule { + element = "METHOD" + limit { + counter = "LINE" + value = "MISSEDCOUNT" + maximum = 0 + } + excludes = knownMissedLinesForMethods.collect { partialSignature, maxMissedLines -> + "com.launchdarkly.sdk.server." + partialSignature + } + } + } +} + idea { module { downloadJavadoc = true diff --git a/src/main/java/com/launchdarkly/sdk/json/SdkSerializationExtensions.java b/src/main/java/com/launchdarkly/sdk/json/SdkSerializationExtensions.java index cf3dac109..5044c577d 100644 --- a/src/main/java/com/launchdarkly/sdk/json/SdkSerializationExtensions.java +++ b/src/main/java/com/launchdarkly/sdk/json/SdkSerializationExtensions.java @@ -5,7 +5,9 @@ // See JsonSerialization.getDeserializableClasses in java-sdk-common. -class SdkSerializationExtensions { +abstract class SdkSerializationExtensions { + private SdkSerializationExtensions() {} + public static Iterable> getDeserializableClasses() { return ImmutableList.>of( FeatureFlagsState.class diff --git a/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java b/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java index 1bf4b61ff..4d38bda0e 100644 --- a/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java @@ -136,10 +136,6 @@ static final class StreamingDataSourceBuilderImpl extends StreamingDataSourceBui public DataSource createDataSource(ClientContext context, DataSourceUpdates dataSourceUpdates) { // Note, we log startup messages under the LDClient class to keep logs more readable - if (context.getBasic().isOffline()) { - return Components.externalUpdatesOnly().createDataSource(context, dataSourceUpdates); - } - LDClient.logger.info("Enabling streaming API"); URI streamUri = baseURI == null ? LDConfig.DEFAULT_STREAM_URI : baseURI; @@ -190,10 +186,6 @@ static final class PollingDataSourceBuilderImpl extends PollingDataSourceBuilder public DataSource createDataSource(ClientContext context, DataSourceUpdates dataSourceUpdates) { // Note, we log startup messages under the LDClient class to keep logs more readable - if (context.getBasic().isOffline()) { - return Components.externalUpdatesOnly().createDataSource(context, dataSourceUpdates); - } - LDClient.logger.info("Disabling streaming API"); LDClient.logger.warn("You should only disable the streaming API if instructed to do so by LaunchDarkly support"); @@ -227,9 +219,6 @@ static final class EventProcessorBuilderImpl extends EventProcessorBuilder implements DiagnosticDescription { @Override public EventProcessor createEventProcessor(ClientContext context) { - if (context.getBasic().isOffline()) { - return new NullEventProcessor(); - } EventSender eventSender = (eventSenderFactory == null ? new DefaultEventSender.Factory() : eventSenderFactory) .createEventSender(context.getBasic(), context.getHttp()); @@ -281,9 +270,14 @@ public HttpConfiguration createHttpConfiguration(BasicConfiguration basicConfigu headers.put("X-LaunchDarkly-Wrapper", wrapperId); } + Proxy proxy = proxyHost == null ? null : new Proxy(Proxy.Type.HTTP, new InetSocketAddress(proxyHost, proxyPort)); + if (proxy != null) { + LDClient.logger.info("Using proxy: {} {} authentication.", proxy, proxyAuth == null ? "without" : "with"); + } + return new HttpConfigurationImpl( connectTimeout, - proxyHost == null ? null : new Proxy(Proxy.Type.HTTP, new InetSocketAddress(proxyHost, proxyPort)), + proxy, proxyAuth, socketTimeout, sslSocketFactory, diff --git a/src/main/java/com/launchdarkly/sdk/server/DataModelDependencies.java b/src/main/java/com/launchdarkly/sdk/server/DataModelDependencies.java index b3a653705..a69a50bc4 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DataModelDependencies.java +++ b/src/main/java/com/launchdarkly/sdk/server/DataModelDependencies.java @@ -39,6 +39,8 @@ * @since 4.6.1 */ abstract class DataModelDependencies { + private DataModelDependencies() {} + static class KindAndKey { final DataKind kind; final String key; @@ -197,29 +199,26 @@ static final class DependencyTracker { */ public void updateDependenciesFrom(DataKind fromKind, String fromKey, ItemDescriptor fromItem) { KindAndKey fromWhat = new KindAndKey(fromKind, fromKey); - Set updatedDependencies = computeDependenciesFrom(fromKind, fromItem); + Set updatedDependencies = computeDependenciesFrom(fromKind, fromItem); // never null Set oldDependencySet = dependenciesFrom.get(fromWhat); if (oldDependencySet != null) { for (KindAndKey oldDep: oldDependencySet) { Set depsToThisOldDep = dependenciesTo.get(oldDep); if (depsToThisOldDep != null) { + // COVERAGE: cannot cause this condition in unit tests, it should never be null depsToThisOldDep.remove(fromWhat); } } } - if (updatedDependencies == null) { - dependenciesFrom.remove(fromWhat); - } else { - dependenciesFrom.put(fromWhat, updatedDependencies); - for (KindAndKey newDep: updatedDependencies) { - Set depsToThisNewDep = dependenciesTo.get(newDep); - if (depsToThisNewDep == null) { - depsToThisNewDep = new HashSet<>(); - dependenciesTo.put(newDep, depsToThisNewDep); - } - depsToThisNewDep.add(fromWhat); + dependenciesFrom.put(fromWhat, updatedDependencies); + for (KindAndKey newDep: updatedDependencies) { + Set depsToThisNewDep = dependenciesTo.get(newDep); + if (depsToThisNewDep == null) { + depsToThisNewDep = new HashSet<>(); + dependenciesTo.put(newDep, depsToThisNewDep); } + depsToThisNewDep.add(fromWhat); } } diff --git a/src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java b/src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java index 28da505a7..90cc25b0d 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java @@ -28,6 +28,7 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; import static com.google.common.collect.Iterables.concat; import static com.google.common.collect.Iterables.transform; @@ -56,6 +57,7 @@ final class DataSourceUpdatesImpl implements DataSourceUpdates { private volatile Status currentStatus; private volatile boolean lastStoreUpdateFailed = false; + volatile Consumer onOutageErrorLog = null; // test instrumentation DataSourceUpdatesImpl( DataStore store, @@ -237,6 +239,7 @@ private Set computeChangedItemsForFullDataSet(Map oldItems = oldDataMap.get(kind); Map newItems = newDataMap.get(kind); if (oldItems == null) { + // COVERAGE: there is no way to simulate this condition in unit tests oldItems = emptyMap(); } if (newItems == null) { @@ -247,6 +250,7 @@ private Set computeChangedItemsForFullDataSet(Map entry) { - return entry.getKey() + " (" + entry.getValue() + (entry.getValue() == 1 ? " time" : " times") + ")"; - } + } + + private static String describeErrorCount(Map.Entry entry) { + return entry.getKey() + " (" + entry.getValue() + (entry.getValue() == 1 ? " time" : " times") + ")"; } } diff --git a/src/main/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImpl.java b/src/main/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImpl.java index 26b67e426..207ee24e3 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImpl.java @@ -16,7 +16,7 @@ final class DataStoreStatusProviderImpl implements DataStoreStatusProvider { } @Override - public Status getStoreStatus() { + public Status getStatus() { return dataStoreUpdates.lastStatus.get(); } diff --git a/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java index 0cf1f941b..7dbee1ed7 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java @@ -197,6 +197,8 @@ static final class EventDispatcher { private static final int MESSAGE_BATCH_SIZE = 50; @VisibleForTesting final EventsConfiguration eventsConfig; + private final BlockingQueue inbox; + private final AtomicBoolean closed; private final List flushWorkers; private final AtomicInteger busyFlushWorkersCount; private final AtomicLong lastKnownPastTime = new AtomicLong(0); @@ -211,12 +213,14 @@ private EventDispatcher( EventsConfiguration eventsConfig, ExecutorService sharedExecutor, int threadPriority, - final BlockingQueue inbox, - final AtomicBoolean closed, + BlockingQueue inbox, + AtomicBoolean closed, DiagnosticAccumulator diagnosticAccumulator, DiagnosticEvent.Init diagnosticInitEvent ) { this.eventsConfig = eventsConfig; + this.inbox = inbox; + this.closed = closed; this.sharedExecutor = sharedExecutor; this.diagnosticAccumulator = diagnosticAccumulator; this.busyFlushWorkersCount = new AtomicInteger(0); @@ -240,24 +244,8 @@ private EventDispatcher( }); mainThread.setDaemon(true); - mainThread.setUncaughtExceptionHandler((Thread t, Throwable e) -> { - // 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. - - // COVERAGE: there is no way to make this happen from test code. - 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(); - inbox.drainTo(messages); - for (EventProcessorMessage m: messages) { - m.completed(); - } - }); - + mainThread.setUncaughtExceptionHandler(this::onUncaughtException); + mainThread.start(); flushWorkers = new ArrayList<>(); @@ -282,6 +270,24 @@ private EventDispatcher( } } + private void onUncaughtException(Thread thread, Throwable e) { + // 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. + // COVERAGE: there is no way to make this happen from test code. + + 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(); + inbox.drainTo(messages); + for (EventProcessorMessage m: messages) { + m.completed(); + } + } + /** * 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, diff --git a/src/main/java/com/launchdarkly/sdk/server/DefaultFeatureRequestor.java b/src/main/java/com/launchdarkly/sdk/server/DefaultFeatureRequestor.java index aef2b9aab..e16c8d826 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DefaultFeatureRequestor.java +++ b/src/main/java/com/launchdarkly/sdk/server/DefaultFeatureRequestor.java @@ -1,14 +1,7 @@ package com.launchdarkly.sdk.server; import com.google.common.annotations.VisibleForTesting; -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableMap; -import com.google.common.collect.Maps; import com.google.common.io.Files; -import com.launchdarkly.sdk.server.DataModel.VersionedData; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; import com.launchdarkly.sdk.server.interfaces.SerializationException; @@ -18,10 +11,7 @@ import java.io.File; import java.io.IOException; import java.net.URI; -import java.util.Map; -import static com.launchdarkly.sdk.server.DataModel.FEATURES; -import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; import static com.launchdarkly.sdk.server.Util.configureHttpClientBuilder; import static com.launchdarkly.sdk.server.Util.getHeadersBuilderFor; import static com.launchdarkly.sdk.server.Util.shutdownHttpClient; @@ -85,24 +75,6 @@ public AllData getAllData() throws IOException, HttpErrorException, Serializatio return JsonHelpers.deserialize(body, AllData.class); } - static FullDataSet toFullDataSet(AllData allData) { - return new FullDataSet(ImmutableMap.of( - FEATURES, toKeyedItems(allData.flags), - SEGMENTS, toKeyedItems(allData.segments) - ).entrySet()); - } - - static KeyedItems toKeyedItems(Map itemsMap) { - if (itemsMap == null) { - return new KeyedItems<>(null); - } - return new KeyedItems<>( - ImmutableList.copyOf( - Maps.transformValues(itemsMap, item -> new ItemDescriptor(item.getVersion(), item)).entrySet() - ) - ); - } - private String get(String path) throws IOException, HttpErrorException { Request request = new Request.Builder() .url(baseUri.resolve(path).toURL()) diff --git a/src/main/java/com/launchdarkly/sdk/server/DiagnosticEvent.java b/src/main/java/com/launchdarkly/sdk/server/DiagnosticEvent.java index 1fa6900a5..c5803f6a9 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DiagnosticEvent.java +++ b/src/main/java/com/launchdarkly/sdk/server/DiagnosticEvent.java @@ -113,7 +113,7 @@ static LDValue getConfigurationData(LDConfig config, BasicConfiguration basicCon } // 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 the component does not implement DiagnosticDescription, set the defaultPropertyName property to "custom". // - If it does implement DiagnosticDescription, call its describeConfiguration() method to get a value. // - If the value is a string, then set the defaultPropertyName property to that value. // - If the value is an object, then copy all of its properties as long as they are ones we recognize @@ -126,22 +126,19 @@ private static void mergeComponentProperties( ) { if (!(component instanceof DiagnosticDescription)) { if (defaultPropertyName != null) { - builder.put(defaultPropertyName, LDValue.of(component.getClass().getSimpleName())); + builder.put(defaultPropertyName, "custom"); } return; } - LDValue componentDesc = ((DiagnosticDescription)component).describeConfiguration(basicConfig); - if (componentDesc == null || componentDesc.isNull()) { - return; - } - if (componentDesc.isString() && defaultPropertyName != null) { - builder.put(defaultPropertyName, componentDesc); + LDValue componentDesc = LDValue.normalize(((DiagnosticDescription)component).describeConfiguration(basicConfig)); + if (defaultPropertyName != null) { + builder.put(defaultPropertyName, componentDesc.isString() ? componentDesc.stringValue() : "custom"); } else if (componentDesc.getType() == LDValueType.OBJECT) { for (String key: componentDesc.keys()) { for (ConfigProperty prop: ConfigProperty.values()) { if (prop.name.equals(key)) { LDValue value = componentDesc.get(key); - if (value.isNull() || value.getType() == prop.type) { + if (value.getType() == prop.type) { builder.put(key, value); } } @@ -181,7 +178,7 @@ static class DiagnosticPlatform { 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 = normalizeOsName(System.getProperty("os.name")); + final String osName = normalizeOsName(System.getProperty("os.name")); // visible for tests private final String osVersion = System.getProperty("os.version"); DiagnosticPlatform() { diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClient.java b/src/main/java/com/launchdarkly/sdk/server/LDClient.java index 1d6273e96..90bbc3832 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClient.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClient.java @@ -172,14 +172,6 @@ public LDClient(String sdkKey, LDConfig config) { useDiagnostics ? new DiagnosticAccumulator(new DiagnosticId(sdkKey)) : null ); - if (context.getHttp().getProxy() != null) { - if (context.getHttp().getProxyAuthentication() != null) { - logger.info("Using proxy: {} with authentication.", context.getHttp().getProxy()); - } else { - logger.info("Using proxy: {} without authentication.", context.getHttp().getProxy()); - } - } - this.eventProcessor = config.eventProcessorFactory.createEventProcessor(context); EventBroadcasterImpl dataStoreStatusNotifier = diff --git a/src/main/java/com/launchdarkly/sdk/server/LDConfig.java b/src/main/java/com/launchdarkly/sdk/server/LDConfig.java index 88ae9b158..0c6a7e09d 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDConfig.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDConfig.java @@ -33,12 +33,17 @@ public final class LDConfig { final int threadPriority; protected LDConfig(Builder builder) { + if (builder.offline) { + this.dataSourceFactory = Components.externalUpdatesOnly(); + this.eventProcessorFactory = Components.noEvents(); + } else { + this.dataSourceFactory = builder.dataSourceFactory == null ? Components.streamingDataSource() : + builder.dataSourceFactory; + this.eventProcessorFactory = builder.eventProcessorFactory == null ? Components.sendEvents() : + builder.eventProcessorFactory; + } this.dataStoreFactory = builder.dataStoreFactory == null ? Components.inMemoryDataStore() : builder.dataStoreFactory; - this.eventProcessorFactory = builder.eventProcessorFactory == null ? Components.sendEvents() : - builder.eventProcessorFactory; - this.dataSourceFactory = builder.dataSourceFactory == null ? Components.streamingDataSource() : - builder.dataSourceFactory; this.diagnosticOptOut = builder.diagnosticOptOut; this.httpConfigFactory = builder.httpConfigFactory == null ? Components.httpConfiguration() : builder.httpConfigFactory; diff --git a/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreStatusManager.java b/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreStatusManager.java index ffb30daab..b2b20e222 100644 --- a/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreStatusManager.java +++ b/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreStatusManager.java @@ -95,8 +95,4 @@ public void run() { } } } - - synchronized boolean isAvailable() { - return lastAvailable; - } } diff --git a/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapper.java b/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapper.java index b202dd453..a620c2d31 100644 --- a/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapper.java +++ b/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapper.java @@ -67,7 +67,7 @@ final class PersistentDataStoreWrapper implements DataStore { ) { this.core = core; - if (cacheTtl == null || cacheTtl.isZero()) { + if (cacheTtl.isZero()) { itemCache = null; allCache = null; initCache = null; @@ -331,15 +331,6 @@ public CacheStats getCacheStats() { itemStats.evictionCount() + allStats.evictionCount()); } - /** - * Return the underlying implementation object. - * - * @return the underlying implementation object - */ - public PersistentDataStore getCore() { - return core; - } - private ItemDescriptor getAndDeserializeItem(DataKind kind, String key) { SerializedItemDescriptor maybeSerializedItem = core.get(kind, key); return maybeSerializedItem == null ? null : deserialize(kind, maybeSerializedItem); @@ -438,7 +429,7 @@ private boolean pollAvailabilityAfterOutage() { return true; } - private static final class CacheKey { + static final class CacheKey { final DataKind kind; final String key; diff --git a/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java b/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java index 76fccaa00..c01c9bb2e 100644 --- a/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java @@ -33,8 +33,8 @@ final class PollingProcessor implements DataSource { private final ScheduledExecutorService scheduler; @VisibleForTesting final Duration pollInterval; private final AtomicBoolean initialized = new AtomicBoolean(false); + private final CompletableFuture initFuture; private volatile ScheduledFuture task; - private volatile CompletableFuture initFuture; PollingProcessor( FeatureRequestor requestor, @@ -46,6 +46,7 @@ final class PollingProcessor implements DataSource { this.dataSourceUpdates = dataSourceUpdates; this.scheduler = sharedExecutor; this.pollInterval = pollInterval; + this.initFuture = new CompletableFuture<>(); } @Override @@ -63,7 +64,7 @@ public void close() throws IOException { // environment where there isn't actually an LDClient. synchronized (this) { if (task != null) { - task.cancel(false); + task.cancel(true); task = null; } } @@ -75,11 +76,9 @@ public Future start() { + pollInterval.toMillis() + " milliseconds"); synchronized (this) { - if (initFuture != null) { - return initFuture; + if (task == null) { + task = scheduler.scheduleAtFixedRate(this::poll, 0L, pollInterval.toMillis(), TimeUnit.MILLISECONDS); } - initFuture = new CompletableFuture<>(); - task = scheduler.scheduleAtFixedRate(this::poll, 0L, pollInterval.toMillis(), TimeUnit.MILLISECONDS); } return initFuture; diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java index 02883af0b..6d20e64f7 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java @@ -68,6 +68,7 @@ final class FileDataSourceImpl implements DataSource { try { fw = FileWatcher.create(dataLoader.getFiles()); } catch (IOException e) { + // COVERAGE: there is no way to simulate this condition in a unit test logger.error("Unable to watch files for auto-updating: " + e); fw = null; } @@ -127,7 +128,7 @@ private static final class FileWatcher implements Runnable { private final WatchService watchService; private final Set watchedFilePaths; private Runnable fileModifiedAction; - private Thread thread; + private final Thread thread; private volatile boolean stopped; private static FileWatcher create(Iterable files) throws IOException { @@ -151,6 +152,9 @@ private static FileWatcher create(Iterable files) throws IOException { private FileWatcher(WatchService watchService, Set watchedFilePaths) { this.watchService = watchService; this.watchedFilePaths = watchedFilePaths; + + thread = new Thread(this, FileDataSourceImpl.class.getName()); + thread.setDaemon(true); } public void run() { @@ -175,6 +179,7 @@ public void run() { try { fileModifiedAction.run(); } catch (Exception e) { + // COVERAGE: there is no way to simulate this condition in a unit test logger.warn("Unexpected exception when reloading file data: " + e); } } @@ -187,16 +192,12 @@ public void run() { public void start(Runnable fileModifiedAction) { this.fileModifiedAction = fileModifiedAction; - thread = new Thread(this, FileDataSourceImpl.class.getName()); - thread.setDaemon(true); thread.start(); } public void stop() { stopped = true; - if (thread != null) { - thread.interrupt(); - } + thread.interrupt(); } } diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceParsing.java b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceParsing.java index 74d3d46e3..b0a6a76e0 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceParsing.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceParsing.java @@ -2,6 +2,7 @@ import com.google.gson.Gson; import com.google.gson.JsonElement; +import com.google.gson.JsonObject; import com.google.gson.JsonSyntaxException; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; @@ -21,6 +22,8 @@ import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; abstract class FileDataSourceParsing { + private 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 @@ -39,21 +42,13 @@ 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("]"); + s.append(" "); } + s.append("[").append(getCause().toString()).append("]"); if (filePath != null) { s.append(": ").append(filePath); } @@ -71,12 +66,6 @@ static final class FlagFileRep { Map segments; FlagFileRep() {} - - FlagFileRep(Map flags, Map flagValues, Map segments) { - this.flags = flags; - this.flagValues = flagValues; - this.segments = segments; - } } static abstract class FlagFileParser { @@ -105,6 +94,7 @@ private static boolean detectJson(Reader r) { return false; } } catch (IOException e) { + // COVERAGE: there is no way to simulate this condition in a unit test return false; } } @@ -127,6 +117,7 @@ public FlagFileRep parseJson(JsonElement tree) throws FileDataException, IOExcep try { return gson.fromJson(tree, FlagFileRep.class); } catch (JsonSyntaxException e) { + // COVERAGE: there is no way to simulate this condition in a unit test throw new FileDataException("cannot parse JSON", e); } } @@ -169,7 +160,7 @@ public FlagFileRep parse(InputStream input) throws FileDataException, IOExceptio } catch (YAMLException e) { throw new FileDataException("unable to parse YAML", e); } - JsonElement jsonRoot = gson.toJsonTree(root); + JsonElement jsonRoot = root == null ? new JsonObject() : gson.toJsonTree(root); return jsonFileParser.parseJson(jsonRoot); } } @@ -182,7 +173,9 @@ public FlagFileRep parse(InputStream input) throws FileDataException, IOExceptio * 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 { + static abstract class FlagFactory { + private FlagFactory() {} + static ItemDescriptor flagFromJson(String jsonString) { return FEATURES.deserialize(jsonString); } diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProvider.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProvider.java index fcb6b3230..3536692b9 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProvider.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProvider.java @@ -22,7 +22,7 @@ public interface DataStoreStatusProvider { * * @return the latest status; will never be null */ - public Status getStoreStatus(); + public Status getStatus(); /** * Indicates whether the current data store implementation supports status monitoring. diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypes.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypes.java index 7c9f9c800..a412c7c4f 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypes.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypes.java @@ -15,6 +15,8 @@ * @since 5.0.0 */ public abstract class DataStoreTypes { + private DataStoreTypes() {} + /** * Represents a separately namespaced collection of storable data items. *

    @@ -160,6 +162,11 @@ public boolean equals(Object o) { return false; } + @Override + public int hashCode() { + return Objects.hash(version, item); + } + @Override public String toString() { return "ItemDescriptor(" + version + "," + item + ")"; @@ -231,6 +238,11 @@ public boolean equals(Object o) { return false; } + @Override + public int hashCode() { + return Objects.hash(version, deleted, serializedItem); + } + @Override public String toString() { return "SerializedItemDescriptor(" + version + "," + deleted + "," + serializedItem + ")"; diff --git a/src/test/java/com/launchdarkly/sdk/server/DataModelDependenciesTest.java b/src/test/java/com/launchdarkly/sdk/server/DataModelDependenciesTest.java new file mode 100644 index 000000000..280d37227 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/DataModelDependenciesTest.java @@ -0,0 +1,363 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.base.Joiner; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Iterables; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.server.DataModel.FeatureFlag; +import com.launchdarkly.sdk.server.DataModel.Operator; +import com.launchdarkly.sdk.server.DataModel.Segment; +import com.launchdarkly.sdk.server.DataModelDependencies.DependencyTracker; +import com.launchdarkly.sdk.server.DataModelDependencies.KindAndKey; +import com.launchdarkly.sdk.server.DataStoreTestTypes.DataBuilder; +import com.launchdarkly.sdk.server.DataStoreTestTypes.TestItem; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; + +import org.junit.Test; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static com.google.common.collect.Iterables.transform; +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; +import static com.launchdarkly.sdk.server.DataStoreTestTypes.TEST_ITEMS; +import static com.launchdarkly.sdk.server.DataStoreTestTypes.toDataMap; +import static com.launchdarkly.sdk.server.ModelBuilders.clause; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.prerequisite; +import static com.launchdarkly.sdk.server.ModelBuilders.ruleBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.segmentBuilder; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.emptyIterable; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.iterableWithSize; +import static org.hamcrest.Matchers.not; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +@SuppressWarnings("javadoc") +public class DataModelDependenciesTest { + @Test + public void computeDependenciesFromFlag() { + FeatureFlag flag1 = flagBuilder("key").build(); + + assertThat( + DataModelDependencies.computeDependenciesFrom( + DataModel.FEATURES, + new ItemDescriptor(flag1.getVersion(), flag1) + ), + emptyIterable() + ); + + FeatureFlag flag2 = ModelBuilders.flagBuilder("key") + .prerequisites( + prerequisite("flag2", 0), + prerequisite("flag3", 0) + ) + .rules( + ruleBuilder() + .clauses( + clause(UserAttribute.KEY, Operator.in, LDValue.of("ignore")), + clause(null, Operator.segmentMatch, LDValue.of("segment1"), LDValue.of("segment2")) + ) + .build(), + ruleBuilder() + .clauses( + clause(null, Operator.segmentMatch, LDValue.of("segment3")) + ) + .build() + ) + .build(); + + assertThat( + DataModelDependencies.computeDependenciesFrom( + DataModel.FEATURES, + new ItemDescriptor(flag2.getVersion(), flag2) + ), + contains( + new KindAndKey(FEATURES, "flag2"), + new KindAndKey(FEATURES, "flag3"), + new KindAndKey(SEGMENTS, "segment1"), + new KindAndKey(SEGMENTS, "segment2"), + new KindAndKey(SEGMENTS, "segment3") + ) + ); + } + + @Test + public void computeDependenciesFromSegment() { + Segment segment = segmentBuilder("segment").build(); + + assertThat( + DataModelDependencies.computeDependenciesFrom( + DataModel.SEGMENTS, + new ItemDescriptor(segment.getVersion(), segment) + ), + emptyIterable() + ); + } + + @Test + public void computeDependenciesFromUnknownDataKind() { + assertThat( + DataModelDependencies.computeDependenciesFrom( + DataStoreTestTypes.TEST_ITEMS, + new ItemDescriptor(1, new DataStoreTestTypes.TestItem("x", 1)) + ), + emptyIterable() + ); + } + + @Test + public void computeDependenciesFromNullItem() { + assertThat( + DataModelDependencies.computeDependenciesFrom( + DataModel.FEATURES, + new ItemDescriptor(1, null) + ), + emptyIterable() + ); + + assertThat( + DataModelDependencies.computeDependenciesFrom( + DataModel.FEATURES, + null + ), + emptyIterable() + ); + } + + @Test + public void sortAllCollections() { + FullDataSet result = DataModelDependencies.sortAllCollections(DEPENDENCY_ORDERING_TEST_DATA); + verifySortedData(result, DEPENDENCY_ORDERING_TEST_DATA); + } + + @SuppressWarnings("unchecked") + @Test + public void sortAllCollectionsLeavesItemsOfUnknownDataKindUnchanged() { + TestItem extraItem1 = new TestItem("item1", 1); + TestItem extraItem2 = new TestItem("item2", 1); + FullDataSet inputData = new DataBuilder() + .addAny(FEATURES, + flagBuilder("a") + .prerequisites(prerequisite("b", 0), prerequisite("c", 0)).build(), + flagBuilder("b") + .prerequisites(prerequisite("c", 0)).build(), + flagBuilder("c").build()) + .addAny(SEGMENTS) + .addAny(TEST_ITEMS, extraItem1, extraItem2) + .build(); + + FullDataSet result = DataModelDependencies.sortAllCollections(inputData); + assertThat(result.getData(), iterableWithSize(3)); + + // the unknown data kind appears last, and the ordering of its items is unchanged + assertThat(transform(result.getData(), coll -> coll.getKey()), + contains(SEGMENTS, FEATURES, TEST_ITEMS)); + assertThat(Iterables.get(result.getData(), 2).getValue().getItems(), + contains(extraItem1.toKeyedItemDescriptor(), extraItem2.toKeyedItemDescriptor())); + } + + static void verifySortedData(FullDataSet sortedData, FullDataSet inputData) { + Map> dataMap = toDataMap(sortedData); + assertEquals(2, dataMap.size()); + Map> inputDataMap = toDataMap(inputData); + + // Segments should always come first + assertEquals(SEGMENTS, Iterables.get(dataMap.keySet(), 0)); + assertEquals(inputDataMap.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(transform(map1.values(), d -> (DataModel.FeatureFlag)d.getItem())); + assertEquals(inputDataMap.get(FEATURES).size(), map1.size()); + for (int itemIndex = 0; itemIndex < list1.size(); itemIndex++) { + DataModel.FeatureFlag item = list1.get(itemIndex); + for (DataModel.Prerequisite prereq: item.getPrerequisites()) { + DataModel.FeatureFlag depFlag = (DataModel.FeatureFlag)map1.get(prereq.getKey()).getItem(); + int depIndex = list1.indexOf(depFlag); + if (depIndex > itemIndex) { + 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(map1.keySet()))); + } + } + } + } + + @Test + public void dependencyTrackerReturnsSingleValueResultForUnknownItem() { + DependencyTracker dt = new DependencyTracker(); + + // a change to any item with no known depenencies affects only itself + verifyAffectedItems(dt, FEATURES, "flag1", + new KindAndKey(FEATURES, "flag1")); + } + + @Test + public void dependencyTrackerBuildsGraph() { + DependencyTracker dt = new DependencyTracker(); + + FeatureFlag flag1 = ModelBuilders.flagBuilder("flag1") + .prerequisites( + prerequisite("flag2", 0), + prerequisite("flag3", 0) + ) + .rules( + ruleBuilder() + .clauses( + clause(null, Operator.segmentMatch, LDValue.of("segment1"), LDValue.of("segment2")) + ) + .build() + ) + .build(); + dt.updateDependenciesFrom(FEATURES, flag1.getKey(), new ItemDescriptor(flag1.getVersion(), flag1)); + + FeatureFlag flag2 = ModelBuilders.flagBuilder("flag2") + .prerequisites( + prerequisite("flag4", 0) + ) + .rules( + ruleBuilder() + .clauses( + clause(null, Operator.segmentMatch, LDValue.of("segment2")) + ) + .build() + ) + .build(); + + dt.updateDependenciesFrom(FEATURES, flag2.getKey(), new ItemDescriptor(flag2.getVersion(), flag2)); + + // a change to flag1 affects only flag1 + verifyAffectedItems(dt, FEATURES, "flag1", + new KindAndKey(FEATURES, "flag1")); + + // a change to flag2 affects flag2 and flag1 + verifyAffectedItems(dt, FEATURES, "flag2", + new KindAndKey(FEATURES, "flag2"), + new KindAndKey(FEATURES, "flag1")); + + // a change to flag3 affects flag3 and flag1 + verifyAffectedItems(dt, FEATURES, "flag3", + new KindAndKey(FEATURES, "flag3"), + new KindAndKey(FEATURES, "flag1")); + + // a change to segment1 affects segment1 and flag1 + verifyAffectedItems(dt, SEGMENTS, "segment1", + new KindAndKey(SEGMENTS, "segment1"), + new KindAndKey(FEATURES, "flag1")); + + // a change to segment2 affects segment2, flag1, and flag2 + verifyAffectedItems(dt, SEGMENTS, "segment2", + new KindAndKey(SEGMENTS, "segment2"), + new KindAndKey(FEATURES, "flag1"), + new KindAndKey(FEATURES, "flag2")); + } + + @Test + public void dependencyTrackerUpdatesGraph() { + DependencyTracker dt = new DependencyTracker(); + + FeatureFlag flag1 = ModelBuilders.flagBuilder("flag1") + .prerequisites(prerequisite("flag3", 0)) + .build(); + dt.updateDependenciesFrom(FEATURES, flag1.getKey(), new ItemDescriptor(flag1.getVersion(), flag1)); + + FeatureFlag flag2 = ModelBuilders.flagBuilder("flag2") + .prerequisites(prerequisite("flag3", 0)) + .build(); + dt.updateDependenciesFrom(FEATURES, flag2.getKey(), new ItemDescriptor(flag2.getVersion(), flag2)); + + // at this point, a change to flag3 affects flag3, flag2, and flag1 + verifyAffectedItems(dt, FEATURES, "flag3", + new KindAndKey(FEATURES, "flag3"), + new KindAndKey(FEATURES, "flag2"), + new KindAndKey(FEATURES, "flag1")); + + // now make it so flag1 now depends on flag4 instead of flag2 + FeatureFlag flag1v2 = ModelBuilders.flagBuilder("flag1") + .prerequisites(prerequisite("flag4", 0)) + .build(); + dt.updateDependenciesFrom(FEATURES, flag1.getKey(), new ItemDescriptor(flag1v2.getVersion(), flag1v2)); + + // now, a change to flag3 affects flag3 and flag2 + verifyAffectedItems(dt, FEATURES, "flag3", + new KindAndKey(FEATURES, "flag3"), + new KindAndKey(FEATURES, "flag2")); + + // and a change to flag4 affects flag4 and flag1 + verifyAffectedItems(dt, FEATURES, "flag4", + new KindAndKey(FEATURES, "flag4"), + new KindAndKey(FEATURES, "flag1")); + } + + @Test + public void dependencyTrackerResetsGraph() { + DependencyTracker dt = new DependencyTracker(); + + FeatureFlag flag1 = ModelBuilders.flagBuilder("flag1") + .prerequisites(prerequisite("flag3", 0)) + .build(); + dt.updateDependenciesFrom(FEATURES, flag1.getKey(), new ItemDescriptor(flag1.getVersion(), flag1)); + + verifyAffectedItems(dt, FEATURES, "flag3", + new KindAndKey(FEATURES, "flag3"), + new KindAndKey(FEATURES, "flag1")); + + dt.reset(); + + verifyAffectedItems(dt, FEATURES, "flag3", + new KindAndKey(FEATURES, "flag3")); + } + + private void verifyAffectedItems(DependencyTracker dt, DataKind kind, String key, KindAndKey... expected) { + Set result = new HashSet<>(); + dt.addAffectedItems(result, new KindAndKey(kind, key)); + assertThat(result, equalTo(ImmutableSet.copyOf(expected))); + } + + @Test + public void kindAndKeyEquality() { + KindAndKey kk1 = new KindAndKey(FEATURES, "key1"); + KindAndKey kk2 = new KindAndKey(FEATURES, "key1"); + assertThat(kk1, equalTo(kk2)); + assertThat(kk2, equalTo(kk1)); + assertThat(kk1.hashCode(), equalTo(kk2.hashCode())); + + KindAndKey kk3 = new KindAndKey(FEATURES, "key2"); + assertThat(kk3, not(equalTo(kk1))); + assertThat(kk1, not(equalTo(kk3))); + + KindAndKey kk4 = new KindAndKey(SEGMENTS, "key1"); + assertThat(kk4, not(equalTo(kk1))); + assertThat(kk1, not(equalTo(kk4))); + + assertThat(kk1, not(equalTo(null))); + assertThat(kk1, not(equalTo("x"))); + } + + static FullDataSet DEPENDENCY_ORDERING_TEST_DATA = + new DataBuilder() + .addAny(FEATURES, + flagBuilder("a") + .prerequisites(prerequisite("b", 0), prerequisite("c", 0)).build(), + flagBuilder("b") + .prerequisites(prerequisite("c", 0), prerequisite("e", 0)).build(), + flagBuilder("c").build(), + flagBuilder("d").build(), + flagBuilder("e").build(), + flagBuilder("f").build()) + .addAny(SEGMENTS, + segmentBuilder("o").build()) + .build(); +} diff --git a/src/test/java/com/launchdarkly/sdk/server/DataSourceStatusProviderImplTest.java b/src/test/java/com/launchdarkly/sdk/server/DataSourceStatusProviderImplTest.java new file mode 100644 index 000000000..39f912972 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/DataSourceStatusProviderImplTest.java @@ -0,0 +1,112 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorInfo; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.State; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.Status; + +import org.junit.Test; + +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; + +import static com.launchdarkly.sdk.server.TestComponents.sharedExecutor; +import static com.launchdarkly.sdk.server.TestUtil.expectNoMoreValues; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.hamcrest.Matchers.lessThan; +import static org.hamcrest.Matchers.sameInstance; + +@SuppressWarnings("javadoc") +public class DataSourceStatusProviderImplTest { + private EventBroadcasterImpl broadcaster = + EventBroadcasterImpl.forDataSourceStatus(sharedExecutor); + private DataSourceUpdatesImpl updates = new DataSourceUpdatesImpl( + TestComponents.inMemoryDataStore(), + null, + null, + broadcaster, + sharedExecutor, + null + ); + private DataSourceStatusProviderImpl statusProvider = new DataSourceStatusProviderImpl(broadcaster, updates); + + @Test + public void getStatus() throws Exception { + assertThat(statusProvider.getStatus().getState(), equalTo(State.INITIALIZING)); + + Instant timeBefore = Instant.now(); + ErrorInfo errorInfo = ErrorInfo.fromHttpError(500); + + updates.updateStatus(State.VALID, errorInfo); + + Status newStatus = statusProvider.getStatus(); + assertThat(newStatus.getState(), equalTo(State.VALID)); + assertThat(newStatus.getStateSince(), greaterThanOrEqualTo(timeBefore)); + assertThat(newStatus.getLastError(), sameInstance(errorInfo)); + } + + @Test + public void statusListeners() throws Exception { + BlockingQueue statuses = new LinkedBlockingQueue<>(); + statusProvider.addStatusListener(statuses::add); + + BlockingQueue unwantedStatuses = new LinkedBlockingQueue<>(); + DataSourceStatusProvider.StatusListener listener2 = unwantedStatuses::add; + statusProvider.addStatusListener(listener2); + statusProvider.removeStatusListener(listener2); // testing that a listener can be unregistered + + updates.updateStatus(State.VALID, null); + + Status newStatus = TestUtil.awaitValue(statuses, Duration.ofMillis(200)); + assertThat(newStatus.getState(), equalTo(State.VALID)); + + expectNoMoreValues(unwantedStatuses, Duration.ofMillis(100)); + } + + @Test + public void waitForStatusWithStatusAlreadyCorrect() throws Exception { + updates.updateStatus(State.VALID, null); + + boolean success = statusProvider.waitFor(State.VALID, Duration.ofMillis(500)); + assertThat(success, equalTo(true)); + } + + @Test + public void waitForStatusSucceeds() throws Exception { + new Thread(() -> { + try { + Thread.sleep(100); + } catch (InterruptedException e) {} + updates.updateStatus(State.VALID, null); + }).start(); + + boolean success = statusProvider.waitFor(State.VALID, Duration.ZERO); + assertThat(success, equalTo(true)); + } + + @Test + public void waitForStatusTimesOut() throws Exception { + long timeStart = System.currentTimeMillis(); + boolean success = statusProvider.waitFor(State.VALID, Duration.ofMillis(300)); + long timeEnd = System.currentTimeMillis(); + assertThat(success, equalTo(false)); + assertThat(timeEnd - timeStart, greaterThanOrEqualTo(270L)); + } + + @Test + public void waitForStatusEndsIfShutDown() throws Exception { + new Thread(() -> { + updates.updateStatus(State.OFF, null); + }).start(); + + long timeStart = System.currentTimeMillis(); + boolean success = statusProvider.waitFor(State.VALID, Duration.ofMillis(500)); + long timeEnd = System.currentTimeMillis(); + assertThat(success, equalTo(false)); + assertThat(timeEnd - timeStart, lessThan(500L)); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/DataSourceUpdatesImplTest.java b/src/test/java/com/launchdarkly/sdk/server/DataSourceUpdatesImplTest.java index 3474e112d..226026463 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataSourceUpdatesImplTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataSourceUpdatesImplTest.java @@ -1,13 +1,14 @@ package com.launchdarkly.sdk.server; -import com.google.common.base.Joiner; -import com.google.common.collect.ImmutableList; -import com.google.common.collect.Iterables; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.server.DataModel.Operator; import com.launchdarkly.sdk.server.DataStoreTestTypes.DataBuilder; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorInfo; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorKind; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.State; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.Status; import com.launchdarkly.sdk.server.interfaces.DataStore; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; @@ -18,25 +19,28 @@ import org.easymock.EasyMockSupport; import org.junit.Test; -import java.util.List; -import java.util.Map; +import java.io.IOException; +import java.time.Duration; +import java.time.Instant; import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; -import static com.google.common.collect.Iterables.transform; import static com.launchdarkly.sdk.server.DataModel.FEATURES; import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; -import static com.launchdarkly.sdk.server.DataStoreTestTypes.toDataMap; import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; import static com.launchdarkly.sdk.server.ModelBuilders.prerequisite; import static com.launchdarkly.sdk.server.ModelBuilders.ruleBuilder; import static com.launchdarkly.sdk.server.ModelBuilders.segmentBuilder; import static com.launchdarkly.sdk.server.TestComponents.inMemoryDataStore; import static com.launchdarkly.sdk.server.TestComponents.sharedExecutor; +import static com.launchdarkly.sdk.server.TestUtil.awaitValue; import static com.launchdarkly.sdk.server.TestUtil.expectEvents; +import static com.launchdarkly.sdk.server.TestUtil.expectNoMoreValues; import static org.easymock.EasyMock.replay; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.fail; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.hamcrest.Matchers.is; @SuppressWarnings("javadoc") public class DataSourceUpdatesImplTest extends EasyMockSupport { @@ -47,7 +51,14 @@ public class DataSourceUpdatesImplTest extends EasyMockSupport { EventBroadcasterImpl.forFlagChangeEvents(TestComponents.sharedExecutor); private DataSourceUpdatesImpl makeInstance(DataStore store) { - return new DataSourceUpdatesImpl(store, null, flagChangeBroadcaster, null, sharedExecutor, null); + return makeInstance(store, null); + } + + private DataSourceUpdatesImpl makeInstance( + DataStore store, + EventBroadcasterImpl statusBroadcaster + ) { + return new DataSourceUpdatesImpl(store, null, flagChangeBroadcaster, statusBroadcaster, sharedExecutor, null); } @Test @@ -142,6 +153,26 @@ public void sendsEventOnUpdateForUpdatedFlag() throws Exception { expectEvents(eventSink, "flag2"); } + + @Test + public void doesNotSendsEventOnUpdateIfItemWasNotReallyUpdated() throws Exception { + DataStore store = inMemoryDataStore(); + DataModel.FeatureFlag flag1 = flagBuilder("flag1").version(1).build(); + DataModel.FeatureFlag flag2 = flagBuilder("flag2").version(1).build(); + DataBuilder builder = new DataBuilder() + .addAny(FEATURES, flag1, flag2); + + DataSourceUpdatesImpl storeUpdates = makeInstance(store); + + storeUpdates.init(builder.build()); + + BlockingQueue eventSink = new LinkedBlockingQueue<>(); + flagChangeBroadcaster.register(eventSink::add); + + storeUpdates.upsert(FEATURES, flag2.getKey(), new ItemDescriptor(flag2.getVersion(), flag2)); + + expectNoMoreValues(eventSink, Duration.ofMillis(100)); + } @Test public void sendsEventsOnInitForDeletedFlags() throws Exception { @@ -301,57 +332,111 @@ public void sendsEventsOnUpdateForFlagsWhoseSegmentsChanged() throws Exception { @Test public void dataSetIsPassedToDataStoreInCorrectOrder() throws Exception { - // This verifies that the client is using DataStoreClientWrapper and that it is applying the - // correct ordering for flag prerequisites, etc. This should work regardless of what kind of - // DataSource we're using. - + // The logic for this is already tested in DataModelDependenciesTest, but here we are verifying + // that DataSourceUpdatesImpl is actually using DataModelDependencies. Capture> captureData = Capture.newInstance(); DataStore store = createStrictMock(DataStore.class); store.init(EasyMock.capture(captureData)); replay(store); DataSourceUpdatesImpl storeUpdates = makeInstance(store); - storeUpdates.init(DEPENDENCY_ORDERING_TEST_DATA); - - Map> dataMap = toDataMap(captureData.getValue()); - assertEquals(2, dataMap.size()); - Map> inputDataMap = toDataMap(DEPENDENCY_ORDERING_TEST_DATA); - - // Segments should always come first - assertEquals(SEGMENTS, Iterables.get(dataMap.keySet(), 0)); - assertEquals(inputDataMap.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(transform(map1.values(), d -> (DataModel.FeatureFlag)d.getItem())); - assertEquals(inputDataMap.get(FEATURES).size(), map1.size()); - for (int itemIndex = 0; itemIndex < list1.size(); itemIndex++) { - DataModel.FeatureFlag item = list1.get(itemIndex); - for (DataModel.Prerequisite prereq: item.getPrerequisites()) { - DataModel.FeatureFlag depFlag = (DataModel.FeatureFlag)map1.get(prereq.getKey()).getItem(); - int depIndex = list1.indexOf(depFlag); - if (depIndex > itemIndex) { - 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(map1.keySet()))); - } - } - } + storeUpdates.init(DataModelDependenciesTest.DEPENDENCY_ORDERING_TEST_DATA); + + DataModelDependenciesTest.verifySortedData(captureData.getValue(), + DataModelDependenciesTest.DEPENDENCY_ORDERING_TEST_DATA); + } - private static FullDataSet DEPENDENCY_ORDERING_TEST_DATA = - new DataBuilder() - .addAny(FEATURES, - flagBuilder("a") - .prerequisites(prerequisite("b", 0), prerequisite("c", 0)).build(), - flagBuilder("b") - .prerequisites(prerequisite("c", 0), prerequisite("e", 0)).build(), - flagBuilder("c").build(), - flagBuilder("d").build(), - flagBuilder("e").build(), - flagBuilder("f").build()) - .addAny(SEGMENTS, - segmentBuilder("o").build()) - .build(); + @Test + public void updateStatusBroadcastsNewStatus() { + EventBroadcasterImpl broadcaster = + EventBroadcasterImpl.forDataSourceStatus(sharedExecutor); + DataSourceUpdatesImpl updates = makeInstance(inMemoryDataStore(), broadcaster); + + BlockingQueue statuses = new LinkedBlockingQueue<>(); + broadcaster.register(statuses::add); + + Instant timeBeforeUpdate = Instant.now(); + ErrorInfo errorInfo = ErrorInfo.fromHttpError(401); + updates.updateStatus(State.OFF, errorInfo); + + Status status = awaitValue(statuses, Duration.ofMillis(500)); + + assertThat(status.getState(), is(State.OFF)); + assertThat(status.getStateSince(), greaterThanOrEqualTo(timeBeforeUpdate)); + assertThat(status.getLastError(), is(errorInfo)); + } + + @Test + public void updateStatusKeepsStateUnchangedIfStateWasInitializingAndNewStateIsInterrupted() { + EventBroadcasterImpl broadcaster = + EventBroadcasterImpl.forDataSourceStatus(sharedExecutor); + DataSourceUpdatesImpl updates = makeInstance(inMemoryDataStore(), broadcaster); + + assertThat(updates.getLastStatus().getState(), is(State.INITIALIZING)); + Instant originalTime = updates.getLastStatus().getStateSince(); + + BlockingQueue statuses = new LinkedBlockingQueue<>(); + broadcaster.register(statuses::add); + + ErrorInfo errorInfo = ErrorInfo.fromHttpError(401); + updates.updateStatus(State.INTERRUPTED, errorInfo); + + Status status = awaitValue(statuses, Duration.ofMillis(500)); + + assertThat(status.getState(), is(State.INITIALIZING)); + assertThat(status.getStateSince(), is(originalTime)); + assertThat(status.getLastError(), is(errorInfo)); + } + + @Test + public void updateStatusDoesNothingIfParametersHaveNoNewData() { + EventBroadcasterImpl broadcaster = + EventBroadcasterImpl.forDataSourceStatus(sharedExecutor); + DataSourceUpdatesImpl updates = makeInstance(inMemoryDataStore(), broadcaster); + + BlockingQueue statuses = new LinkedBlockingQueue<>(); + broadcaster.register(statuses::add); + + updates.updateStatus(null, null); + updates.updateStatus(State.INITIALIZING, null); + + TestUtil.expectNoMoreValues(statuses, Duration.ofMillis(100)); + } + + @Test + public void outageTimeoutLogging() throws Exception { + BlockingQueue outageErrors = new LinkedBlockingQueue<>(); + Duration outageTimeout = Duration.ofMillis(100); + + DataSourceUpdatesImpl updates = new DataSourceUpdatesImpl( + inMemoryDataStore(), + null, + flagChangeBroadcaster, + EventBroadcasterImpl.forDataSourceStatus(sharedExecutor), + sharedExecutor, + outageTimeout + ); + updates.onOutageErrorLog = outageErrors::add; + + // simulate an outage + updates.updateStatus(State.INTERRUPTED, ErrorInfo.fromHttpError(500)); + + // but recover from it immediately + updates.updateStatus(State.VALID, null); + + // wait till the timeout would have elapsed - no special message should be logged + expectNoMoreValues(outageErrors, outageTimeout.plus(Duration.ofMillis(20))); + + // simulate another outage + updates.updateStatus(State.INTERRUPTED, ErrorInfo.fromHttpError(501)); + updates.updateStatus(State.INTERRUPTED, ErrorInfo.fromHttpError(502)); + updates.updateStatus(State.INTERRUPTED, ErrorInfo.fromException(ErrorKind.NETWORK_ERROR, new IOException("x"))); + updates.updateStatus(State.INTERRUPTED, ErrorInfo.fromHttpError(501)); + + String errorsDesc = awaitValue(outageErrors, Duration.ofMillis(250)); // timing is approximate + assertThat(errorsDesc, containsString("NETWORK_ERROR (1 time)")); + assertThat(errorsDesc, containsString("ERROR_RESPONSE(501) (2 times)")); + assertThat(errorsDesc, containsString("ERROR_RESPONSE(502) (1 time)")); + } } diff --git a/src/test/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImplTest.java b/src/test/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImplTest.java new file mode 100644 index 000000000..b7b248951 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImplTest.java @@ -0,0 +1,123 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider.CacheStats; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider.Status; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; + +import org.junit.Test; + +import java.io.IOException; +import java.time.Duration; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; + +import static com.launchdarkly.sdk.server.TestComponents.sharedExecutor; +import static com.launchdarkly.sdk.server.TestUtil.expectNoMoreValues; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.nullValue; + +@SuppressWarnings("javadoc") +public class DataStoreStatusProviderImplTest { + private EventBroadcasterImpl broadcaster = + EventBroadcasterImpl.forDataStoreStatus(sharedExecutor); + private MockDataStore store = new MockDataStore(); + private DataStoreUpdatesImpl updates = new DataStoreUpdatesImpl(broadcaster); + private DataStoreStatusProviderImpl statusProvider = new DataStoreStatusProviderImpl(store, updates); + + @Test + public void getStatus() throws Exception { + assertThat(statusProvider.getStatus(), equalTo(new Status(true, false))); + + updates.updateStatus(new Status(false, false)); + + assertThat(statusProvider.getStatus(), equalTo(new Status(false, false))); + + updates.updateStatus(new Status(false, true)); + + assertThat(statusProvider.getStatus(), equalTo(new Status(false, true))); + } + + @Test + public void statusListeners() throws Exception { + BlockingQueue statuses = new LinkedBlockingQueue<>(); + statusProvider.addStatusListener(statuses::add); + + BlockingQueue unwantedStatuses = new LinkedBlockingQueue<>(); + DataStoreStatusProvider.StatusListener listener2 = unwantedStatuses::add; + statusProvider.addStatusListener(listener2); + statusProvider.removeStatusListener(listener2); // testing that a listener can be unregistered + + updates.updateStatus(new Status(false, false)); + + Status newStatus = TestUtil.awaitValue(statuses, Duration.ofMillis(200)); + assertThat(newStatus, equalTo(new Status(false, false))); + + expectNoMoreValues(unwantedStatuses, Duration.ofMillis(100)); + } + + @Test + public void isStatusMonitoringEnabled() { + assertThat(statusProvider.isStatusMonitoringEnabled(), equalTo(false)); + + store.statusMonitoringEnabled = true; + + assertThat(statusProvider.isStatusMonitoringEnabled(), equalTo(true)); + } + + @Test + public void cacheStats() { + assertThat(statusProvider.getCacheStats(), nullValue()); + + CacheStats stats = new CacheStats(0, 0, 0, 0, 0, 0); + store.cacheStats = stats; + + assertThat(statusProvider.getCacheStats(), equalTo(stats)); + } + + private static final class MockDataStore implements DataStore { + volatile boolean statusMonitoringEnabled; + volatile CacheStats cacheStats; + + @Override + public void close() throws IOException {} + + @Override + public void init(FullDataSet allData) {} + + @Override + public ItemDescriptor get(DataKind kind, String key) { + return null; + } + + @Override + public KeyedItems getAll(DataKind kind) { + return null; + } + + @Override + public boolean upsert(DataKind kind, String key, ItemDescriptor item) { + return false; + } + + @Override + public boolean isInitialized() { + return false; + } + + @Override + public boolean isStatusMonitoringEnabled() { + return statusMonitoringEnabled; + } + + @Override + public CacheStats getCacheStats() { + return cacheStats; + } + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/DataStoreTestTypes.java b/src/test/java/com/launchdarkly/sdk/server/DataStoreTestTypes.java index e69eeb77d..1a4d4c6c2 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataStoreTestTypes.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataStoreTestTypes.java @@ -1,7 +1,7 @@ package com.launchdarkly.sdk.server; -import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Iterables; import com.google.common.collect.Maps; import com.launchdarkly.sdk.server.DataModel.VersionedData; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; @@ -11,7 +11,9 @@ import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.SerializedItemDescriptor; import java.util.AbstractMap; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Objects; @@ -79,6 +81,10 @@ public TestItem withVersion(int newVersion) { public ItemDescriptor toItemDescriptor() { return new ItemDescriptor(version, this); } + + public Map.Entry toKeyedItemDescriptor() { + return new AbstractMap.SimpleEntry<>(key, toItemDescriptor()); + } public SerializedItemDescriptor toSerializedItemDescriptor() { return toSerialized(TEST_ITEMS, toItemDescriptor()); @@ -130,7 +136,7 @@ private static ItemDescriptor deserializeTestItem(String s) { } public static class DataBuilder { - private Map> data = new HashMap<>(); + private Map>> data = new HashMap<>(); public DataBuilder add(DataKind kind, TestItem... items) { return addAny(kind, items); @@ -138,20 +144,21 @@ public DataBuilder add(DataKind kind, TestItem... items) { // This is defined separately because test code that's outside of this package can't see DataModel.VersionedData public DataBuilder addAny(DataKind kind, VersionedData... items) { - Map itemsMap = data.get(kind); - if (itemsMap == null) { - itemsMap = new HashMap<>(); - data.put(kind, itemsMap); + List> itemsList = data.get(kind); + if (itemsList == null) { + itemsList = new ArrayList<>(); + data.put(kind, itemsList); } for (VersionedData item: items) { - itemsMap.put(item.getKey(), new ItemDescriptor(item.getVersion(), item)); + itemsList.removeIf(e -> e.getKey().equals(item.getKey())); + itemsList.add(new AbstractMap.SimpleEntry<>(item.getKey(), new ItemDescriptor(item.getVersion(), item))); } return this; } public DataBuilder remove(DataKind kind, String key) { if (data.get(kind) != null) { - data.get(kind).remove(key); + data.get(kind).removeIf(e -> e.getKey().equals(key)); } return this; } @@ -159,22 +166,18 @@ public DataBuilder remove(DataKind kind, String key) { public FullDataSet build() { return new FullDataSet<>( ImmutableMap.copyOf( - Maps.transformValues(data, itemsMap -> - new KeyedItems<>(ImmutableList.copyOf(itemsMap.entrySet())) - )).entrySet() + Maps.transformValues(data, itemsList -> new KeyedItems<>(itemsList))).entrySet() ); } public FullDataSet buildSerialized() { return new FullDataSet<>( ImmutableMap.copyOf( - Maps.transformEntries(data, (kind, itemsMap) -> + Maps.transformEntries(data, (kind, itemsList) -> new KeyedItems<>( - ImmutableMap.copyOf( - Maps.transformValues(itemsMap, item -> DataStoreTestTypes.toSerialized(kind, item)) - ).entrySet() - ) - ) + Iterables.transform(itemsList, e -> + new AbstractMap.SimpleEntry<>(e.getKey(), DataStoreTestTypes.toSerialized(kind, e.getValue()))) + )) ).entrySet()); } } diff --git a/src/test/java/com/launchdarkly/sdk/server/DataStoreUpdatesImplTest.java b/src/test/java/com/launchdarkly/sdk/server/DataStoreUpdatesImplTest.java new file mode 100644 index 000000000..6afee009b --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/DataStoreUpdatesImplTest.java @@ -0,0 +1,55 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider.Status; + +import org.junit.Test; + +import java.time.Duration; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; + +import static com.launchdarkly.sdk.server.TestComponents.sharedExecutor; +import static com.launchdarkly.sdk.server.TestUtil.expectNoMoreValues; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +@SuppressWarnings("javadoc") +public class DataStoreUpdatesImplTest { + private EventBroadcasterImpl broadcaster = + EventBroadcasterImpl.forDataStoreStatus(sharedExecutor); + private final DataStoreUpdatesImpl updates = new DataStoreUpdatesImpl(broadcaster); + + @Test + public void updateStatusBroadcastsNewStatus() { + BlockingQueue statuses = new LinkedBlockingQueue<>(); + broadcaster.register(statuses::add); + + updates.updateStatus(new Status(false, false)); + + Status newStatus = TestUtil.awaitValue(statuses, Duration.ofMillis(200)); + assertThat(newStatus, equalTo(new Status(false, false))); + + expectNoMoreValues(statuses, Duration.ofMillis(100)); + } + + @Test + public void updateStatusDoesNothingIfNewStatusIsSame() { + BlockingQueue statuses = new LinkedBlockingQueue<>(); + broadcaster.register(statuses::add); + + updates.updateStatus(new Status(true, false)); + + expectNoMoreValues(statuses, Duration.ofMillis(100)); + } + + @Test + public void updateStatusDoesNothingIfNewStatusIsNull() { + BlockingQueue statuses = new LinkedBlockingQueue<>(); + broadcaster.register(statuses::add); + + updates.updateStatus(null); + + expectNoMoreValues(statuses, Duration.ofMillis(100)); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java b/src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java index 0b713915b..cc0407ae4 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java @@ -5,8 +5,12 @@ import com.google.gson.JsonObject; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.ObjectBuilder; +import com.launchdarkly.sdk.server.integrations.PollingDataSourceBuilder; import com.launchdarkly.sdk.server.interfaces.BasicConfiguration; import com.launchdarkly.sdk.server.interfaces.ClientContext; +import com.launchdarkly.sdk.server.interfaces.DataSource; +import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; +import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; @@ -23,6 +27,9 @@ import java.util.UUID; import static com.launchdarkly.sdk.server.TestComponents.clientContext; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.not; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; @@ -31,7 +38,8 @@ public class DiagnosticEventTest { private static Gson gson = new Gson(); private static List testStreamInits = Collections.singletonList(new DiagnosticEvent.StreamInit(1500, 100, true)); - + private static final URI CUSTOM_URI = URI.create("http://1.1.1.1"); + @Test public void testSerialization() { DiagnosticId diagnosticId = new DiagnosticId("SDK_KEY"); @@ -115,62 +123,139 @@ public void testCustomDiagnosticConfigurationGeneralProperties() { @Test public void testCustomDiagnosticConfigurationForStreaming() { - LDConfig ldConfig = new LDConfig.Builder() + LDConfig ldConfig1 = new LDConfig.Builder() .dataSource( Components.streamingDataSource() - .baseURI(URI.create("https://1.1.1.1")) - .pollingBaseURI(URI.create("https://1.1.1.1")) + .baseURI(CUSTOM_URI) + .pollingBaseURI(CUSTOM_URI) .initialReconnectDelay(Duration.ofSeconds(2)) ) .build(); - - LDValue diagnosticJson = makeConfigData(ldConfig); - LDValue expected = expectedDefaultPropertiesWithoutStreaming() + LDValue expected1 = expectedDefaultPropertiesWithoutStreaming() .put("customBaseURI", true) .put("customStreamURI", true) .put("reconnectTimeMillis", 2_000) .build(); + assertEquals(expected1, makeConfigData(ldConfig1)); + + LDConfig ldConfig2 = new LDConfig.Builder() + .dataSource(Components.streamingDataSource()) // no custom base URIs + .build(); + LDValue expected2 = expectedDefaultProperties().build(); + assertEquals(expected2, makeConfigData(ldConfig2)); - assertEquals(expected, diagnosticJson); + LDConfig ldConfig3 = new LDConfig.Builder() + .dataSource(Components.streamingDataSource().baseURI(LDConfig.DEFAULT_STREAM_URI)) // set a URI, but not a custom one + .build(); + LDValue expected3 = expectedDefaultProperties().build(); + assertEquals(expected3, makeConfigData(ldConfig3)); + + LDConfig ldConfig4 = new LDConfig.Builder() + .dataSource(Components.streamingDataSource().pollingBaseURI(CUSTOM_URI)) + .build(); + LDValue expected4 = expectedDefaultProperties() + .put("customBaseURI", true) + .build(); + assertEquals(expected4, makeConfigData(ldConfig4)); + + LDConfig ldConfig5 = new LDConfig.Builder() + .dataSource(Components.streamingDataSource().pollingBaseURI(LDConfig.DEFAULT_BASE_URI)) + .build(); + LDValue expected5 = expectedDefaultProperties().build(); + assertEquals(expected5, makeConfigData(ldConfig5)); + + LDConfig ldConfig6 = new LDConfig.Builder() + .dataSource(Components.streamingDataSource().baseURI(CUSTOM_URI)) + .build(); + LDValue expected6 = expectedDefaultProperties() + .put("customBaseURI", true) + .put("customStreamURI", true) + .build(); + assertEquals(expected6, makeConfigData(ldConfig6)); } @Test public void testCustomDiagnosticConfigurationForPolling() { - LDConfig ldConfig = new LDConfig.Builder() + LDConfig ldConfig1 = new LDConfig.Builder() .dataSource( Components.pollingDataSource() - .baseURI(URI.create("https://1.1.1.1")) + .baseURI(CUSTOM_URI) .pollInterval(Duration.ofSeconds(60)) ) .build(); - - LDValue diagnosticJson = makeConfigData(ldConfig); - LDValue expected = expectedDefaultPropertiesWithoutStreaming() + LDValue expected1 = expectedDefaultPropertiesWithoutStreaming() .put("customBaseURI", true) .put("pollingIntervalMillis", 60_000) .put("streamingDisabled", true) .build(); + assertEquals(expected1, makeConfigData(ldConfig1)); - assertEquals(expected, diagnosticJson); + LDConfig ldConfig2 = new LDConfig.Builder() + .dataSource(Components.pollingDataSource()) // no custom base URI + .build(); + LDValue expected2 = expectedDefaultPropertiesWithoutStreaming() + .put("pollingIntervalMillis", PollingDataSourceBuilder.DEFAULT_POLL_INTERVAL.toMillis()) + .put("streamingDisabled", true) + .build(); + assertEquals(expected2, makeConfigData(ldConfig2)); + + LDConfig ldConfig3 = new LDConfig.Builder() + .dataSource(Components.pollingDataSource().baseURI(LDConfig.DEFAULT_BASE_URI)) // set a URI, but not a custom one + .build(); + assertEquals(expected2, makeConfigData(ldConfig3)); // result is same as previous test case + } + + @Test + public void testCustomDiagnosticConfigurationForCustomDataStore() { + LDConfig ldConfig1 = new LDConfig.Builder() + .dataStore(new DataStoreFactoryWithDiagnosticDescription(LDValue.of("my-test-store"))) + .build(); + LDValue expected1 = expectedDefaultProperties().put("dataStoreType", "my-test-store").build(); + assertEquals(expected1, makeConfigData(ldConfig1)); + + LDConfig ldConfig2 = new LDConfig.Builder() + .dataStore(new DataStoreFactoryWithoutDiagnosticDescription()) + .build(); + LDValue expected2 = expectedDefaultProperties().put("dataStoreType", "custom").build(); + assertEquals(expected2, makeConfigData(ldConfig2)); + + LDConfig ldConfig3 = new LDConfig.Builder() + .dataStore(new DataStoreFactoryWithDiagnosticDescription(null)) + .build(); + LDValue expected3 = expectedDefaultProperties().put("dataStoreType", "custom").build(); + assertEquals(expected3, makeConfigData(ldConfig3)); + + LDConfig ldConfig4 = new LDConfig.Builder() + .dataStore(new DataStoreFactoryWithDiagnosticDescription(LDValue.of(4))) + .build(); + LDValue expected4 = expectedDefaultProperties().put("dataStoreType", "custom").build(); + assertEquals(expected4, makeConfigData(ldConfig4)); } @Test public void testCustomDiagnosticConfigurationForPersistentDataStore() { - LDConfig ldConfig = new LDConfig.Builder() + LDConfig ldConfig1 = new LDConfig.Builder() .dataStore(Components.persistentDataStore(new PersistentDataStoreFactoryWithComponentName())) .build(); - LDValue diagnosticJson = makeConfigData(ldConfig); - LDValue expected = expectedDefaultProperties() - .put("dataStoreType", "my-test-store") + LDValue diagnosticJson1 = makeConfigData(ldConfig1); + LDValue expected1 = expectedDefaultProperties().put("dataStoreType", "my-test-store").build(); + + assertEquals(expected1, diagnosticJson1); + + LDConfig ldConfig2 = new LDConfig.Builder() + .dataStore(Components.persistentDataStore(new PersistentDataStoreFactoryWithoutComponentName())) .build(); - assertEquals(expected, diagnosticJson); + LDValue diagnosticJson2 = makeConfigData(ldConfig2); + LDValue expected2 = expectedDefaultProperties().put("dataStoreType", "custom").build(); + + assertEquals(expected2, diagnosticJson2); } @Test public void testCustomDiagnosticConfigurationForEvents() { - LDConfig ldConfig = new LDConfig.Builder() + LDConfig ldConfig1 = new LDConfig.Builder() .events( Components.sendEvents() .allAttributesPrivate(true) @@ -184,8 +269,8 @@ public void testCustomDiagnosticConfigurationForEvents() { ) .build(); - LDValue diagnosticJson = makeConfigData(ldConfig); - LDValue expected = expectedDefaultProperties() + LDValue diagnosticJson1 = makeConfigData(ldConfig1); + LDValue expected1 = expectedDefaultProperties() .put("allAttributesPrivate", true) .put("customEventsURI", true) .put("diagnosticRecordingIntervalMillis", 1_800_000) @@ -196,19 +281,32 @@ public void testCustomDiagnosticConfigurationForEvents() { .put("userKeysFlushIntervalMillis", 600_000) .build(); - assertEquals(expected, diagnosticJson); - } + assertEquals(expected1, diagnosticJson1); + + LDConfig ldConfig2 = new LDConfig.Builder() + .events(Components.sendEvents()) // no custom base URI + .build(); + + LDValue diagnosticJson2 = makeConfigData(ldConfig2); + LDValue expected2 = expectedDefaultProperties().build(); + + assertEquals(expected2, diagnosticJson2); + + LDConfig ldConfig3 = new LDConfig.Builder() + .events(Components.sendEvents().baseURI(LDConfig.DEFAULT_EVENTS_URI)) // set a base URI, but not a custom one + .build(); + + assertEquals(expected2, makeConfigData(ldConfig3)); // result is same as previous test case +} @Test public void testCustomDiagnosticConfigurationForDaemonMode() { LDConfig ldConfig = new LDConfig.Builder() .dataSource(Components.externalUpdatesOnly()) - .dataStore(new DataStoreFactoryWithComponentName()) .build(); LDValue diagnosticJson = makeConfigData(ldConfig); LDValue expected = expectedDefaultPropertiesWithoutStreaming() - .put("dataStoreType", "my-test-store") .put("usingRelayDaemon", true) .build(); @@ -238,10 +336,81 @@ public void testCustomDiagnosticConfigurationHttpProperties() { assertEquals(expected, diagnosticJson); } - private static class DataStoreFactoryWithComponentName implements DataStoreFactory, DiagnosticDescription { + @Test + public void customComponentCannotInjectUnsupportedConfigProperty() { + String unsupportedPropertyName = "fake"; + LDValue description = LDValue.buildObject().put(unsupportedPropertyName, true).build(); + LDConfig config = new LDConfig.Builder() + .dataSource(new DataSourceFactoryWithDiagnosticDescription(description)) + .build(); + + LDValue diagnosticJson = makeConfigData(config); + + assertThat(diagnosticJson.keys(), not(hasItem(unsupportedPropertyName))); + } + + @Test + public void customComponentCannotInjectSupportedConfigPropertyWithWrongType() { + LDValue description = LDValue.buildObject().put("streamingDisabled", 3).build(); + LDConfig config = new LDConfig.Builder() + .dataSource(new DataSourceFactoryWithDiagnosticDescription(description)) + .build(); + + LDValue diagnosticJson = makeConfigData(config); + + assertThat(diagnosticJson.keys(), not(hasItem("streamingDisabled"))); + } + + @Test + public void customComponentDescriptionOfUnsupportedTypeIsIgnored() { + LDConfig config1 = new LDConfig.Builder() + .dataSource(new DataSourceFactoryWithDiagnosticDescription(LDValue.of(3))) + .build(); + LDConfig config2 = new LDConfig.Builder() + .dataSource(new DataSourceFactoryWithoutDiagnosticDescription()) + .build(); + + LDValue diagnosticJson1 = makeConfigData(config1); + LDValue diagnosticJson2 = makeConfigData(config2); + + assertEquals(diagnosticJson1, diagnosticJson2); + } + + private static class DataSourceFactoryWithDiagnosticDescription implements DataSourceFactory, DiagnosticDescription { + private final LDValue value; + + DataSourceFactoryWithDiagnosticDescription(LDValue value) { + this.value = value; + } + @Override public LDValue describeConfiguration(BasicConfiguration basicConfig) { - return LDValue.of("my-test-store"); + return value; + } + + @Override + public DataSource createDataSource(ClientContext context, DataSourceUpdates dataSourceUpdates) { + return null; + } + } + + private static class DataSourceFactoryWithoutDiagnosticDescription implements DataSourceFactory { + @Override + public DataSource createDataSource(ClientContext context, DataSourceUpdates dataSourceUpdates) { + return null; + } + } + + private static class DataStoreFactoryWithDiagnosticDescription implements DataStoreFactory, DiagnosticDescription { + private final LDValue value; + + DataStoreFactoryWithDiagnosticDescription(LDValue value) { + this.value = value; + } + + @Override + public LDValue describeConfiguration(BasicConfiguration basicConfig) { + return value; } @Override @@ -249,6 +418,13 @@ public DataStore createDataStore(ClientContext context, DataStoreUpdates dataSto return null; } } + + private static class DataStoreFactoryWithoutDiagnosticDescription implements DataStoreFactory { + @Override + public DataStore createDataStore(ClientContext context, DataStoreUpdates dataStoreUpdates) { + return null; + } + } private static class PersistentDataStoreFactoryWithComponentName implements PersistentDataStoreFactory, DiagnosticDescription { @Override @@ -261,4 +437,11 @@ public PersistentDataStore createPersistentDataStore(ClientContext context) { return null; } } + + private static class PersistentDataStoreFactoryWithoutComponentName implements PersistentDataStoreFactory { + @Override + public PersistentDataStore createPersistentDataStore(ClientContext context) { + return null; + } + } } diff --git a/src/test/java/com/launchdarkly/sdk/server/DiagnosticSdkTest.java b/src/test/java/com/launchdarkly/sdk/server/DiagnosticSdkTest.java index de3d6e0be..1c3f07dca 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DiagnosticSdkTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DiagnosticSdkTest.java @@ -31,14 +31,21 @@ public void defaultFieldValues() { @Test public void getsWrapperValuesFromConfig() { - LDConfig config = new LDConfig.Builder() + LDConfig config1 = new LDConfig.Builder() .http(Components.httpConfiguration().wrapper("Scala", "0.1.0")) .build(); - DiagnosticSdk diagnosticSdk = new DiagnosticSdk(makeHttpConfig(config)); - assertEquals("java-server-sdk", diagnosticSdk.name); - assertEquals(Version.SDK_VERSION, diagnosticSdk.version); - assertEquals(diagnosticSdk.wrapperName, "Scala"); - assertEquals(diagnosticSdk.wrapperVersion, "0.1.0"); + DiagnosticSdk diagnosticSdk1 = new DiagnosticSdk(makeHttpConfig(config1)); + assertEquals("java-server-sdk", diagnosticSdk1.name); + assertEquals(Version.SDK_VERSION, diagnosticSdk1.version); + assertEquals(diagnosticSdk1.wrapperName, "Scala"); + assertEquals(diagnosticSdk1.wrapperVersion, "0.1.0"); + + LDConfig config2 = new LDConfig.Builder() + .http(Components.httpConfiguration().wrapper("Scala", null)) + .build(); + DiagnosticSdk diagnosticSdk2 = new DiagnosticSdk(makeHttpConfig(config2)); + assertEquals(diagnosticSdk2.wrapperName, "Scala"); + assertNull(diagnosticSdk2.wrapperVersion); } @Test @@ -63,4 +70,24 @@ public void gsonSerializationWithWrapper() { assertEquals("Scala", jsonObject.getAsJsonPrimitive("wrapperName").getAsString()); assertEquals("0.1.0", jsonObject.getAsJsonPrimitive("wrapperVersion").getAsString()); } + + @Test + public void platformOsNames() { + String realOsName = System.getProperty("os.name"); + try { + System.setProperty("os.name", "Mac OS X"); + assertEquals("MacOS", new DiagnosticEvent.Init.DiagnosticPlatform().osName); + + System.setProperty("os.name", "Windows 10"); + assertEquals("Windows", new DiagnosticEvent.Init.DiagnosticPlatform().osName); + + System.setProperty("os.name", "Linux"); + assertEquals("Linux", new DiagnosticEvent.Init.DiagnosticPlatform().osName); + + System.clearProperty("os.name"); + assertNull(new DiagnosticEvent.Init.DiagnosticPlatform().osName); + } finally { + System.setProperty("os.name", realOsName); + } + } } diff --git a/src/test/java/com/launchdarkly/sdk/server/EventBroadcasterImplTest.java b/src/test/java/com/launchdarkly/sdk/server/EventBroadcasterImplTest.java index 66dcd5b13..721cf87ac 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EventBroadcasterImplTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EventBroadcasterImplTest.java @@ -18,6 +18,31 @@ public class EventBroadcasterImplTest { public void sendingEventWithNoListenersDoesNotCauseError() { broadcaster.broadcast(new FakeEvent()); } + + @Test + public void sendingEventWithNoExecutorDoesNotCauseError() { + new EventBroadcasterImpl<>(FakeListener::sendEvent, null).broadcast(new FakeEvent()); + } + + @Test + public void hasListeners() { + assertThat(broadcaster.hasListeners(), is(false)); + + FakeListener listener1 = e -> {}; + FakeListener listener2 = e -> {}; + broadcaster.register(listener1); + broadcaster.register(listener2); + + assertThat(broadcaster.hasListeners(), is(true)); + + broadcaster.unregister(listener1); + + assertThat(broadcaster.hasListeners(), is(true)); + + broadcaster.unregister(listener2); + + assertThat(broadcaster.hasListeners(), is(false)); + } @Test public void allListenersReceiveEvent() throws Exception { @@ -74,6 +99,23 @@ public void canUnregisterListener() throws Exception { assertThat(events2.isEmpty(), is(true)); } + @Test + public void exceptionFromEarlierListenerDoesNotInterfereWithLaterListener() throws Exception { + FakeListener listener1 = e -> { + throw new RuntimeException("sorry"); + }; + broadcaster.register(listener1); + + BlockingQueue events2 = new LinkedBlockingQueue<>(); + FakeListener listener2 = events2::add; + broadcaster.register(listener2); + + FakeEvent e = new FakeEvent(); + broadcaster.broadcast(e); + + assertThat(events2.take(), is(e)); + } + static class FakeEvent {} static interface FakeListener { diff --git a/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java b/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java index 279fb019b..88657ebc7 100644 --- a/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java @@ -12,12 +12,14 @@ import java.util.ArrayList; import java.util.List; +import java.util.function.Supplier; import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; import static com.launchdarkly.sdk.EvaluationReason.ErrorKind.MALFORMED_FLAG; import static com.launchdarkly.sdk.server.FlagsStateOption.DETAILS_ONLY_FOR_TRACKED_FLAGS; import static com.launchdarkly.sdk.server.FlagsStateOption.WITH_REASONS; import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.TestUtil.verifyEqualityForType; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNotNull; @@ -145,33 +147,20 @@ public void equalInstancesAreEqual() { public void equalMetadataInstancesAreEqual() { // Testing this various cases is easier at a low level - equalInstancesAreEqual() above already // verifies that we test for metadata equality in general - List allPermutations = new ArrayList<>(); + List> allPermutations = new ArrayList<>(); for (Integer variation: new Integer[] { null, 0, 1 }) { for (EvaluationReason reason: new EvaluationReason[] { null, EvaluationReason.off(), EvaluationReason.fallthrough() }) { for (Integer version: new Integer[] { null, 10, 11 }) { for (boolean trackEvents: new boolean[] { false, true }) { for (Long debugEventsUntilDate: new Long[] { null, 1000L, 1001L }) { - FeatureFlagsState.FlagMetadata m1 = new FeatureFlagsState.FlagMetadata( - variation, reason, version, trackEvents, debugEventsUntilDate); - FeatureFlagsState.FlagMetadata m2 = new FeatureFlagsState.FlagMetadata( - variation, reason, version, trackEvents, debugEventsUntilDate); - assertEquals(m1, m2); - assertEquals(m2, m1); - assertNotEquals(m1, null); - assertNotEquals(m1, "x"); - allPermutations.add(m1); + allPermutations.add(() -> new FeatureFlagsState.FlagMetadata( + variation, reason, version, trackEvents, debugEventsUntilDate)); } } } } } - for (int i = 0; i < allPermutations.size(); i++) { - for (int j = 0; j < allPermutations.size(); j++) { - if (i != j) { - assertNotEquals(allPermutations.get(i), allPermutations.get(j)); - } - } - } + verifyEqualityForType(allPermutations); } @Test diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java index 2b9934f78..0b3c7592e 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java @@ -33,7 +33,6 @@ import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.greaterThanOrEqualTo; -import static org.hamcrest.Matchers.lessThan; import static org.hamcrest.Matchers.nullValue; import static org.junit.Assert.assertEquals; @@ -195,85 +194,6 @@ public void dataSourceStatusProviderSendsStatusUpdates() throws Exception { } } - @Test - public void dataSourceStatusProviderWaitForStatusWithStatusAlreadyCorrect() throws Exception { - DataSourceFactoryThatExposesUpdater updatableSource = new DataSourceFactoryThatExposesUpdater(new DataBuilder().build()); - LDConfig config = new LDConfig.Builder() - .dataSource(updatableSource) - .events(Components.noEvents()) - .build(); - - try (LDClient client = new LDClient(SDK_KEY, config)) { - updatableSource.dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.VALID, null); - - boolean success = client.getDataSourceStatusProvider().waitFor(DataSourceStatusProvider.State.VALID, - Duration.ofMillis(500)); - assertThat(success, equalTo(true)); - } - } - - @Test - public void dataSourceStatusProviderWaitForStatusSucceeds() throws Exception { - DataSourceFactoryThatExposesUpdater updatableSource = new DataSourceFactoryThatExposesUpdater(new DataBuilder().build()); - LDConfig config = new LDConfig.Builder() - .dataSource(updatableSource) - .events(Components.noEvents()) - .build(); - - try (LDClient client = new LDClient(SDK_KEY, config)) { - new Thread(() -> { - try { - Thread.sleep(100); - } catch (InterruptedException e) {} - updatableSource.dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.VALID, null); - }).start(); - - boolean success = client.getDataSourceStatusProvider().waitFor(DataSourceStatusProvider.State.VALID, - Duration.ofMillis(500)); - assertThat(success, equalTo(true)); - } - } - - @Test - public void dataSourceStatusProviderWaitForStatusTimesOut() throws Exception { - DataSourceFactoryThatExposesUpdater updatableSource = new DataSourceFactoryThatExposesUpdater(new DataBuilder().build()); - LDConfig config = new LDConfig.Builder() - .dataSource(updatableSource) - .events(Components.noEvents()) - .build(); - - try (LDClient client = new LDClient(SDK_KEY, config)) { - long timeStart = System.currentTimeMillis(); - boolean success = client.getDataSourceStatusProvider().waitFor(DataSourceStatusProvider.State.VALID, - Duration.ofMillis(300)); - long timeEnd = System.currentTimeMillis(); - assertThat(success, equalTo(false)); - assertThat(timeEnd - timeStart, greaterThanOrEqualTo(270L)); - } - } - - @Test - public void dataSourceStatusProviderWaitForStatusEndsIfShutDown() throws Exception { - DataSourceFactoryThatExposesUpdater updatableSource = new DataSourceFactoryThatExposesUpdater(new DataBuilder().build()); - LDConfig config = new LDConfig.Builder() - .dataSource(updatableSource) - .events(Components.noEvents()) - .build(); - - try (LDClient client = new LDClient(SDK_KEY, config)) { - new Thread(() -> { - updatableSource.dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.OFF, null); - }).start(); - - long timeStart = System.currentTimeMillis(); - boolean success = client.getDataSourceStatusProvider().waitFor(DataSourceStatusProvider.State.VALID, - Duration.ofMillis(500)); - long timeEnd = System.currentTimeMillis(); - assertThat(success, equalTo(false)); - assertThat(timeEnd - timeStart, lessThan(500L)); - } - } - @Test public void dataStoreStatusMonitoringIsDisabledForInMemoryStore() throws Exception { LDConfig config = new LDConfig.Builder() @@ -312,9 +232,9 @@ public void dataStoreStatusProviderReturnsLatestStatus() throws Exception { try (LDClient client = new LDClient(SDK_KEY, config)) { DataStoreStatusProvider.Status originalStatus = new DataStoreStatusProvider.Status(true, false); DataStoreStatusProvider.Status newStatus = new DataStoreStatusProvider.Status(false, false); - assertThat(client.getDataStoreStatusProvider().getStoreStatus(), equalTo(originalStatus)); + assertThat(client.getDataStoreStatusProvider().getStatus(), equalTo(originalStatus)); factoryWithUpdater.dataStoreUpdates.updateStatus(newStatus); - assertThat(client.getDataStoreStatusProvider().getStoreStatus(), equalTo(newStatus)); + assertThat(client.getDataStoreStatusProvider().getStatus(), equalTo(newStatus)); } } diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java index ee7e5a923..85efc339a 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java @@ -2,11 +2,13 @@ import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.integrations.MockPersistentDataStore; import com.launchdarkly.sdk.server.interfaces.ClientContext; import com.launchdarkly.sdk.server.interfaces.DataSource; import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; import com.launchdarkly.sdk.server.interfaces.EventProcessor; import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; import com.launchdarkly.sdk.server.interfaces.LDClientInterface; @@ -395,6 +397,25 @@ public void getVersion() throws Exception { } } + @Test + public void canGetCacheStatsFromDataStoreStatusProvider() throws Exception { + LDConfig config1 = new LDConfig.Builder() + .dataSource(Components.externalUpdatesOnly()) + .events(Components.noEvents()) + .build(); + try (LDClient client1 = new LDClient(SDK_KEY, config1)) { + assertNull(client1.getDataStoreStatusProvider().getCacheStats()); + } + + LDConfig config2 = new LDConfig.Builder() + .dataStore(Components.persistentDataStore(c -> new MockPersistentDataStore())) + .build(); + try (LDClient client2 = new LDClient(SDK_KEY, config2)) { + DataStoreStatusProvider.CacheStats expectedStats = new DataStoreStatusProvider.CacheStats(0, 0, 0, 0, 0, 0); + assertEquals(expectedStats, client2.getDataStoreStatusProvider().getCacheStats()); + } + } + @Test public void testSecureModeHash() throws IOException { setupMockDataSourceToInitialize(true); diff --git a/src/test/java/com/launchdarkly/sdk/server/LDConfigTest.java b/src/test/java/com/launchdarkly/sdk/server/LDConfigTest.java index 61c96ce9c..0d908b10a 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDConfigTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDConfigTest.java @@ -82,11 +82,18 @@ public void eventProcessorFactory() { @Test public void offline() { - LDConfig config = new LDConfig.Builder().offline(true).build(); - assertTrue(config.offline); + LDConfig config1 = new LDConfig.Builder().offline(true).build(); + assertTrue(config1.offline); + assertSame(Components.externalUpdatesOnly(), config1.dataSourceFactory); + assertSame(Components.noEvents(), config1.eventProcessorFactory); - LDConfig config1 = new LDConfig.Builder().offline(true).offline(false).build(); - assertFalse(config1.offline); + LDConfig config2 = new LDConfig.Builder().offline(true).dataSource(Components.streamingDataSource()).build(); + assertTrue(config2.offline); + assertSame(Components.externalUpdatesOnly(), config2.dataSourceFactory); // offline overrides specified factory + assertSame(Components.noEvents(), config2.eventProcessorFactory); + + LDConfig config3 = new LDConfig.Builder().offline(true).offline(false).build(); + assertFalse(config3.offline); // just testing that the setter works for both true and false } @Test diff --git a/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperOtherTest.java b/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperOtherTest.java new file mode 100644 index 000000000..d69a4208d --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperOtherTest.java @@ -0,0 +1,115 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.DataStoreTestTypes.TestItem; +import com.launchdarkly.sdk.server.integrations.MockPersistentDataStore; +import com.launchdarkly.sdk.server.integrations.PersistentDataStoreBuilder.StaleValuesPolicy; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; + +import org.junit.Test; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Supplier; + +import static com.launchdarkly.sdk.server.DataStoreTestTypes.TEST_ITEMS; +import static com.launchdarkly.sdk.server.TestComponents.sharedExecutor; +import static com.launchdarkly.sdk.server.TestUtil.verifyEqualityForType; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.junit.Assert.assertEquals; + +/** + * These tests are for PersistentDataStoreWrapper functionality that doesn't fit into the parameterized + * PersistentDataStoreWrapperTest suite. + */ +@SuppressWarnings("javadoc") +public class PersistentDataStoreWrapperOtherTest { + private static final RuntimeException FAKE_ERROR = new RuntimeException("fake error"); + + private final MockPersistentDataStore core; + + public PersistentDataStoreWrapperOtherTest() { + this.core = new MockPersistentDataStore(); + } + + private PersistentDataStoreWrapper makeWrapper(Duration cacheTtl, StaleValuesPolicy policy) { + return new PersistentDataStoreWrapper( + core, + cacheTtl, + policy, + false, + status -> {}, + sharedExecutor + ); + } + + @Test + public void cacheKeyEquality() { + List> allPermutations = new ArrayList<>(); + for (DataKind kind: new DataKind[] { DataModel.FEATURES, DataModel.SEGMENTS }) { + for (String key: new String[] { "a", "b" }) { + allPermutations.add(() -> PersistentDataStoreWrapper.CacheKey.forItem(kind, key)); + } + } + verifyEqualityForType(allPermutations); + } + + @Test + public void cacheInRefreshModeRefreshesExpiredItem() throws Exception { + try (PersistentDataStoreWrapper wrapper = makeWrapper(Duration.ofMillis(20), StaleValuesPolicy.REFRESH)) { + TestItem itemv1 = new TestItem("key", 1); + TestItem itemv2 = new TestItem(itemv1.key, 2); + core.forceSet(TEST_ITEMS, itemv1); + + assertEquals(0, core.getQueryCount); + + ItemDescriptor result1 = wrapper.get(TEST_ITEMS, itemv1.key); + assertThat(result1, equalTo(itemv1.toItemDescriptor())); + assertEquals(1, core.getQueryCount); + + // item is now in the cache + // change the item in the underlying store + core.forceSet(TEST_ITEMS, itemv2); + + // wait for the cached item to expire + Thread.sleep(50); + + // it has not yet tried to requery the store, because we didn't use ASYNC_REFRESH + assertEquals(1, core.getQueryCount); + + // try to get it again - it refreshes the cache with the new data + ItemDescriptor result2 = wrapper.get(TEST_ITEMS, itemv1.key); + assertThat(result2, equalTo(itemv2.toItemDescriptor())); + } + } + + @Test + public void cacheInRefreshModeKeepsExpiredItemInCacheIfRefreshFails() throws Exception { + try (PersistentDataStoreWrapper wrapper = makeWrapper(Duration.ofMillis(20), StaleValuesPolicy.REFRESH)) { + TestItem item = new TestItem("key", 1); + core.forceSet(TEST_ITEMS, item); + + assertEquals(0, core.getQueryCount); + + ItemDescriptor result1 = wrapper.get(TEST_ITEMS, item.key); + assertThat(result1, equalTo(item.toItemDescriptor())); + assertEquals(1, core.getQueryCount); + + // item is now in the cache + // now make it so the core will return an error if get() is called + core.fakeError = FAKE_ERROR; + + // wait for the cached item to expire + Thread.sleep(50); + + // it has not yet tried to requery the store, because we didn't use REFRESH_ASYNC + assertEquals(1, core.getQueryCount); + + // try to get it again - the query fails, but in REFRESH mode it swallows the error and keeps the old cached value + ItemDescriptor result2 = wrapper.get(TEST_ITEMS, item.key); + assertThat(result2, equalTo(item.toItemDescriptor())); + } + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperTest.java b/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperTest.java index 60821ea2a..4c881181d 100644 --- a/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperTest.java @@ -37,6 +37,7 @@ import static org.hamcrest.Matchers.greaterThan; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.nullValue; +import static org.junit.Assert.assertNull; import static org.junit.Assert.fail; import static org.junit.Assume.assumeThat; @@ -494,11 +495,9 @@ public void initializedCanCacheFalseResult() throws Exception { @Test public void canGetCacheStats() throws Exception { - assumeThat(testMode.isCachedWithFiniteTtl(), is(true)); - try (PersistentDataStoreWrapper w = new PersistentDataStoreWrapper( core, - Duration.ofSeconds(30), + testMode.getCacheTtl(), PersistentDataStoreBuilder.StaleValuesPolicy.EVICT, true, this::updateStatus, @@ -506,6 +505,11 @@ public void canGetCacheStats() throws Exception { )) { CacheStats stats = w.getCacheStats(); + if (!testMode.isCached()) { + assertNull(stats); + return; + } + assertThat(stats, equalTo(new CacheStats(0, 0, 0, 0, 0, 0))); // Cause a cache miss @@ -544,7 +548,7 @@ public void canGetCacheStats() throws Exception { @Test public void statusIsOkInitially() throws Exception { - DataStoreStatusProvider.Status status = dataStoreStatusProvider.getStoreStatus(); + DataStoreStatusProvider.Status status = dataStoreStatusProvider.getStatus(); assertThat(status.isAvailable(), is(true)); assertThat(status.isRefreshNeeded(), is(false)); } @@ -553,7 +557,7 @@ public void statusIsOkInitially() throws Exception { public void statusIsUnavailableAfterError() throws Exception { causeStoreError(core, wrapper); - DataStoreStatusProvider.Status status = dataStoreStatusProvider.getStoreStatus(); + DataStoreStatusProvider.Status status = dataStoreStatusProvider.getStatus(); assertThat(status.isAvailable(), is(false)); assertThat(status.isRefreshNeeded(), is(false)); } diff --git a/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java index c7418341e..b9f061814 100644 --- a/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java @@ -1,5 +1,6 @@ package com.launchdarkly.sdk.server; +import com.launchdarkly.sdk.server.DataModel.FeatureFlag; import com.launchdarkly.sdk.server.TestComponents.MockDataSourceUpdates; import com.launchdarkly.sdk.server.TestComponents.MockDataStoreStatusProvider; import com.launchdarkly.sdk.server.integrations.PollingDataSourceBuilder; @@ -9,6 +10,7 @@ import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.Status; import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; +import com.launchdarkly.sdk.server.interfaces.SerializationException; import org.junit.Before; import org.junit.Test; @@ -16,16 +18,20 @@ import java.io.IOException; import java.net.URI; import java.time.Duration; +import java.util.Collections; import java.util.HashMap; import java.util.concurrent.BlockingQueue; import java.util.concurrent.Future; import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import static com.launchdarkly.sdk.server.TestComponents.clientContext; import static com.launchdarkly.sdk.server.TestComponents.dataStoreThatThrowsException; import static com.launchdarkly.sdk.server.TestComponents.sharedExecutor; +import static com.launchdarkly.sdk.server.TestUtil.awaitValue; +import static com.launchdarkly.sdk.server.TestUtil.expectNoMoreValues; import static com.launchdarkly.sdk.server.TestUtil.requireDataSourceStatus; import static com.launchdarkly.sdk.server.TestUtil.requireDataSourceStatusEventually; import static com.launchdarkly.sdk.server.TestUtil.shouldNotTimeOut; @@ -36,6 +42,7 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; @@ -84,20 +91,38 @@ public void builderCanSpecifyConfiguration() throws Exception { } @Test - public void testConnectionOk() throws Exception { - requestor.allData = new FeatureRequestor.AllData(new HashMap<>(), new HashMap<>()); + public void successfulPolls() throws Exception { + FeatureFlag flagv1 = ModelBuilders.flagBuilder("flag").version(1).build(); + FeatureFlag flagv2 = ModelBuilders.flagBuilder(flagv1.getKey()).version(2).build(); + FeatureRequestor.AllData datav1 = new FeatureRequestor.AllData(Collections.singletonMap(flagv1.getKey(), flagv1), + Collections.emptyMap()); + FeatureRequestor.AllData datav2 = new FeatureRequestor.AllData(Collections.singletonMap(flagv1.getKey(), flagv2), + Collections.emptyMap()); + + requestor.gate = new Semaphore(0); + requestor.allData = datav1; BlockingQueue statuses = new LinkedBlockingQueue<>(); dataSourceUpdates.statusBroadcaster.register(statuses::add); - try (PollingProcessor pollingProcessor = makeProcessor()) { + try (PollingProcessor pollingProcessor = makeProcessor(Duration.ofMillis(100))) { Future initFuture = pollingProcessor.start(); + + // allow first poll to complete + requestor.gate.release(); + initFuture.get(1000, TimeUnit.MILLISECONDS); assertTrue(pollingProcessor.isInitialized()); - assertEquals(requestor.allData.toFullDataSet(), dataSourceUpdates.awaitInit()); + assertEquals(datav1.toFullDataSet(), dataSourceUpdates.awaitInit()); + // allow second poll to complete - should return new data + requestor.allData = datav2; + requestor.gate.release(); + requireDataSourceStatus(statuses, State.VALID); + + assertEquals(datav2.toFullDataSet(), dataSourceUpdates.awaitInit()); } } @@ -149,6 +174,61 @@ public void testDataStoreFailure() throws Exception { } } + @Test + public void testMalformedData() throws Exception { + requestor.runtimeException = new SerializationException(new Exception("the JSON was displeasing")); + + BlockingQueue statuses = new LinkedBlockingQueue<>(); + dataSourceUpdates.statusBroadcaster.register(statuses::add); + + try (PollingProcessor pollingProcessor = makeProcessor()) { + pollingProcessor.start(); + + Status status = requireDataSourceStatus(statuses, State.INITIALIZING); + assertNotNull(status.getLastError()); + assertEquals(ErrorKind.INVALID_DATA, status.getLastError().getKind()); + assertEquals(requestor.runtimeException.toString(), status.getLastError().getMessage()); + + assertFalse(pollingProcessor.isInitialized()); + } + } + + @Test + public void testUnknownException() throws Exception { + requestor.runtimeException = new RuntimeException("everything is displeasing"); + + BlockingQueue statuses = new LinkedBlockingQueue<>(); + dataSourceUpdates.statusBroadcaster.register(statuses::add); + + try (PollingProcessor pollingProcessor = makeProcessor()) { + pollingProcessor.start(); + + Status status = requireDataSourceStatus(statuses, State.INITIALIZING); + assertNotNull(status.getLastError()); + assertEquals(ErrorKind.UNKNOWN, status.getLastError().getKind()); + assertEquals(requestor.runtimeException.toString(), status.getLastError().getMessage()); + + assertFalse(pollingProcessor.isInitialized()); + } + } + + @Test + public void startingWhenAlreadyStartedDoesNothing() throws Exception { + requestor.allData = new FeatureRequestor.AllData(new HashMap<>(), new HashMap<>()); + + try (PollingProcessor pollingProcessor = makeProcessor(Duration.ofMillis(500))) { + Future initFuture1 = pollingProcessor.start(); + + awaitValue(requestor.queries, Duration.ofMillis(100)); // a poll request was made + + Future initFuture2 = pollingProcessor.start(); + assertSame(initFuture1, initFuture2); + + + expectNoMoreValues(requestor.queries, Duration.ofMillis(100)); // we did NOT start another polling task + } + } + @Test public void http400ErrorIsRecoverable() throws Exception { testRecoverableHttpError(400); @@ -251,6 +331,9 @@ private static class MockFeatureRequestor implements FeatureRequestor { volatile AllData allData; volatile HttpErrorException httpException; volatile IOException ioException; + volatile RuntimeException runtimeException; + volatile Semaphore gate; + final BlockingQueue queries = new LinkedBlockingQueue<>(); public void close() throws IOException {} @@ -263,12 +346,21 @@ public DataModel.Segment getSegment(String segmentKey) throws IOException, HttpE } public AllData getAllData() throws IOException, HttpErrorException { + queries.add(true); + if (gate != null) { + try { + gate.acquire(); + } catch (InterruptedException e) {} + } if (httpException != null) { throw httpException; } if (ioException != null) { throw ioException; } + if (runtimeException != null) { + throw runtimeException; + } return allData; } } diff --git a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java index 847826d10..200cd34d6 100644 --- a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java @@ -5,6 +5,7 @@ import com.launchdarkly.eventsource.EventSource; import com.launchdarkly.eventsource.MessageEvent; import com.launchdarkly.eventsource.UnsuccessfulResponseException; +import com.launchdarkly.sdk.server.DataModel.VersionedData; import com.launchdarkly.sdk.server.StreamProcessor.EventSourceParams; import com.launchdarkly.sdk.server.TestComponents.MockDataSourceUpdates; import com.launchdarkly.sdk.server.TestComponents.MockDataStoreStatusProvider; @@ -17,6 +18,7 @@ import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; @@ -49,8 +51,6 @@ import static com.launchdarkly.sdk.server.TestUtil.requireDataSourceStatus; import static com.launchdarkly.sdk.server.TestUtil.shouldNotTimeOut; import static com.launchdarkly.sdk.server.TestUtil.shouldTimeOut; -import static com.launchdarkly.sdk.server.TestUtil.upsertFlag; -import static com.launchdarkly.sdk.server.TestUtil.upsertSegment; import static com.launchdarkly.sdk.server.integrations.StreamingDataSourceBuilder.DEFAULT_INITIAL_RECONNECT_DELAY; import static org.easymock.EasyMock.expect; import static org.easymock.EasyMock.expectLastCall; @@ -231,68 +231,53 @@ public void putCausesFutureToBeSet() throws Exception { @Test public void patchUpdatesFeature() throws Exception { - expectNoStreamRestart(); - - createStreamProcessor(STREAM_URI).start(); - EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; - handler.onMessage("put", emptyPutEvent()); - - String path = "/flags/" + FEATURE1_KEY; - MessageEvent event = new MessageEvent("{\"path\":\"" + path + "\",\"data\":" + - featureJson(FEATURE1_KEY, FEATURE1_VERSION) + "}"); - handler.onMessage("patch", event); - - assertFeatureInStore(FEATURE); + doPatchSuccessTest(FEATURES, FEATURE, "/flags/" + FEATURE.getKey()); } @Test public void patchUpdatesSegment() throws Exception { + doPatchSuccessTest(SEGMENTS, SEGMENT, "/segments/" + SEGMENT.getKey()); + } + + private void doPatchSuccessTest(DataKind kind, VersionedData item, String path) throws Exception { expectNoStreamRestart(); createStreamProcessor(STREAM_URI).start(); EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; handler.onMessage("put", emptyPutEvent()); - String path = "/segments/" + SEGMENT1_KEY; - MessageEvent event = new MessageEvent("{\"path\":\"" + path + "\",\"data\":" + - segmentJson(SEGMENT1_KEY, SEGMENT1_VERSION) + "}"); + String json = kind.serialize(new ItemDescriptor(item.getVersion(), item)); + MessageEvent event = new MessageEvent("{\"path\":\"" + path + "\",\"data\":" + json + "}"); handler.onMessage("patch", event); - assertSegmentInStore(SEGMENT); + ItemDescriptor result = dataStore.get(kind, item.getKey()); + assertNotNull(result.getItem()); + assertEquals(item.getVersion(), result.getVersion()); } - + @Test public void deleteDeletesFeature() throws Exception { - expectNoStreamRestart(); - - createStreamProcessor(STREAM_URI).start(); - EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; - handler.onMessage("put", emptyPutEvent()); - upsertFlag(dataStore, FEATURE); - - String path = "/flags/" + FEATURE1_KEY; - MessageEvent event = new MessageEvent("{\"path\":\"" + path + "\",\"version\":" + - (FEATURE1_VERSION + 1) + "}"); - handler.onMessage("delete", event); - - assertEquals(ItemDescriptor.deletedItem(FEATURE1_VERSION + 1), dataStore.get(FEATURES, FEATURE1_KEY)); + doDeleteSuccessTest(FEATURES, FEATURE, "/flags/" + FEATURE.getKey()); } @Test public void deleteDeletesSegment() throws Exception { + doDeleteSuccessTest(SEGMENTS, SEGMENT, "/segments/" + SEGMENT.getKey()); + } + + private void doDeleteSuccessTest(DataKind kind, VersionedData item, String path) throws Exception { expectNoStreamRestart(); createStreamProcessor(STREAM_URI).start(); EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; handler.onMessage("put", emptyPutEvent()); - upsertSegment(dataStore, SEGMENT); + dataStore.upsert(kind, item.getKey(), new ItemDescriptor(item.getVersion(), item)); - String path = "/segments/" + SEGMENT1_KEY; MessageEvent event = new MessageEvent("{\"path\":\"" + path + "\",\"version\":" + - (SEGMENT1_VERSION + 1) + "}"); + (item.getVersion() + 1) + "}"); handler.onMessage("delete", event); - assertEquals(ItemDescriptor.deletedItem(SEGMENT1_VERSION + 1), dataStore.get(SEGMENTS, SEGMENT1_KEY)); + assertEquals(ItemDescriptor.deletedItem(item.getVersion() + 1), dataStore.get(kind, item.getKey())); } @Test @@ -396,6 +381,32 @@ public void streamWillReconnectAfterGeneralIOException() throws Exception { ConnectionErrorHandler errorHandler = mockEventSourceCreator.getNextReceivedParams().errorHandler; ConnectionErrorHandler.Action action = errorHandler.onConnectionError(new IOException()); assertEquals(ConnectionErrorHandler.Action.PROCEED, action); + + assertNotNull(dataSourceUpdates.getLastStatus().getLastError()); + assertEquals(ErrorKind.NETWORK_ERROR, dataSourceUpdates.getLastStatus().getLastError().getKind()); + } + + @Test + public void streamWillReconnectAfterHttpError() throws Exception { + createStreamProcessor(STREAM_URI).start(); + ConnectionErrorHandler errorHandler = mockEventSourceCreator.getNextReceivedParams().errorHandler; + ConnectionErrorHandler.Action action = errorHandler.onConnectionError(new UnsuccessfulResponseException(500)); + assertEquals(ConnectionErrorHandler.Action.PROCEED, action); + + assertNotNull(dataSourceUpdates.getLastStatus().getLastError()); + assertEquals(ErrorKind.ERROR_RESPONSE, dataSourceUpdates.getLastStatus().getLastError().getKind()); + assertEquals(500, dataSourceUpdates.getLastStatus().getLastError().getStatusCode()); + } + + @Test + public void streamWillReconnectAfterUnknownError() throws Exception { + createStreamProcessor(STREAM_URI).start(); + ConnectionErrorHandler errorHandler = mockEventSourceCreator.getNextReceivedParams().errorHandler; + ConnectionErrorHandler.Action action = errorHandler.onConnectionError(new RuntimeException("what?")); + assertEquals(ConnectionErrorHandler.Action.PROCEED, action); + + assertNotNull(dataSourceUpdates.getLastStatus().getLastError()); + assertEquals(ErrorKind.UNKNOWN, dataSourceUpdates.getLastStatus().getLastError().getKind()); } @Test @@ -500,6 +511,11 @@ public void patchEventWithInvalidPathCausesNoStreamRestart() throws Exception { verifyEventCausesNoStreamRestart("patch", "{\"path\":\"/wrong\", \"data\":{\"key\":\"flagkey\"}}"); } + @Test + public void patchEventWithNullPathCausesStreamRestart() throws Exception { + verifyInvalidDataEvent("patch", "{\"path\":null, \"data\":{\"key\":\"flagkey\"}}"); + } + @Test public void deleteEventWithInvalidJsonCausesStreamRestart() throws Exception { verifyInvalidDataEvent("delete", "{sorry"); @@ -647,7 +663,7 @@ public void storeFailureOnIndirectPutCausesStreamRestart() throws Exception { @Test public void storeFailureOnIndirectPatchCausesStreamRestart() throws Exception { MockDataSourceUpdates badUpdates = dataSourceUpdatesThatMakesUpdatesFailAndDoesNotSupportStatusMonitoring(); - setupRequestorToReturnAllDataWithFlag(FEATURE); + expect(mockRequestor.getFlag(FEATURE1_KEY)).andReturn(FEATURE); expectStreamRestart(); replayAll(); @@ -655,11 +671,34 @@ public void storeFailureOnIndirectPatchCausesStreamRestart() throws Exception { try (StreamProcessor sp = createStreamProcessorWithStoreUpdates(badUpdates)) { sp.start(); EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; - handler.onMessage("indirect/put", new MessageEvent("")); + handler.onMessage("indirect/patch", new MessageEvent("/flags/" + FEATURE1_KEY)); } verifyAll(); } + @Test + public void onCommentIsIgnored() throws Exception { + // This just verifies that we are not doing anything with comment data, by passing a null instead of a string + try (StreamProcessor sp = createStreamProcessor(STREAM_URI)) { + sp.start(); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onComment(null); + } + } + + @Test + public void onErrorIsIgnored() throws Exception { + expectNoStreamRestart(); + replayAll(); + + // EventSource won't call our onError() method because we are using a ConnectionErrorHandler instead. + try (StreamProcessor sp = createStreamProcessor(STREAM_URI)) { + sp.start(); + EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; + handler.onError(new Exception("sorry")); + } + } + private MockDataSourceUpdates dataSourceUpdatesThatMakesUpdatesFailAndDoesNotSupportStatusMonitoring() { DataStore badStore = dataStoreThatThrowsException(new RuntimeException("sorry")); DataStoreStatusProvider badStoreStatusProvider = new MockDataStoreStatusProvider(false); diff --git a/src/test/java/com/launchdarkly/sdk/server/TestComponents.java b/src/test/java/com/launchdarkly/sdk/server/TestComponents.java index a34f25b10..125d7320f 100644 --- a/src/test/java/com/launchdarkly/sdk/server/TestComponents.java +++ b/src/test/java/com/launchdarkly/sdk/server/TestComponents.java @@ -335,7 +335,7 @@ public void updateStatus(DataStoreStatusProvider.Status newStatus) { } @Override - public Status getStoreStatus() { + public Status getStatus() { return lastStatus.get(); } diff --git a/src/test/java/com/launchdarkly/sdk/server/TestUtil.java b/src/test/java/com/launchdarkly/sdk/server/TestUtil.java index 2f1851b72..d34f55efa 100644 --- a/src/test/java/com/launchdarkly/sdk/server/TestUtil.java +++ b/src/test/java/com/launchdarkly/sdk/server/TestUtil.java @@ -19,6 +19,7 @@ import java.time.Duration; import java.time.Instant; import java.util.HashSet; +import java.util.List; import java.util.Set; import java.util.concurrent.BlockingQueue; import java.util.concurrent.ExecutionException; @@ -31,6 +32,8 @@ import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.sameInstance; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; @@ -93,6 +96,35 @@ public static void shouldTimeOut(Future future, Duration interval) throws Exe } } + public static void verifyEqualityForType(List> creatorsForPossibleValues) { + for (int i = 0; i < creatorsForPossibleValues.size(); i++) { + for (int j = 0; j < creatorsForPossibleValues.size(); j++) { + T value1 = creatorsForPossibleValues.get(i).get(); + T value2 = creatorsForPossibleValues.get(j).get(); + assertThat(value1, not(sameInstance(value2))); + if (i == j) { + // instance is equal to itself + assertThat(value1, equalTo(value1)); + + // commutative equality + assertThat(value1, equalTo(value2)); + assertThat(value2, equalTo(value1)); + + // equal hash code + assertThat(value1.hashCode(), equalTo(value2.hashCode())); + + // unequal to null, unequal to value of wrong class + assertThat(value1, not(equalTo(null))); + assertThat(value1, not(equalTo(new Object()))); + } else { + // commutative inequality + assertThat(value1, not(equalTo(value2))); + assertThat(value2, not(equalTo(value1))); + } + } + } + } + public static DataSourceStatusProvider.Status requireDataSourceStatus(BlockingQueue statuses) { try { DataSourceStatusProvider.Status status = statuses.poll(1, TimeUnit.SECONDS); diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/EventProcessorBuilderTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/EventProcessorBuilderTest.java index 7a9142d80..e13e52639 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/EventProcessorBuilderTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/EventProcessorBuilderTest.java @@ -1,31 +1,145 @@ package com.launchdarkly.sdk.server.integrations; +import com.google.common.collect.ImmutableSet; +import com.launchdarkly.sdk.UserAttribute; import com.launchdarkly.sdk.server.Components; -import com.launchdarkly.sdk.server.integrations.EventProcessorBuilder; +import com.launchdarkly.sdk.server.interfaces.EventSenderFactory; import org.junit.Test; +import java.net.URI; import java.time.Duration; +import static com.launchdarkly.sdk.server.Components.sendEvents; +import static com.launchdarkly.sdk.server.integrations.EventProcessorBuilder.DEFAULT_CAPACITY; +import static com.launchdarkly.sdk.server.integrations.EventProcessorBuilder.DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL; +import static com.launchdarkly.sdk.server.integrations.EventProcessorBuilder.DEFAULT_FLUSH_INTERVAL; +import static com.launchdarkly.sdk.server.integrations.EventProcessorBuilder.DEFAULT_USER_KEYS_CAPACITY; +import static com.launchdarkly.sdk.server.integrations.EventProcessorBuilder.DEFAULT_USER_KEYS_FLUSH_INTERVAL; +import static com.launchdarkly.sdk.server.integrations.EventProcessorBuilder.MIN_DIAGNOSTIC_RECORDING_INTERVAL; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; @SuppressWarnings("javadoc") public class EventProcessorBuilderTest { @Test - public void testDefaultDiagnosticRecordingInterval() { - EventProcessorBuilder builder = Components.sendEvents(); - assertEquals(Duration.ofSeconds(900), builder.diagnosticRecordingInterval); + public void allAttributesPrivate() { + assertEquals(false, sendEvents().allAttributesPrivate); + + assertEquals(true, sendEvents().allAttributesPrivate(true).allAttributesPrivate); + + assertEquals(false, sendEvents() + .allAttributesPrivate(true) + .allAttributesPrivate(false) + .allAttributesPrivate); } + + @Test + public void baseURI() { + assertNull(sendEvents().baseURI); + + assertEquals(URI.create("x"), sendEvents().baseURI(URI.create("x")).baseURI); + + assertNull(sendEvents() + .baseURI(URI.create("x")) + .baseURI(null) + .baseURI); + } + + @Test + public void capacity() { + assertEquals(DEFAULT_CAPACITY, sendEvents().capacity); + + assertEquals(200, sendEvents().capacity(200).capacity); + } + + @Test + public void diagnosticRecordingInterval() { + EventProcessorBuilder builder1 = sendEvents(); + assertEquals(DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL, builder1.diagnosticRecordingInterval); + + EventProcessorBuilder builder2 = sendEvents().diagnosticRecordingInterval(Duration.ofSeconds(120)); + assertEquals(Duration.ofSeconds(120), builder2.diagnosticRecordingInterval); + + EventProcessorBuilder builder3 = sendEvents() + .diagnosticRecordingInterval(Duration.ofSeconds(120)) + .diagnosticRecordingInterval(null); // null sets it back to the default + assertEquals(DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL, builder3.diagnosticRecordingInterval); + EventProcessorBuilder builder4 = sendEvents().diagnosticRecordingInterval(Duration.ofSeconds(10)); + assertEquals(MIN_DIAGNOSTIC_RECORDING_INTERVAL, builder4.diagnosticRecordingInterval); + + } + + @Test + public void eventSender() { + assertNull(sendEvents().eventSenderFactory); + + EventSenderFactory f = (ec, hc) -> null; + assertSame(f, sendEvents().eventSender(f).eventSenderFactory); + + assertNull(sendEvents().eventSender(f).eventSender(null).eventSenderFactory); + } + @Test - public void testDiagnosticRecordingInterval() { - EventProcessorBuilder builder = Components.sendEvents().diagnosticRecordingInterval(Duration.ofSeconds(120)); - assertEquals(Duration.ofSeconds(120), builder.diagnosticRecordingInterval); + public void flushInterval() { + EventProcessorBuilder builder1 = Components.sendEvents(); + assertEquals(DEFAULT_FLUSH_INTERVAL, builder1.flushInterval); + + EventProcessorBuilder builder2 = Components.sendEvents().flushInterval(Duration.ofSeconds(120)); + assertEquals(Duration.ofSeconds(120), builder2.flushInterval); + + EventProcessorBuilder builder3 = Components.sendEvents() + .flushInterval(Duration.ofSeconds(120)) + .flushInterval(null); // null sets it back to the default + assertEquals(DEFAULT_FLUSH_INTERVAL, builder3.flushInterval); } + + @Test + public void inlineUsersInEvents() { + assertEquals(false, sendEvents().inlineUsersInEvents); + + assertEquals(true, sendEvents().inlineUsersInEvents(true).inlineUsersInEvents); + assertEquals(false, sendEvents() + .inlineUsersInEvents(true) + .inlineUsersInEvents(false) + .inlineUsersInEvents); + } + + @Test + public void privateAttributeNames() { + assertNull(sendEvents().privateAttributes); + + assertEquals(ImmutableSet.of(UserAttribute.forName("a"), UserAttribute.forName("b")), + sendEvents().privateAttributeNames("a", "b").privateAttributes); + } + + @Test + public void privateAttributes() { + assertEquals(ImmutableSet.of(UserAttribute.EMAIL, UserAttribute.NAME), + sendEvents().privateAttributes(UserAttribute.EMAIL, UserAttribute.NAME).privateAttributes); + } + + @Test + public void userKeysCapacity() { + assertEquals(DEFAULT_USER_KEYS_CAPACITY, sendEvents().userKeysCapacity); + + assertEquals(44, sendEvents().userKeysCapacity(44).userKeysCapacity); + } + @Test - public void testMinimumDiagnosticRecordingIntervalEnforced() { - EventProcessorBuilder builder = Components.sendEvents().diagnosticRecordingInterval(Duration.ofSeconds(10)); - assertEquals(Duration.ofSeconds(60), builder.diagnosticRecordingInterval); + public void usrKeysFlushInterval() { + EventProcessorBuilder builder1 = Components.sendEvents(); + assertEquals(DEFAULT_USER_KEYS_FLUSH_INTERVAL, builder1.userKeysFlushInterval); + + EventProcessorBuilder builder2 = Components.sendEvents().userKeysFlushInterval(Duration.ofSeconds(120)); + assertEquals(Duration.ofSeconds(120), builder2.userKeysFlushInterval); + + EventProcessorBuilder builder3 = Components.sendEvents() + .userKeysFlushInterval(Duration.ofSeconds(120)) + .userKeysFlushInterval(null); // null sets it back to the default + assertEquals(DEFAULT_USER_KEYS_FLUSH_INTERVAL, builder3.userKeysFlushInterval); } } diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceAutoUpdateTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceAutoUpdateTest.java new file mode 100644 index 000000000..1a0d5862e --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceAutoUpdateTest.java @@ -0,0 +1,123 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.launchdarkly.sdk.server.LDConfig; +import com.launchdarkly.sdk.server.TestComponents; +import com.launchdarkly.sdk.server.TestComponents.MockDataSourceUpdates; +import com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.TempDir; +import com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.TempFile; +import com.launchdarkly.sdk.server.interfaces.DataSource; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataStore; + +import org.junit.Test; + +import java.nio.file.Path; +import java.time.Duration; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; + +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; +import static com.launchdarkly.sdk.server.DataStoreTestTypes.toItemsMap; +import static com.launchdarkly.sdk.server.TestComponents.clientContext; +import static com.launchdarkly.sdk.server.TestComponents.inMemoryDataStore; +import static com.launchdarkly.sdk.server.TestUtil.repeatWithTimeout; +import static com.launchdarkly.sdk.server.TestUtil.requireDataSourceStatusEventually; +import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.ALL_FLAG_KEYS; +import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.getResourceContents; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +@SuppressWarnings("javadoc") +public class FileDataSourceAutoUpdateTest { + private final DataStore store; + private MockDataSourceUpdates dataSourceUpdates; + private final LDConfig config = new LDConfig.Builder().build(); + + public FileDataSourceAutoUpdateTest() throws Exception { + store = inMemoryDataStore(); + dataSourceUpdates = TestComponents.dataSourceUpdates(store); + } + + private static FileDataSourceBuilder makeFactoryWithFile(Path path) { + return FileData.dataSource().filePaths(path); + } + + private DataSource makeDataSource(FileDataSourceBuilder builder) { + return builder.createDataSource(clientContext("", config), dataSourceUpdates); + } + + @Test + public void modifiedFileIsNotReloadedIfAutoUpdateIsOff() throws Exception { + try (TempDir dir = TempDir.create()) { + try (TempFile file = dir.tempFile(".json")) { + file.setContents(getResourceContents("flag-only.json")); + FileDataSourceBuilder factory1 = makeFactoryWithFile(file.path); + try (DataSource fp = makeDataSource(factory1)) { + fp.start(); + file.setContents(getResourceContents("segment-only.json")); + Thread.sleep(400); + assertThat(toItemsMap(store.getAll(FEATURES)).size(), equalTo(1)); + assertThat(toItemsMap(store.getAll(SEGMENTS)).size(), equalTo(0)); + } + } + } + } + + // 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 { + try (TempDir dir = TempDir.create()) { + try (TempFile file = dir.tempFile(".json")) { + FileDataSourceBuilder factory1 = makeFactoryWithFile(file.path).autoUpdate(true); + file.setContents(getResourceContents("flag-only.json")); // this file has 1 flag + try (DataSource fp = makeDataSource(factory1)) { + fp.start(); + Thread.sleep(1000); + file.setContents(getResourceContents("all-properties.json")); // this file has all the flags + repeatWithTimeout(Duration.ofSeconds(10), Duration.ofMillis(500), () -> { + if (toItemsMap(store.getAll(FEATURES)).size() == ALL_FLAG_KEYS.size()) { + // success - return a non-null value to make repeatWithTimeout end + return fp; + } + return null; + }); + } + } + } + } + + @Test + public void ifFilesAreBadAtStartTimeAutoUpdateCanStillLoadGoodDataLater() throws Exception { + BlockingQueue statuses = new LinkedBlockingQueue<>(); + dataSourceUpdates.register(statuses::add); + + try (TempDir dir = TempDir.create()) { + try (TempFile file = dir.tempFile(".json")) { + file.setContents("not valid"); + FileDataSourceBuilder factory1 = makeFactoryWithFile(file.path).autoUpdate(true); + try (DataSource fp = makeDataSource(factory1)) { + fp.start(); + Thread.sleep(1000); + file.setContents(getResourceContents("flag-only.json")); // this file has 1 flag + repeatWithTimeout(Duration.ofSeconds(10), Duration.ofMillis(500), () -> { + if (toItemsMap(store.getAll(FEATURES)).size() > 0) { + // success - status is now VALID, after having first been INITIALIZING - can still see that an error occurred + DataSourceStatusProvider.Status status = requireDataSourceStatusEventually(statuses, + DataSourceStatusProvider.State.VALID, DataSourceStatusProvider.State.INITIALIZING); + assertNotNull(status.getLastError()); + assertEquals(DataSourceStatusProvider.ErrorKind.INVALID_DATA, status.getLastError().getKind()); + + return status; + } + return null; + }); + } + } + } + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java index 240b5f1ab..96aa4acb0 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java @@ -1,5 +1,6 @@ package com.launchdarkly.sdk.server.integrations; +import com.google.common.collect.ImmutableSet; import com.launchdarkly.sdk.server.LDConfig; import com.launchdarkly.sdk.server.TestComponents; import com.launchdarkly.sdk.server.TestComponents.MockDataSourceUpdates; @@ -9,11 +10,8 @@ import org.junit.Test; -import java.io.IOException; -import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; -import java.time.Duration; import java.util.concurrent.BlockingQueue; import java.util.concurrent.Future; import java.util.concurrent.LinkedBlockingQueue; @@ -24,17 +22,13 @@ import static com.launchdarkly.sdk.server.DataStoreTestTypes.toItemsMap; import static com.launchdarkly.sdk.server.TestComponents.clientContext; import static com.launchdarkly.sdk.server.TestComponents.inMemoryDataStore; -import static com.launchdarkly.sdk.server.TestUtil.repeatWithTimeout; import static com.launchdarkly.sdk.server.TestUtil.requireDataSourceStatus; -import static com.launchdarkly.sdk.server.TestUtil.requireDataSourceStatusEventually; import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.ALL_FLAG_KEYS; import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.ALL_SEGMENT_KEYS; -import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.getResourceContents; import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.resourceFilePath; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; @SuppressWarnings("javadoc") public class FileDataSourceTest { @@ -43,12 +37,10 @@ public class FileDataSourceTest { private final DataStore store; private MockDataSourceUpdates dataSourceUpdates; private final LDConfig config = new LDConfig.Builder().build(); - private final FileDataSourceBuilder factory; public FileDataSourceTest() throws Exception { store = inMemoryDataStore(); dataSourceUpdates = TestComponents.dataSourceUpdates(store); - factory = makeFactoryWithFile(resourceFilePath("all-properties.json")); } private static FileDataSourceBuilder makeFactoryWithFile(Path path) { @@ -61,6 +53,7 @@ private DataSource makeDataSource(FileDataSourceBuilder builder) { @Test public void flagsAreNotLoadedUntilStart() throws Exception { + FileDataSourceBuilder factory = makeFactoryWithFile(resourceFilePath("all-properties.json")); try (DataSource fp = makeDataSource(factory)) { assertThat(store.isInitialized(), equalTo(false)); assertThat(size(store.getAll(FEATURES).getItems()), equalTo(0)); @@ -70,193 +63,74 @@ public void flagsAreNotLoadedUntilStart() throws Exception { @Test public void flagsAreLoadedOnStart() throws Exception { + FileDataSourceBuilder factory = makeFactoryWithFile(resourceFilePath("all-properties.json")); try (DataSource fp = makeDataSource(factory)) { - fp.start(); - assertThat(store.isInitialized(), equalTo(true)); + verifySuccessfulStart(fp); + assertThat(toItemsMap(store.getAll(FEATURES)).keySet(), equalTo(ALL_FLAG_KEYS)); assertThat(toItemsMap(store.getAll(SEGMENTS)).keySet(), equalTo(ALL_SEGMENT_KEYS)); } } - - @Test - public void startFutureIsCompletedAfterSuccessfulLoad() throws Exception { - try (DataSource fp = makeDataSource(factory)) { - Future future = fp.start(); - assertThat(future.isDone(), equalTo(true)); - } - } - + @Test - public void initializedIsTrueAfterSuccessfulLoad() throws Exception { + public void filePathsCanBeSpecifiedAsStrings() throws Exception { + FileDataSourceBuilder factory = FileData.dataSource().filePaths(resourceFilePath("all-properties.json").toString()); try (DataSource fp = makeDataSource(factory)) { - fp.start(); - assertThat(fp.isInitialized(), equalTo(true)); + verifySuccessfulStart(fp); + + assertThat(toItemsMap(store.getAll(FEATURES)).keySet(), equalTo(ALL_FLAG_KEYS)); + assertThat(toItemsMap(store.getAll(SEGMENTS)).keySet(), equalTo(ALL_SEGMENT_KEYS)); } } @Test - public void statusIsValidAfterSuccessfulLoad() throws Exception { - BlockingQueue statuses = new LinkedBlockingQueue<>(); - dataSourceUpdates.register(statuses::add); - + public void flagsAreLoadedOnStartFromYamlFile() throws Exception { + FileDataSourceBuilder factory = makeFactoryWithFile(resourceFilePath("all-properties.yml")); try (DataSource fp = makeDataSource(factory)) { - fp.start(); - assertThat(fp.isInitialized(), equalTo(true)); + verifySuccessfulStart(fp); - requireDataSourceStatus(statuses, DataSourceStatusProvider.State.VALID); + assertThat(toItemsMap(store.getAll(FEATURES)).keySet(), equalTo(ALL_FLAG_KEYS)); + assertThat(toItemsMap(store.getAll(SEGMENTS)).keySet(), equalTo(ALL_SEGMENT_KEYS)); } } @Test - public void startFutureIsCompletedAfterUnsuccessfulLoad() throws Exception { - factory.filePaths(badFilePath); - try (DataSource fp = makeDataSource(factory)) { - Future future = fp.start(); - assertThat(future.isDone(), equalTo(true)); + public void startSucceedsWithEmptyFile() throws Exception { + try (DataSource fp = makeDataSource(makeFactoryWithFile(resourceFilePath("no-data.json")))) { + verifySuccessfulStart(fp); + + assertThat(toItemsMap(store.getAll(FEATURES)).keySet(), equalTo(ImmutableSet.of())); + assertThat(toItemsMap(store.getAll(SEGMENTS)).keySet(), equalTo(ImmutableSet.of())); } } - @Test - public void initializedIsFalseAfterUnsuccessfulLoad() throws Exception { - factory.filePaths(badFilePath); - try (DataSource fp = makeDataSource(factory)) { - fp.start(); - assertThat(fp.isInitialized(), equalTo(false)); - } - } - - @Test - public void statusIsInitializingAfterUnsuccessfulLoad() throws Exception { + private void verifySuccessfulStart(DataSource fp) { BlockingQueue statuses = new LinkedBlockingQueue<>(); dataSourceUpdates.register(statuses::add); - factory.filePaths(badFilePath); - try (DataSource fp = makeDataSource(factory)) { - fp.start(); - assertThat(fp.isInitialized(), equalTo(false)); - - DataSourceStatusProvider.Status status = requireDataSourceStatus(statuses, DataSourceStatusProvider.State.INITIALIZING); - assertNotNull(status.getLastError()); - assertEquals(DataSourceStatusProvider.ErrorKind.INVALID_DATA, status.getLastError().getKind()); - } - } - - @Test - public void modifiedFileIsNotReloadedIfAutoUpdateIsOff() throws Exception { - try (TempDir dir = TempDir.create()) { - try (TempFile file = dir.tempFile(".json")) { - file.setContents(getResourceContents("flag-only.json")); - FileDataSourceBuilder factory1 = makeFactoryWithFile(file.path); - try (DataSource fp = makeDataSource(factory1)) { - fp.start(); - file.setContents(getResourceContents("segment-only.json")); - Thread.sleep(400); - assertThat(toItemsMap(store.getAll(FEATURES)).size(), equalTo(1)); - assertThat(toItemsMap(store.getAll(SEGMENTS)).size(), equalTo(0)); - } - } - } + Future future = fp.start(); + + assertThat(future.isDone(), equalTo(true)); + assertThat(store.isInitialized(), equalTo(true)); + requireDataSourceStatus(statuses, DataSourceStatusProvider.State.VALID); } - // 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 { - try (TempDir dir = TempDir.create()) { - try (TempFile file = dir.tempFile(".json")) { - System.out.println("dir = " + dir.path + ", file = " + file.path); - FileDataSourceBuilder factory1 = makeFactoryWithFile(file.path).autoUpdate(true); - file.setContents(getResourceContents("flag-only.json")); // this file has 1 flag - try (DataSource fp = makeDataSource(factory1)) { - fp.start(); - Thread.sleep(1000); - file.setContents(getResourceContents("all-properties.json")); // this file has all the flags - repeatWithTimeout(Duration.ofSeconds(10), Duration.ofMillis(500), () -> { - if (toItemsMap(store.getAll(FEATURES)).size() == ALL_FLAG_KEYS.size()) { - // success - return a non-null value to make repeatWithTimeout end - return fp; - } - return null; - }); - } - } + public void startFailsWithNonexistentFile() throws Exception { + try (DataSource fp = makeDataSource(makeFactoryWithFile(badFilePath))) { + verifyUnsuccessfulStart(fp); } } - - @Test - public void ifFilesAreBadAtStartTimeAutoUpdateCanStillLoadGoodDataLater() throws Exception { + + private void verifyUnsuccessfulStart(DataSource fp) { BlockingQueue statuses = new LinkedBlockingQueue<>(); dataSourceUpdates.register(statuses::add); - try (TempDir dir = TempDir.create()) { - try (TempFile file = dir.tempFile(".json")) { - file.setContents("not valid"); - FileDataSourceBuilder factory1 = makeFactoryWithFile(file.path).autoUpdate(true); - try (DataSource fp = makeDataSource(factory1)) { - fp.start(); - Thread.sleep(1000); - file.setContents(getResourceContents("flag-only.json")); // this file has 1 flag - repeatWithTimeout(Duration.ofSeconds(10), Duration.ofMillis(500), () -> { - if (toItemsMap(store.getAll(FEATURES)).size() > 0) { - // success - status is now VALID, after having first been INITIALIZING - can still see that an error occurred - DataSourceStatusProvider.Status status = requireDataSourceStatusEventually(statuses, - DataSourceStatusProvider.State.VALID, DataSourceStatusProvider.State.INITIALIZING); - assertNotNull(status.getLastError()); - assertEquals(DataSourceStatusProvider.ErrorKind.INVALID_DATA, status.getLastError().getKind()); - - return status; - } - return null; - }); - } - } - } - } - - // These helpers ensure that we clean up all temporary files, and also that we only create temporary - // files within our own temporary directories - since creating a file within a shared system temp - // directory might mean there are thousands of other files there, which could be a problem if the - // filesystem watcher implementation has to traverse the directory. - - private static class TempDir implements AutoCloseable { - final Path path; - - private TempDir(Path path) { - this.path = path; - } - - public void close() throws IOException { - Files.delete(path); - } - - public static TempDir create() throws IOException { - return new TempDir(Files.createTempDirectory("java-sdk-tests")); - } - - public TempFile tempFile(String suffix) throws IOException { - return new TempFile(Files.createTempFile(path, "java-sdk-tests", suffix)); - } - } - - private static class TempFile implements AutoCloseable { - final Path path; + Future future = fp.start(); - private TempFile(Path path) { - this.path = path; - } - - @Override - public void close() throws IOException { - delete(); - } - - public void delete() throws IOException { - Files.delete(path); - } - - public void setContents(String content) throws IOException { - Files.write(path, content.getBytes("UTF-8")); - } + assertThat(future.isDone(), equalTo(true)); + assertThat(store.isInitialized(), equalTo(false)); + DataSourceStatusProvider.Status status = requireDataSourceStatus(statuses, DataSourceStatusProvider.State.INITIALIZING); + assertEquals(DataSourceStatusProvider.ErrorKind.INVALID_DATA, status.getLastError().getKind()); } } diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTestData.java b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTestData.java index de1cb8ae5..8e99fbde8 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTestData.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTestData.java @@ -4,6 +4,7 @@ import com.google.common.collect.ImmutableSet; import com.launchdarkly.sdk.LDValue; +import java.io.IOException; import java.net.URISyntaxException; import java.net.URL; import java.nio.file.Files; @@ -43,4 +44,55 @@ public static Path resourceFilePath(String filename) throws URISyntaxException { public static String getResourceContents(String filename) throws Exception { return new String(Files.readAllBytes(resourceFilePath(filename))); } + + // These helpers ensure that we clean up all temporary files, and also that we only create temporary + // files within our own temporary directories - since creating a file within a shared system temp + // directory might mean there are thousands of other files there, which could be a problem if the + // filesystem watcher implementation has to traverse the directory. + + static class TempDir implements AutoCloseable { + final Path path; + + private TempDir(Path path) { + this.path = path; + } + + public void close() throws IOException { + Files.delete(path); + } + + public static TempDir create() throws IOException { + return new TempDir(Files.createTempDirectory("java-sdk-tests")); + } + + public TempFile tempFile(String suffix) throws IOException { + return new TempFile(Files.createTempFile(path, "java-sdk-tests", suffix)); + } + } + + // These helpers ensure that we clean up all temporary files, and also that we only create temporary + // files within our own temporary directories - since creating a file within a shared system temp + // directory might mean there are thousands of other files there, which could be a problem if the + // filesystem watcher implementation has to traverse the directory. + + static class TempFile implements AutoCloseable { + final Path path; + + private TempFile(Path path) { + this.path = path; + } + + @Override + public void close() throws IOException { + delete(); + } + + public void delete() throws IOException { + Files.delete(path); + } + + public void setContents(String content) throws IOException { + Files.write(path, content.getBytes("UTF-8")); + } + } } diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/FlagFileParserTestBase.java b/src/test/java/com/launchdarkly/sdk/server/integrations/FlagFileParserTestBase.java index 4aedd7359..1829cde20 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/FlagFileParserTestBase.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/FlagFileParserTestBase.java @@ -16,7 +16,9 @@ import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.resourceFilePath; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.nullValue; +import static org.junit.Assert.fail; @SuppressWarnings("javadoc") public abstract class FlagFileParserTestBase { @@ -68,10 +70,15 @@ public void canParseFileWithOnlySegment() throws Exception { } } - @Test(expected = FileDataException.class) + @Test public void throwsExpectedErrorForBadFile() throws Exception { try (FileInputStream input = openFile("malformed")) { - parser.parse(input); + try { + parser.parse(input); + fail("expected exception"); + } catch (FileDataException e) { + assertThat(e.getDescription(), not(nullValue())); + } } } diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/HttpConfigurationBuilderTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/HttpConfigurationBuilderTest.java index fd0fdd60c..73253d4eb 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/HttpConfigurationBuilderTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/HttpConfigurationBuilderTest.java @@ -21,6 +21,8 @@ import javax.net.ssl.X509TrustManager; import static com.launchdarkly.sdk.server.TestUtil.getSdkVersion; +import static com.launchdarkly.sdk.server.integrations.HttpConfigurationBuilder.DEFAULT_CONNECT_TIMEOUT; +import static com.launchdarkly.sdk.server.integrations.HttpConfigurationBuilder.DEFAULT_SOCKET_TIMEOUT; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; @@ -40,10 +42,10 @@ private static ImmutableMap.Builder buildBasicHeaders() { @Test public void testDefaults() { HttpConfiguration hc = Components.httpConfiguration().createHttpConfiguration(BASIC_CONFIG); - assertEquals(HttpConfigurationBuilder.DEFAULT_CONNECT_TIMEOUT, hc.getConnectTimeout()); + assertEquals(DEFAULT_CONNECT_TIMEOUT, hc.getConnectTimeout()); assertNull(hc.getProxy()); assertNull(hc.getProxyAuthentication()); - assertEquals(HttpConfigurationBuilder.DEFAULT_SOCKET_TIMEOUT, hc.getSocketTimeout()); + assertEquals(DEFAULT_SOCKET_TIMEOUT, hc.getSocketTimeout()); assertNull(hc.getSslSocketFactory()); assertNull(hc.getTrustManager()); assertEquals(buildBasicHeaders().build(), ImmutableMap.copyOf(hc.getDefaultHeaders())); @@ -55,7 +57,13 @@ public void testConnectTimeout() { .connectTimeout(Duration.ofMillis(999)) .createHttpConfiguration(BASIC_CONFIG); assertEquals(999, hc.getConnectTimeout().toMillis()); - } + + HttpConfiguration hc2 = Components.httpConfiguration() + .connectTimeout(Duration.ofMillis(999)) + .connectTimeout(null) + .createHttpConfiguration(BASIC_CONFIG); + assertEquals(DEFAULT_CONNECT_TIMEOUT, hc2.getConnectTimeout()); +} @Test public void testProxy() { @@ -79,10 +87,16 @@ public void testProxyBasicAuth() { @Test public void testSocketTimeout() { - HttpConfiguration hc = Components.httpConfiguration() + HttpConfiguration hc1 = Components.httpConfiguration() + .socketTimeout(Duration.ofMillis(999)) + .createHttpConfiguration(BASIC_CONFIG); + assertEquals(999, hc1.getSocketTimeout().toMillis()); + + HttpConfiguration hc2 = Components.httpConfiguration() .socketTimeout(Duration.ofMillis(999)) + .socketTimeout(null) .createHttpConfiguration(BASIC_CONFIG); - assertEquals(999, hc.getSocketTimeout().toMillis()); + assertEquals(DEFAULT_SOCKET_TIMEOUT, hc2.getSocketTimeout()); } @Test diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/MockPersistentDataStore.java b/src/test/java/com/launchdarkly/sdk/server/integrations/MockPersistentDataStore.java index 3f7fca20a..f1542e4b5 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/MockPersistentDataStore.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/MockPersistentDataStore.java @@ -2,11 +2,11 @@ import com.google.common.collect.ImmutableList; import com.launchdarkly.sdk.server.DataStoreTestTypes.TestItem; -import com.launchdarkly.sdk.server.interfaces.PersistentDataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.SerializedItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.PersistentDataStore; import java.io.IOException; import java.util.HashMap; @@ -26,6 +26,7 @@ public static final class MockDatabaseInstance { public final AtomicBoolean inited; public final AtomicInteger initedCount = new AtomicInteger(0); public volatile int initedQueryCount; + public volatile int getQueryCount; public volatile boolean persistOnlyAsString; public volatile boolean unavailable; public volatile RuntimeException fakeError; @@ -56,6 +57,7 @@ public void close() throws IOException { @Override public SerializedItemDescriptor get(DataKind kind, String key) { + getQueryCount++; maybeThrow(); if (data.containsKey(kind)) { SerializedItemDescriptor item = data.get(kind).get(key); diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreBuilderTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreBuilderTest.java new file mode 100644 index 000000000..aac3f122a --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreBuilderTest.java @@ -0,0 +1,63 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.launchdarkly.sdk.server.integrations.PersistentDataStoreBuilder.StaleValuesPolicy; +import com.launchdarkly.sdk.server.interfaces.PersistentDataStoreFactory; + +import org.junit.Test; + +import java.time.Duration; + +import static com.launchdarkly.sdk.server.Components.persistentDataStore; +import static com.launchdarkly.sdk.server.integrations.PersistentDataStoreBuilder.DEFAULT_CACHE_TTL; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +@SuppressWarnings("javadoc") +public class PersistentDataStoreBuilderTest { + private static final PersistentDataStoreFactory factory = context -> null; + + @Test + public void factory() { + assertSame(factory, persistentDataStore(factory).persistentDataStoreFactory); + } + + @Test + public void cacheTime() { + assertEquals(DEFAULT_CACHE_TTL, persistentDataStore(factory).cacheTime); + + assertEquals(Duration.ofMinutes(3), persistentDataStore(factory).cacheTime(Duration.ofMinutes(3)).cacheTime); + + assertEquals(Duration.ofMillis(3), persistentDataStore(factory).cacheMillis(3).cacheTime); + + assertEquals(Duration.ofSeconds(3), persistentDataStore(factory).cacheSeconds(3).cacheTime); + + assertEquals(DEFAULT_CACHE_TTL, + persistentDataStore(factory).cacheTime(Duration.ofMinutes(3)).cacheTime(null).cacheTime); + + assertEquals(Duration.ZERO, persistentDataStore(factory).noCaching().cacheTime); + + assertEquals(Duration.ofMillis(-1), persistentDataStore(factory).cacheForever().cacheTime); + } + + @Test + public void staleValuesPolicy() { + assertEquals(StaleValuesPolicy.EVICT, persistentDataStore(factory).staleValuesPolicy); + + assertEquals(StaleValuesPolicy.REFRESH, + persistentDataStore(factory).staleValuesPolicy(StaleValuesPolicy.REFRESH).staleValuesPolicy); + + assertEquals(StaleValuesPolicy.EVICT, + persistentDataStore(factory).staleValuesPolicy(StaleValuesPolicy.REFRESH).staleValuesPolicy(null).staleValuesPolicy); + } + + @Test + public void recordCacheStats() { + assertFalse(persistentDataStore(factory).recordCacheStats); + + assertTrue(persistentDataStore(factory).recordCacheStats(true).recordCacheStats); + + assertFalse(persistentDataStore(factory).recordCacheStats(true).recordCacheStats(false).recordCacheStats); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/PollingDataSourceBuilderTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/PollingDataSourceBuilderTest.java new file mode 100644 index 000000000..9adc68341 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/PollingDataSourceBuilderTest.java @@ -0,0 +1,37 @@ +package com.launchdarkly.sdk.server.integrations; + +import org.junit.Test; + +import java.net.URI; +import java.time.Duration; + +import static com.launchdarkly.sdk.server.Components.pollingDataSource; +import static com.launchdarkly.sdk.server.integrations.PollingDataSourceBuilder.DEFAULT_POLL_INTERVAL; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +@SuppressWarnings("javadoc") +public class PollingDataSourceBuilderTest { + @Test + public void baseURI() { + assertNull(pollingDataSource().baseURI); + + assertEquals(URI.create("x"), pollingDataSource().baseURI(URI.create("x")).baseURI); + + assertNull(pollingDataSource().baseURI(URI.create("X")).baseURI(null).baseURI); + } + + @Test + public void pollInterval() { + assertEquals(DEFAULT_POLL_INTERVAL, pollingDataSource().pollInterval); + + assertEquals(Duration.ofMinutes(7), + pollingDataSource().pollInterval(Duration.ofMinutes(7)).pollInterval); + + assertEquals(DEFAULT_POLL_INTERVAL, + pollingDataSource().pollInterval(Duration.ofMinutes(7)).pollInterval(null).pollInterval); + + assertEquals(DEFAULT_POLL_INTERVAL, + pollingDataSource().pollInterval(Duration.ofMillis(1)).pollInterval); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/StreamingDataSourceBuilderTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/StreamingDataSourceBuilderTest.java new file mode 100644 index 000000000..1fd337d6e --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/StreamingDataSourceBuilderTest.java @@ -0,0 +1,43 @@ +package com.launchdarkly.sdk.server.integrations; + +import org.junit.Test; + +import java.net.URI; +import java.time.Duration; + +import static com.launchdarkly.sdk.server.Components.streamingDataSource; +import static com.launchdarkly.sdk.server.integrations.StreamingDataSourceBuilder.DEFAULT_INITIAL_RECONNECT_DELAY; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +@SuppressWarnings("javadoc") +public class StreamingDataSourceBuilderTest { + @Test + public void baseURI() { + assertNull(streamingDataSource().baseURI); + + assertEquals(URI.create("x"), streamingDataSource().baseURI(URI.create("x")).baseURI); + + assertNull(streamingDataSource().baseURI(URI.create("X")).baseURI(null).baseURI); + } + + @Test + public void initialReconnectDelay() { + assertEquals(DEFAULT_INITIAL_RECONNECT_DELAY, streamingDataSource().initialReconnectDelay); + + assertEquals(Duration.ofMillis(222), + streamingDataSource().initialReconnectDelay(Duration.ofMillis(222)).initialReconnectDelay); + + assertEquals(DEFAULT_INITIAL_RECONNECT_DELAY, + streamingDataSource().initialReconnectDelay(Duration.ofMillis(222)).initialReconnectDelay(null).initialReconnectDelay); + } + + @Test + public void pollingBaseURI() { + assertNull(streamingDataSource().pollingBaseURI); + + assertEquals(URI.create("x"), streamingDataSource().pollingBaseURI(URI.create("x")).pollingBaseURI); + + assertNull(streamingDataSource().pollingBaseURI(URI.create("x")).pollingBaseURI(null).pollingBaseURI); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProviderTypesTest.java b/src/test/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProviderTypesTest.java new file mode 100644 index 000000000..39a9debd3 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProviderTypesTest.java @@ -0,0 +1,111 @@ +package com.launchdarkly.sdk.server.interfaces; + +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorInfo; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorKind; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.State; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.Status; + +import org.junit.Test; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Supplier; + +import static com.launchdarkly.sdk.server.TestUtil.verifyEqualityForType; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.nullValue; +import static org.hamcrest.Matchers.sameInstance; + +@SuppressWarnings("javadoc") +public class DataSourceStatusProviderTypesTest { + @Test + public void statusProperties() { + Instant time = Instant.ofEpochMilli(10000); + ErrorInfo e = ErrorInfo.fromHttpError(401); + Status s = new Status(State.VALID, time, e); + assertThat(s.getState(), equalTo(State.VALID)); + assertThat(s.getStateSince(), equalTo(time)); + assertThat(s.getLastError(), sameInstance(e)); + } + + @Test + public void statusEquality() { + List> allPermutations = new ArrayList<>(); + for (State state: State.values()) { + for (Instant time: new Instant[] { Instant.ofEpochMilli(1000), Instant.ofEpochMilli(2000) }) { + for (ErrorInfo e: new ErrorInfo[] { null, ErrorInfo.fromHttpError(400), ErrorInfo.fromHttpError(401) }) { + allPermutations.add(() -> new Status(state, time, e)); + } + } + } + verifyEqualityForType(allPermutations); + } + + @Test + public void statusStringRepresentation() { + Status s1 = new Status(State.VALID, Instant.now(), null); + assertThat(s1.toString(), equalTo("Status(VALID," + s1.getStateSince() + ",null)")); + + Status s2 = new Status(State.VALID, Instant.now(), ErrorInfo.fromHttpError(401)); + assertThat(s2.toString(), equalTo("Status(VALID," + s2.getStateSince() + "," + s2.getLastError() + ")")); + } + + @Test + public void errorInfoProperties() { + Instant time = Instant.ofEpochMilli(10000); + ErrorInfo e1 = new ErrorInfo(ErrorKind.ERROR_RESPONSE, 401, "nope", time); + assertThat(e1.getKind(), equalTo(ErrorKind.ERROR_RESPONSE)); + assertThat(e1.getStatusCode(), equalTo(401)); + assertThat(e1.getMessage(), equalTo("nope")); + assertThat(e1.getTime(), equalTo(time)); + + ErrorInfo e2 = ErrorInfo.fromHttpError(401); + assertThat(e2.getKind(), equalTo(ErrorKind.ERROR_RESPONSE)); + assertThat(e2.getStatusCode(), equalTo(401)); + assertThat(e2.getMessage(), nullValue()); + assertThat(e2.getTime(), not(nullValue())); + + Exception ex = new Exception("sorry"); + ErrorInfo e3 = ErrorInfo.fromException(ErrorKind.UNKNOWN, ex); + assertThat(e3.getKind(), equalTo(ErrorKind.UNKNOWN)); + assertThat(e3.getStatusCode(), equalTo(0)); + assertThat(e3.getMessage(), equalTo(ex.toString())); + assertThat(e3.getTime(), not(nullValue())); + } + + @Test + public void errorInfoEquality() { + List> allPermutations = new ArrayList<>(); + for (ErrorKind kind: ErrorKind.values()) { + for (int statusCode: new int[] { 0, 1 }) { + for (String message: new String[] { null, "a", "b" }) { + for (Instant time: new Instant[] { Instant.ofEpochMilli(1000), Instant.ofEpochMilli(2000) }) { + allPermutations.add(() -> new ErrorInfo(kind, statusCode, message, time)); + } + } + } + } + verifyEqualityForType(allPermutations); + } + + @Test + public void errorStringRepresentation() { + ErrorInfo e1 = new ErrorInfo(ErrorKind.ERROR_RESPONSE, 401, null, Instant.now()); + assertThat(e1.toString(), equalTo("ERROR_RESPONSE(401)@" + e1.getTime())); + + ErrorInfo e2 = new ErrorInfo(ErrorKind.ERROR_RESPONSE, 401, "nope", Instant.now()); + assertThat(e2.toString(), equalTo("ERROR_RESPONSE(401,nope)@" + e2.getTime())); + + ErrorInfo e3 = new ErrorInfo(ErrorKind.NETWORK_ERROR, 0, "hello", Instant.now()); + assertThat(e3.toString(), equalTo("NETWORK_ERROR(hello)@" + e3.getTime())); + + ErrorInfo e4 = new ErrorInfo(ErrorKind.STORE_ERROR, 0, null, Instant.now()); + assertThat(e4.toString(), equalTo("STORE_ERROR@" + e4.getTime())); + + ErrorInfo e5 = new ErrorInfo(ErrorKind.UNKNOWN, 0, null, null); + assertThat(e5.toString(), equalTo("UNKNOWN")); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProviderTypesTest.java b/src/test/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProviderTypesTest.java new file mode 100644 index 000000000..59647d21c --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProviderTypesTest.java @@ -0,0 +1,80 @@ +package com.launchdarkly.sdk.server.interfaces; + +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider.CacheStats; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider.Status; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Supplier; + +import static com.launchdarkly.sdk.server.TestUtil.verifyEqualityForType; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +@SuppressWarnings("javadoc") +public class DataStoreStatusProviderTypesTest { + @Test + public void statusProperties() { + Status s1 = new Status(true, false); + assertThat(s1.isAvailable(), equalTo(true)); + assertThat(s1.isRefreshNeeded(), equalTo(false)); + + Status s2 = new Status(false, true); + assertThat(s2.isAvailable(), equalTo(false)); + assertThat(s2.isRefreshNeeded(), equalTo(true)); + } + + @Test + public void statusEquality() { + List> allPermutations = new ArrayList<>(); + allPermutations.add(() -> new Status(false, false)); + allPermutations.add(() -> new Status(false, true)); + allPermutations.add(() -> new Status(true, false)); + allPermutations.add(() -> new Status(true, true)); + verifyEqualityForType(allPermutations); + } + + @Test + public void statusStringRepresentation() { + assertThat(new Status(true, false).toString(), equalTo("Status(true,false)")); + } + + @Test + public void cacheStatsProperties() { + CacheStats stats = new CacheStats(1, 2, 3, 4, 5, 6); + assertThat(stats.getHitCount(), equalTo(1L)); + assertThat(stats.getMissCount(), equalTo(2L)); + assertThat(stats.getLoadSuccessCount(), equalTo(3L)); + assertThat(stats.getLoadExceptionCount(), equalTo(4L)); + assertThat(stats.getTotalLoadTime(), equalTo(5L)); + assertThat(stats.getEvictionCount(), equalTo(6L)); + } + + @Test + public void cacheStatsEquality() { + List> allPermutations = new ArrayList<>(); + int[] values = new int[] { 0, 1, 2 }; + for (int hit: values) { + for (int miss: values) { + for (int loadSuccess: values) { + for (int loadException: values) { + for (int totalLoad: values) { + for (int eviction: values) { + allPermutations.add(() -> new CacheStats(hit, miss, loadSuccess, loadException, totalLoad, eviction)); + } + } + } + } + } + } + verifyEqualityForType(allPermutations); + } + + @Test + public void cacheStatsStringRepresentation() { + CacheStats stats = new CacheStats(1, 2, 3, 4, 5, 6); + assertThat(stats.toString(), equalTo("{hit=1, miss=2, loadSuccess=3, loadException=4, totalLoadTime=5, evictionCount=6}")); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypesTest.java b/src/test/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypesTest.java new file mode 100644 index 000000000..fc7889637 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypesTest.java @@ -0,0 +1,165 @@ +package com.launchdarkly.sdk.server.interfaces; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSortedMap; +import com.launchdarkly.sdk.server.DataModel; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.SerializedItemDescriptor; + +import org.junit.Test; + +import java.util.AbstractMap; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; +import java.util.function.Supplier; + +import static com.launchdarkly.sdk.server.TestUtil.verifyEqualityForType; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.emptyIterable; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.nullValue; +import static org.hamcrest.Matchers.sameInstance; + +@SuppressWarnings("javadoc") +public class DataStoreTypesTest { + @Test + public void dataKindProperties() { + Function serializer = item -> "version=" + item.getVersion(); + Function deserializer = s -> new ItemDescriptor(0, s); + + DataKind k = new DataKind("foo", serializer, deserializer); + + assertThat(k.getName(), equalTo("foo")); + assertThat(k.serialize(new ItemDescriptor(9, null)), equalTo("version=9")); + assertThat(k.deserialize("x"), equalTo(new ItemDescriptor(0, "x"))); + + assertThat(k.toString(), equalTo("DataKind(foo)")); + } + + @Test + public void itemDescriptorProperties() { + Object o = new Object(); + ItemDescriptor i1 = new ItemDescriptor(1, o); + assertThat(i1.getVersion(), equalTo(1)); + assertThat(i1.getItem(), sameInstance(o)); + + ItemDescriptor i2 = ItemDescriptor.deletedItem(2); + assertThat(i2.getVersion(), equalTo(2)); + assertThat(i2.getItem(), nullValue()); + } + + @Test + public void itemDescriptorEquality() { + List> allPermutations = new ArrayList<>(); + for (int version: new int[] { 1, 2 }) { + for (Object item: new Object[] { null, "a", "b" }) { + allPermutations.add(() -> new ItemDescriptor(version, item)); + } + } + verifyEqualityForType(allPermutations); + } + + @Test + public void itemDescriptorStringRepresentation() { + assertThat(new ItemDescriptor(1, "a").toString(), equalTo("ItemDescriptor(1,a)")); + assertThat(new ItemDescriptor(2, null).toString(), equalTo("ItemDescriptor(2,null)")); + } + + @Test + public void serializedItemDescriptorProperties() { + SerializedItemDescriptor si1 = new SerializedItemDescriptor(1, false, "x"); + assertThat(si1.getVersion(), equalTo(1)); + assertThat(si1.isDeleted(), equalTo(false)); + assertThat(si1.getSerializedItem(), equalTo("x")); + + SerializedItemDescriptor si2 = new SerializedItemDescriptor(2, true, null); + assertThat(si2.getVersion(), equalTo(2)); + assertThat(si2.isDeleted(), equalTo(true)); + assertThat(si2.getSerializedItem(), nullValue()); + } + + @Test + public void serializedItemDescriptorEquality() { + List> allPermutations = new ArrayList<>(); + for (int version: new int[] { 1, 2 }) { + for (boolean deleted: new boolean[] { true, false }) { + for (String item: new String[] { null, "a", "b" }) { + allPermutations.add(() -> new SerializedItemDescriptor(version, deleted, item)); + } + } + } + verifyEqualityForType(allPermutations); + } + + @Test + public void serializedItemDescriptorStringRepresentation() { + assertThat(new SerializedItemDescriptor(1, false, "a").toString(), equalTo("SerializedItemDescriptor(1,false,a)")); + assertThat(new SerializedItemDescriptor(2, true, null).toString(), equalTo("SerializedItemDescriptor(2,true,null)")); + } + + @SuppressWarnings("unchecked") + @Test + public void keyedItemsProperties() { + ItemDescriptor item1 = new ItemDescriptor(1, "a"); + ItemDescriptor item2 = new ItemDescriptor(2, "b"); + + KeyedItems items = new KeyedItems<>(ImmutableSortedMap.of("key1", item1, "key2", item2).entrySet()); + + assertThat(items.getItems(), contains( + new AbstractMap.SimpleEntry<>("key1", item1), + new AbstractMap.SimpleEntry<>("key2", item2) + )); + + KeyedItems emptyItems = new KeyedItems<>(null); + + assertThat(emptyItems.getItems(), emptyIterable()); + } + + @Test + public void keyedItemsEquality() { + List>> allPermutations = new ArrayList<>(); + for (String key: new String[] { "key1", "key2"}) { + for (int version: new int[] { 1, 2 }) { + for (String data: new String[] { null, "a", "b" }) { + allPermutations.add(() -> new KeyedItems<>(ImmutableMap.of(key, new ItemDescriptor(version, data)).entrySet())); + } + } + } + verifyEqualityForType(allPermutations); + } + + @SuppressWarnings("unchecked") + @Test + public void fullDataSetProperties() { + ItemDescriptor item1 = new ItemDescriptor(1, "a"); + KeyedItems items = new KeyedItems<>(ImmutableMap.of("key1", item1).entrySet()); + FullDataSet data = new FullDataSet<>(ImmutableMap.of(DataModel.FEATURES, items).entrySet()); + + assertThat(data.getData(), contains( + new AbstractMap.SimpleEntry<>(DataModel.FEATURES, items) + )); + + FullDataSet emptyData = new FullDataSet<>(null); + + assertThat(emptyData.getData(), emptyIterable()); + } + + @Test + public void fullDataSetEquality() { + List>> allPermutations = new ArrayList<>(); + for (DataKind kind: new DataKind[] { DataModel.FEATURES, DataModel.SEGMENTS }) { + for (int version: new int[] { 1, 2 }) { + allPermutations.add(() -> new FullDataSet<>( + ImmutableMap.of(kind, + new KeyedItems<>(ImmutableMap.of("key", new ItemDescriptor(version, "a")).entrySet()) + ).entrySet())); + } + } + verifyEqualityForType(allPermutations); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/interfaces/HttpAuthenticationTypesTest.java b/src/test/java/com/launchdarkly/sdk/server/interfaces/HttpAuthenticationTypesTest.java new file mode 100644 index 000000000..fe71a7530 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/interfaces/HttpAuthenticationTypesTest.java @@ -0,0 +1,18 @@ +package com.launchdarkly.sdk.server.interfaces; + +import com.launchdarkly.sdk.server.interfaces.HttpAuthentication.Challenge; + +import org.junit.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +@SuppressWarnings("javadoc") +public class HttpAuthenticationTypesTest { + @Test + public void challengeProperties() { + Challenge c = new Challenge("Basic", "realm"); + assertThat(c.getScheme(), equalTo("Basic")); + assertThat(c.getRealm(), equalTo("realm")); + } +} diff --git a/src/test/resources/filesource/no-data.json b/src/test/resources/filesource/no-data.json new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/resources/filesource/value-only.json b/src/test/resources/filesource/value-only.json index ddf99a41d..4b6444ef7 100644 --- a/src/test/resources/filesource/value-only.json +++ b/src/test/resources/filesource/value-only.json @@ -1,3 +1,4 @@ + { "flagValues": { "flag2": "value2" From 55f14d155678608a8e7f86c26e21302d21b9a972 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 28 May 2020 21:51:12 -0700 Subject: [PATCH 500/673] re-simplify DataBuilder --- .../sdk/server/DataStoreTestTypes.java | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/src/test/java/com/launchdarkly/sdk/server/DataStoreTestTypes.java b/src/test/java/com/launchdarkly/sdk/server/DataStoreTestTypes.java index 1a4d4c6c2..793eb852d 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataStoreTestTypes.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataStoreTestTypes.java @@ -1,7 +1,7 @@ package com.launchdarkly.sdk.server; +import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; -import com.google.common.collect.Iterables; import com.google.common.collect.Maps; import com.launchdarkly.sdk.server.DataModel.VersionedData; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; @@ -11,9 +11,8 @@ import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.SerializedItemDescriptor; import java.util.AbstractMap; -import java.util.ArrayList; import java.util.HashMap; -import java.util.List; +import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; @@ -136,7 +135,7 @@ private static ItemDescriptor deserializeTestItem(String s) { } public static class DataBuilder { - private Map>> data = new HashMap<>(); + private Map> data = new HashMap<>(); public DataBuilder add(DataKind kind, TestItem... items) { return addAny(kind, items); @@ -144,21 +143,20 @@ public DataBuilder add(DataKind kind, TestItem... items) { // This is defined separately because test code that's outside of this package can't see DataModel.VersionedData public DataBuilder addAny(DataKind kind, VersionedData... items) { - List> itemsList = data.get(kind); - if (itemsList == null) { - itemsList = new ArrayList<>(); - data.put(kind, itemsList); + Map itemsMap = data.get(kind); + if (itemsMap == null) { + itemsMap = new LinkedHashMap<>(); // use LinkedHashMap to preserve insertion order + data.put(kind, itemsMap); } for (VersionedData item: items) { - itemsList.removeIf(e -> e.getKey().equals(item.getKey())); - itemsList.add(new AbstractMap.SimpleEntry<>(item.getKey(), new ItemDescriptor(item.getVersion(), item))); + itemsMap.put(item.getKey(), new ItemDescriptor(item.getVersion(), item)); } return this; } public DataBuilder remove(DataKind kind, String key) { if (data.get(kind) != null) { - data.get(kind).removeIf(e -> e.getKey().equals(key)); + data.get(kind).remove(key); } return this; } @@ -166,18 +164,22 @@ public DataBuilder remove(DataKind kind, String key) { public FullDataSet build() { return new FullDataSet<>( ImmutableMap.copyOf( - Maps.transformValues(data, itemsList -> new KeyedItems<>(itemsList))).entrySet() + Maps.transformValues(data, itemsMap -> + new KeyedItems<>(ImmutableList.copyOf(itemsMap.entrySet())) + )).entrySet() ); } public FullDataSet buildSerialized() { return new FullDataSet<>( ImmutableMap.copyOf( - Maps.transformEntries(data, (kind, itemsList) -> + Maps.transformEntries(data, (kind, itemsMap) -> new KeyedItems<>( - Iterables.transform(itemsList, e -> - new AbstractMap.SimpleEntry<>(e.getKey(), DataStoreTestTypes.toSerialized(kind, e.getValue()))) - )) + ImmutableMap.copyOf( + Maps.transformValues(itemsMap, item -> DataStoreTestTypes.toSerialized(kind, item)) + ).entrySet() + ) + ) ).entrySet()); } } From 3df7da4a933e5ca070af0bc3f772909d66e38638 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 28 May 2020 22:38:40 -0700 Subject: [PATCH 501/673] increase timeouts --- .../sdk/server/DataSourceStatusProviderImplTest.java | 3 ++- .../sdk/server/DataStoreStatusProviderImplTest.java | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/launchdarkly/sdk/server/DataSourceStatusProviderImplTest.java b/src/test/java/com/launchdarkly/sdk/server/DataSourceStatusProviderImplTest.java index 39f912972..771399693 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataSourceStatusProviderImplTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataSourceStatusProviderImplTest.java @@ -13,6 +13,7 @@ import java.util.concurrent.LinkedBlockingQueue; import static com.launchdarkly.sdk.server.TestComponents.sharedExecutor; +import static com.launchdarkly.sdk.server.TestUtil.awaitValue; import static com.launchdarkly.sdk.server.TestUtil.expectNoMoreValues; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; @@ -61,7 +62,7 @@ public void statusListeners() throws Exception { updates.updateStatus(State.VALID, null); - Status newStatus = TestUtil.awaitValue(statuses, Duration.ofMillis(200)); + Status newStatus = awaitValue(statuses, Duration.ofMillis(300)); assertThat(newStatus.getState(), equalTo(State.VALID)); expectNoMoreValues(unwantedStatuses, Duration.ofMillis(100)); diff --git a/src/test/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImplTest.java b/src/test/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImplTest.java index b7b248951..6c14c886d 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImplTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImplTest.java @@ -17,6 +17,7 @@ import java.util.concurrent.LinkedBlockingQueue; import static com.launchdarkly.sdk.server.TestComponents.sharedExecutor; +import static com.launchdarkly.sdk.server.TestUtil.awaitValue; import static com.launchdarkly.sdk.server.TestUtil.expectNoMoreValues; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; @@ -55,7 +56,7 @@ public void statusListeners() throws Exception { updates.updateStatus(new Status(false, false)); - Status newStatus = TestUtil.awaitValue(statuses, Duration.ofMillis(200)); + Status newStatus = awaitValue(statuses, Duration.ofMillis(300)); assertThat(newStatus, equalTo(new Status(false, false))); expectNoMoreValues(unwantedStatuses, Duration.ofMillis(100)); From 6fc2a7a17aff4de7000d34249d9227d1da37cac9 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 29 May 2020 10:40:04 -0700 Subject: [PATCH 502/673] misc fixes --- .../sdk/server/PersistentDataStoreWrapper.java | 12 ++++++------ .../sdk/server/DataSourceStatusProviderImplTest.java | 2 +- .../sdk/server/DataStoreStatusProviderImplTest.java | 2 +- .../sdk/server/PersistentDataStoreWrapperTest.java | 7 +++++++ 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapper.java b/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapper.java index a620c2d31..071928645 100644 --- a/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapper.java +++ b/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapper.java @@ -152,14 +152,14 @@ public boolean isInitialized() { return true; } boolean result; - if (initCache != null) { - try { + try { + if (initCache != null) { result = initCache.get(""); - } catch (ExecutionException e) { - result = false; + } else { + result = core.isInitialized(); } - } else { - result = core.isInitialized(); + } catch (Exception e) { + result = false; } if (result) { inited.set(true); diff --git a/src/test/java/com/launchdarkly/sdk/server/DataSourceStatusProviderImplTest.java b/src/test/java/com/launchdarkly/sdk/server/DataSourceStatusProviderImplTest.java index 771399693..91022b9f5 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataSourceStatusProviderImplTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataSourceStatusProviderImplTest.java @@ -62,7 +62,7 @@ public void statusListeners() throws Exception { updates.updateStatus(State.VALID, null); - Status newStatus = awaitValue(statuses, Duration.ofMillis(300)); + Status newStatus = awaitValue(statuses, Duration.ofMillis(500)); assertThat(newStatus.getState(), equalTo(State.VALID)); expectNoMoreValues(unwantedStatuses, Duration.ofMillis(100)); diff --git a/src/test/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImplTest.java b/src/test/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImplTest.java index 6c14c886d..8b395fe7c 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImplTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImplTest.java @@ -56,7 +56,7 @@ public void statusListeners() throws Exception { updates.updateStatus(new Status(false, false)); - Status newStatus = awaitValue(statuses, Duration.ofMillis(300)); + Status newStatus = awaitValue(statuses, Duration.ofMillis(500)); assertThat(newStatus, equalTo(new Status(false, false))); expectNoMoreValues(unwantedStatuses, Duration.ofMillis(100)); diff --git a/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperTest.java b/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperTest.java index 4c881181d..439f73606 100644 --- a/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperTest.java @@ -493,6 +493,13 @@ public void initializedCanCacheFalseResult() throws Exception { } } + @Test + public void isInitializedCatchesException() throws Exception { + core.fakeError = FAKE_ERROR; + + assertThat(wrapper.isInitialized(), is(false)); + } + @Test public void canGetCacheStats() throws Exception { try (PersistentDataStoreWrapper w = new PersistentDataStoreWrapper( From 8b27de5a31298a3aff40953c41251ea785c704fe Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 29 May 2020 10:47:10 -0700 Subject: [PATCH 503/673] rm unnecessary override --- build.gradle | 1 - 1 file changed, 1 deletion(-) diff --git a/build.gradle b/build.gradle index b6885fe6d..3775bde6a 100644 --- a/build.gradle +++ b/build.gradle @@ -452,7 +452,6 @@ jacocoTestCoverageVerification { "LDClient.secureModeHash(com.launchdarkly.sdk.LDUser)": 4, "PersistentDataStoreStatusManager.1.run()": 2, "PersistentDataStoreWrapper.PersistentDataStoreWrapper(com.launchdarkly.sdk.server.interfaces.PersistentDataStore, java.time.Duration, com.launchdarkly.sdk.server.integrations.PersistentDataStoreBuilder.StaleValuesPolicy, boolean, com.launchdarkly.sdk.server.interfaces.DataStoreUpdates, java.util.concurrent.ScheduledExecutorService)": 2, - "PersistentDataStoreWrapper.isInitialized()": 2, "PersistentDataStoreWrapper.getAll(com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind)": 3, "PersistentDataStoreWrapper.deserialize(com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind, com.launchdarkly.sdk.server.interfaces.DataStoreTypes.SerializedItemDescriptor)": 2, "SemanticVersion.parse(java.lang.String, boolean)": 2, From 43778a39865d6227c9d9e37c037ee2ca2c6f2017 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 29 May 2020 11:18:24 -0700 Subject: [PATCH 504/673] indents --- .../com/launchdarkly/sdk/server/DataStoreTestTypes.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/test/java/com/launchdarkly/sdk/server/DataStoreTestTypes.java b/src/test/java/com/launchdarkly/sdk/server/DataStoreTestTypes.java index 793eb852d..5d728d018 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataStoreTestTypes.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataStoreTestTypes.java @@ -164,9 +164,9 @@ public DataBuilder remove(DataKind kind, String key) { public FullDataSet build() { return new FullDataSet<>( ImmutableMap.copyOf( - Maps.transformValues(data, itemsMap -> - new KeyedItems<>(ImmutableList.copyOf(itemsMap.entrySet())) - )).entrySet() + Maps.transformValues(data, itemsMap -> + new KeyedItems<>(ImmutableList.copyOf(itemsMap.entrySet())) + )).entrySet() ); } From dbe962f72fe8454f9eaf21fd76f161601d617a3b Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 29 May 2020 11:14:13 -0700 Subject: [PATCH 505/673] update benchmark code for API change --- .../com/launchdarkly/sdk/server/EventProcessorBenchmarks.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 45ef5d702..955788e53 100644 --- a/benchmarks/src/jmh/java/com/launchdarkly/sdk/server/EventProcessorBenchmarks.java +++ b/benchmarks/src/jmh/java/com/launchdarkly/sdk/server/EventProcessorBenchmarks.java @@ -1,6 +1,7 @@ package com.launchdarkly.sdk.server; import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.interfaces.BasicConfiguration; import com.launchdarkly.sdk.server.interfaces.Event; import com.launchdarkly.sdk.server.interfaces.EventProcessor; import com.launchdarkly.sdk.server.interfaces.EventSender; @@ -140,7 +141,7 @@ private static final class MockEventSenderFactory implements EventSenderFactory } @Override - public EventSender createEventSender(String arg0, HttpConfiguration arg1) { + public EventSender createEventSender(BasicConfiguration arg0, HttpConfiguration arg1) { return instance; } } From 73062caa7be871cb16b1376b30cd933f89c98f47 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 29 May 2020 17:05:44 -0700 Subject: [PATCH 506/673] support loading file data from a classpath resource --- .../sdk/server/integrations/FileData.java | 4 +- .../integrations/FileDataSourceBuilder.java | 94 +++++++++++++++++-- .../integrations/FileDataSourceImpl.java | 35 +++---- .../integrations/FileDataSourceParsing.java | 12 +-- .../server/integrations/DataLoaderTest.java | 47 +++++++--- .../FileDataSourceAutoUpdateTest.java | 14 +++ .../integrations/FileDataSourceTest.java | 8 ++ .../integrations/FileDataSourceTestData.java | 6 +- 8 files changed, 176 insertions(+), 44 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/FileData.java b/src/main/java/com/launchdarkly/sdk/server/integrations/FileData.java index f38722cf1..40683fbdb 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/FileData.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/FileData.java @@ -12,8 +12,8 @@ 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 allows you to use local files (or classpath resources containing file data) 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 diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBuilder.java b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBuilder.java index 0c4228a4b..5df65352b 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBuilder.java @@ -1,10 +1,14 @@ package com.launchdarkly.sdk.server.integrations; +import com.google.common.io.ByteStreams; import com.launchdarkly.sdk.server.interfaces.ClientContext; import com.launchdarkly.sdk.server.interfaces.DataSource; import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; import java.nio.file.InvalidPathException; import java.nio.file.Path; import java.nio.file.Paths; @@ -12,16 +16,17 @@ 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.sdk.server.LDConfig.Builder#dataSource(DataSourceFactory)}. + * 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), and/or + * {@link #classpathResources(String...)} to specify classpath data resources; then pass the resulting + * object to {@link com.launchdarkly.sdk.server.LDConfig.Builder#dataSource(DataSourceFactory)}. *

    * For more details, see {@link FileData}. * * @since 4.12.0 */ public final class FileDataSourceBuilder implements DataSourceFactory { - private final List sources = new ArrayList<>(); + final List sources = new ArrayList<>(); // visible for tests private boolean autoUpdate = false; /** @@ -37,7 +42,7 @@ public final class FileDataSourceBuilder implements DataSourceFactory { */ public FileDataSourceBuilder filePaths(String... filePaths) throws InvalidPathException { for (String p: filePaths) { - sources.add(Paths.get(p)); + sources.add(new FilePathSourceInfo(Paths.get(p))); } return this; } @@ -53,14 +58,33 @@ public FileDataSourceBuilder filePaths(String... filePaths) throws InvalidPathEx */ public FileDataSourceBuilder filePaths(Path... filePaths) { for (Path p: filePaths) { - sources.add(p); + sources.add(new FilePathSourceInfo(p)); + } + return this; + } + + /** + * Adds any number of classpath resources for loading flag data. The resources 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 resourceLocations resource location(s) in the format used by {@code ClassLoader.getResource()}; these + * are absolute paths, so for instance a resource called "data.json" in the package "com.mypackage" would have + * the location "/com/mypackage/data.json" + * @return the same factory object + */ + public FileDataSourceBuilder classpathResources(String... resourceLocations) { + for (String location: resourceLocations) { + sources.add(new ClasspathResourceSourceInfo(location)); } 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. + * whenever there is a change. By default, it will not, so the flags will only be loaded once. This feature + * only works with real files, not with {@link #classpathResources(String...)}. *

    * 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. @@ -85,4 +109,60 @@ public FileDataSourceBuilder autoUpdate(boolean autoUpdate) { public DataSource createDataSource(ClientContext context, DataSourceUpdates dataSourceUpdates) { return new FileDataSourceImpl(dataSourceUpdates, sources, autoUpdate); } + + static abstract class SourceInfo { + abstract byte[] readData() throws IOException; + abstract Path toFilePath(); + } + + static final class FilePathSourceInfo extends SourceInfo { + final Path path; + + FilePathSourceInfo(Path path) { + this.path = path; + } + + @Override + byte[] readData() throws IOException { + return Files.readAllBytes(path); + } + + @Override + Path toFilePath() { + return path; + } + + @Override + public String toString() { + return path.toString(); + } + } + + static final class ClasspathResourceSourceInfo extends SourceInfo { + String location; + + ClasspathResourceSourceInfo(String location) { + this.location = location; + } + + @Override + byte[] readData() throws IOException { + try (InputStream is = getClass().getClassLoader().getResourceAsStream(location)) { + if (is == null) { + throw new IOException("classpath resource not found"); + } + return ByteStreams.toByteArray(is); + } + } + + @Override + Path toFilePath() { + return null; + } + + @Override + public String toString() { + return "classpath:" + location; + } + } } \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java index 6d20e64f7..ba078af6e 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java @@ -2,6 +2,7 @@ import com.google.common.collect.ImmutableList; import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.integrations.FileDataSourceBuilder.SourceInfo; import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FileDataException; import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FlagFactory; import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FlagFileParser; @@ -23,7 +24,6 @@ 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; @@ -59,14 +59,14 @@ final class FileDataSourceImpl implements DataSource { private final AtomicBoolean inited = new AtomicBoolean(false); private final FileWatcher fileWatcher; - FileDataSourceImpl(DataSourceUpdates dataSourceUpdates, List sources, boolean autoUpdate) { + FileDataSourceImpl(DataSourceUpdates dataSourceUpdates, List sources, boolean autoUpdate) { this.dataSourceUpdates = dataSourceUpdates; this.dataLoader = new DataLoader(sources); FileWatcher fw = null; if (autoUpdate) { try { - fw = FileWatcher.create(dataLoader.getFiles()); + fw = FileWatcher.create(dataLoader.getSources()); } catch (IOException e) { // COVERAGE: there is no way to simulate this condition in a unit test logger.error("Unable to watch files for auto-updating: " + e); @@ -131,16 +131,19 @@ private static final class FileWatcher implements Runnable { private final Thread thread; private volatile boolean stopped; - private static FileWatcher create(Iterable files) throws IOException { + private static FileWatcher create(Iterable sources) 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 (SourceInfo s: sources) { + Path p = s.toFilePath(); + if (p != null) { + absoluteFilePaths.add(p); + directoryPaths.add(p.getParent()); + } } for (Path d: directoryPaths) { d.register(ws, ENTRY_CREATE, ENTRY_MODIFY, ENTRY_DELETE); @@ -206,21 +209,21 @@ public void stop() { * be read or parsed, or if any flag or segment keys are duplicates. */ static final class DataLoader { - private final List files; + private final List sources; - public DataLoader(List files) { - this.files = new ArrayList(files); + public DataLoader(List sources) { + this.sources = new ArrayList<>(sources); } - public Iterable getFiles() { - return files; + public Iterable getSources() { + return sources; } public void load(DataBuilder builder) throws FileDataException { - for (Path p: files) { + for (SourceInfo s: sources) { try { - byte[] data = Files.readAllBytes(p); + byte[] data = s.readData(); FlagFileParser parser = FlagFileParser.selectForContent(data); FlagFileRep fileContents = parser.parse(new ByteArrayInputStream(data)); if (fileContents.flags != null) { @@ -239,9 +242,9 @@ public void load(DataBuilder builder) throws FileDataException } } } catch (FileDataException e) { - throw new FileDataException(e.getMessage(), e.getCause(), p); + throw new FileDataException(e.getMessage(), e.getCause(), s); } catch (IOException e) { - throw new FileDataException(null, e, p); + throw new FileDataException(null, e, s); } } } diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceParsing.java b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceParsing.java index b0a6a76e0..63c31fcdb 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceParsing.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceParsing.java @@ -5,6 +5,7 @@ import com.google.gson.JsonObject; import com.google.gson.JsonSyntaxException; import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.integrations.FileDataSourceBuilder.SourceInfo; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import org.yaml.snakeyaml.Yaml; @@ -15,7 +16,6 @@ import java.io.InputStream; import java.io.InputStreamReader; import java.io.Reader; -import java.nio.file.Path; import java.util.Map; import static com.launchdarkly.sdk.server.DataModel.FEATURES; @@ -31,11 +31,11 @@ private FileDataSourceParsing() {} */ @SuppressWarnings("serial") static final class FileDataException extends Exception { - private final Path filePath; + private final SourceInfo source; - public FileDataException(String message, Throwable cause, Path filePath) { + public FileDataException(String message, Throwable cause, SourceInfo source) { super(message, cause); - this.filePath = filePath; + this.source = source; } public FileDataException(String message, Throwable cause) { @@ -49,8 +49,8 @@ public String getDescription() { s.append(" "); } s.append("[").append(getCause().toString()).append("]"); - if (filePath != null) { - s.append(": ").append(filePath); + if (source != null) { + s.append(": ").append(source.toString()); } return s.toString(); } diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/DataLoaderTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/DataLoaderTest.java index d9f2969db..60deba210 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/DataLoaderTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/DataLoaderTest.java @@ -1,6 +1,5 @@ package com.launchdarkly.sdk.server.integrations; -import com.google.common.collect.ImmutableList; import com.google.gson.Gson; import com.google.gson.JsonElement; import com.google.gson.JsonObject; @@ -20,6 +19,7 @@ import static com.launchdarkly.sdk.server.DataStoreTestTypes.toDataMap; import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.FLAG_VALUE_1_KEY; import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.resourceFilePath; +import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.resourceLocation; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; @@ -28,25 +28,42 @@ public class DataLoaderTest { private static final Gson gson = new Gson(); private DataBuilder builder = new DataBuilder(); + + @Test + public void canLoadFromFilePath() throws Exception { + DataLoader ds = new DataLoader(FileData.dataSource().filePaths(resourceFilePath("flag-only.json")).sources); + ds.load(builder); + assertDataHasItemsOfKind(FEATURES); + } + + @Test + public void canLoadFromClasspath() throws Exception { + DataLoader ds = new DataLoader(FileData.dataSource().classpathResources(resourceLocation("flag-only.json")).sources); + ds.load(builder); + assertDataHasItemsOfKind(FEATURES); + } + @Test public void yamlFileIsAutoDetected() throws Exception { - DataLoader ds = new DataLoader(ImmutableList.of(resourceFilePath("flag-only.yml"))); + DataLoader ds = new DataLoader(FileData.dataSource().filePaths(resourceFilePath("flag-only.yml")).sources); ds.load(builder); assertDataHasItemsOfKind(FEATURES); } @Test public void jsonFileIsAutoDetected() throws Exception { - DataLoader ds = new DataLoader(ImmutableList.of(resourceFilePath("segment-only.json"))); + DataLoader ds = new DataLoader(FileData.dataSource().filePaths(resourceFilePath("segment-only.json")).sources); 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"))); + DataLoader ds = new DataLoader(FileData.dataSource().filePaths( + resourceFilePath("flag-only.json"), + resourceFilePath("segment-only.yml") + ).sources); ds.load(builder); assertDataHasItemsOfKind(FEATURES); assertDataHasItemsOfKind(SEGMENTS); @@ -54,7 +71,7 @@ public void canLoadMultipleFiles() throws Exception { @Test public void flagValueIsConvertedToFlag() throws Exception { - DataLoader ds = new DataLoader(ImmutableList.of(resourceFilePath("value-only.json"))); + DataLoader ds = new DataLoader(FileData.dataSource().filePaths(resourceFilePath("value-only.json")).sources); JsonObject expected = gson.fromJson( "{\"key\":\"flag2\",\"on\":true,\"fallthrough\":{\"variation\":0},\"variations\":[\"value2\"]," + "\"trackEvents\":false,\"deleted\":false,\"version\":0}", @@ -72,8 +89,10 @@ public void flagValueIsConvertedToFlag() throws Exception { @Test public void duplicateFlagKeyInFlagsThrowsException() throws Exception { try { - DataLoader ds = new DataLoader(ImmutableList.of(resourceFilePath("flag-only.json"), - resourceFilePath("flag-with-duplicate-key.json"))); + DataLoader ds = new DataLoader(FileData.dataSource().filePaths( + resourceFilePath("flag-only.json"), + resourceFilePath("flag-with-duplicate-key.json") + ).sources); ds.load(builder); } catch (FileDataException e) { assertThat(e.getMessage(), containsString("key \"flag1\" was already defined")); @@ -83,8 +102,10 @@ public void duplicateFlagKeyInFlagsThrowsException() throws Exception { @Test public void duplicateFlagKeyInFlagsAndFlagValuesThrowsException() throws Exception { try { - DataLoader ds = new DataLoader(ImmutableList.of(resourceFilePath("flag-only.json"), - resourceFilePath("value-with-duplicate-key.json"))); + DataLoader ds = new DataLoader(FileData.dataSource().filePaths( + resourceFilePath("flag-only.json"), + resourceFilePath("value-with-duplicate-key.json") + ).sources); ds.load(builder); } catch (FileDataException e) { assertThat(e.getMessage(), containsString("key \"flag1\" was already defined")); @@ -94,8 +115,10 @@ public void duplicateFlagKeyInFlagsAndFlagValuesThrowsException() throws Excepti @Test public void duplicateSegmentKeyThrowsException() throws Exception { try { - DataLoader ds = new DataLoader(ImmutableList.of(resourceFilePath("segment-only.json"), - resourceFilePath("segment-with-duplicate-key.json"))); + DataLoader ds = new DataLoader(FileData.dataSource().filePaths( + resourceFilePath("segment-only.json"), + resourceFilePath("segment-with-duplicate-key.json") + ).sources); ds.load(builder); } catch (FileDataException e) { assertThat(e.getMessage(), containsString("key \"seg1\" was already defined")); diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceAutoUpdateTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceAutoUpdateTest.java index 1a0d5862e..d8f5362b3 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceAutoUpdateTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceAutoUpdateTest.java @@ -29,6 +29,7 @@ import static org.hamcrest.Matchers.equalTo; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; @SuppressWarnings("javadoc") public class FileDataSourceAutoUpdateTest { @@ -120,4 +121,17 @@ public void ifFilesAreBadAtStartTimeAutoUpdateCanStillLoadGoodDataLater() throws } } } + + @Test + public void autoUpdateDoesNothingForClasspathResource() throws Exception { + // This just verifies that we don't cause an exception by trying to start a FileWatcher for + // something that isn't a real file. + FileDataSourceBuilder factory = FileData.dataSource() + .classpathResources(FileDataSourceTestData.resourceLocation("all-properties.json")) + .autoUpdate(true); + try (DataSource fp = makeDataSource(factory)) { + fp.start(); + assertTrue(fp.isInitialized()); + } + } } diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java index 96aa4acb0..8977a1d3a 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java @@ -122,6 +122,14 @@ public void startFailsWithNonexistentFile() throws Exception { } } + @Test + public void startFailsWithNonexistentClasspathResource() throws Exception { + FileDataSourceBuilder factory = FileData.dataSource().classpathResources("we-have-no-such-thing"); + try (DataSource fp = makeDataSource(factory)) { + verifyUnsuccessfulStart(fp); + } + } + private void verifyUnsuccessfulStart(DataSource fp) { BlockingQueue statuses = new LinkedBlockingQueue<>(); dataSourceUpdates.register(statuses::add); diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTestData.java b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTestData.java index 8e99fbde8..ab1d55023 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTestData.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTestData.java @@ -37,9 +37,13 @@ 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 = FileDataSourceTestData.class.getClassLoader().getResource(resourceLocation(filename)); return Paths.get(resource.toURI()); } + + public static String resourceLocation(String filename) throws URISyntaxException { + return "filesource/" + filename; + } public static String getResourceContents(String filename) throws Exception { return new String(Files.readAllBytes(resourceFilePath(filename))); From f02f8356e7ad02b427dca71963494ebeaaa93366 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 29 May 2020 17:09:38 -0700 Subject: [PATCH 507/673] 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 4a3a7fb0c442ee02d07b5f7f8d309c0e3a1e5593 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 29 May 2020 17:14:38 -0700 Subject: [PATCH 508/673] minor test fixes --- .../launchdarkly/sdk/server/DefaultEventProcessorTest.java | 4 ++-- .../java/com/launchdarkly/sdk/server/JsonHelpersTest.java | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java index a7c2fd831..b9fc32585 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java @@ -266,7 +266,7 @@ public void eventCapacityIsEnforced() throws Exception { // Using such a tiny buffer means there's also a tiny inbox queue, so we'll add a slight // delay to keep EventDispatcher from being overwhelmed - Thread.sleep(1); + Thread.sleep(10); } ep.flush(); assertThat(es.getEventsFromLastRequest(), Matchers.iterableWithSize(capacity)); @@ -293,7 +293,7 @@ public void eventCapacityDoesNotPreventSummaryEventFromBeingSent() throws Except // Using such a tiny buffer means there's also a tiny inbox queue, so we'll add a slight // delay to keep EventDispatcher from being overwhelmed - Thread.sleep(1); + Thread.sleep(10); } ep.flush(); diff --git a/src/test/java/com/launchdarkly/sdk/server/JsonHelpersTest.java b/src/test/java/com/launchdarkly/sdk/server/JsonHelpersTest.java index 9bbeade54..28f767554 100644 --- a/src/test/java/com/launchdarkly/sdk/server/JsonHelpersTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/JsonHelpersTest.java @@ -2,13 +2,12 @@ import com.google.gson.JsonElement; import com.google.gson.annotations.JsonAdapter; -import com.launchdarkly.sdk.server.interfaces.SerializationException; import com.launchdarkly.sdk.server.DataModel.FeatureFlag; import com.launchdarkly.sdk.server.DataModel.Segment; import com.launchdarkly.sdk.server.DataModel.VersionedData; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.SerializationException; -import org.junit.Assert; import org.junit.Test; import static org.junit.Assert.assertEquals; From 0fbd4b0efd3d0b478ffacf850bad43610193876f Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 29 May 2020 17:28:52 -0700 Subject: [PATCH 509/673] make class final --- .../java/com/launchdarkly/sdk/server/FeatureFlagsState.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java b/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java index 7e9eca8da..71822714f 100644 --- a/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java +++ b/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java @@ -35,7 +35,7 @@ * @since 4.3.0 */ @JsonAdapter(FeatureFlagsState.JsonSerialization.class) -public class FeatureFlagsState implements JsonSerializable { +public final class FeatureFlagsState implements JsonSerializable { private final Map flagValues; private final Map flagMetadata; private final boolean valid; From 91941faa4f048d6c28c37f57d07e4c902fc448f8 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 29 May 2020 18:55:07 -0700 Subject: [PATCH 510/673] rm beta changelog items --- CHANGELOG.md | 46 ---------------------------------------------- 1 file changed, 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d31660fe9..700c3ce64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,52 +2,6 @@ All notable changes to the LaunchDarkly Java SDK will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org). -## [5.0.0-rc2] - 2020-05-13 -The primary purpose of this second beta release is to introduce the new `DataSourceStatusProvider` API, which is the server-side equivalent to the "connection status" API that exists in LaunchDarkly's mobile SDKs. Other additions and changes since the previous beta release (5.0.0-rc1) are described below. See the [5.0.0-rc1 release notes](https://github.com/launchdarkly/java-server-sdk/releases/tag/5.0.0-rc1) for other changes since 4.13.0. - -### Added: -- `LDClient.getDataSourceStatusProvider()` is a status monitoring mechanism similar to `getDataStoreStatusProvider()`, but for the data source (streaming, polling, or file data). You can not only check the current connection status, but also choose to receive notifications when the status changes. -- `LDConfig.Builder.logging()` is a new configuration category for options related to logging. Currently the only such option is `escalateDataSourceOutageLoggingAfter`, which controls the new connection failure logging behavior described below. -- `LDConfig.Builder.threadPriority()` allows you to set the priority for worker threads created by the SDK. - -### Changed: -- Network failures and server errors for streaming or polling requests were previously logged at `ERROR` level in most cases but sometimes at `WARN` level. They are now all at `WARN` level, but with a new behavior: if connection failures continue without a successful retry for a certain amount of time, the SDK will log a special `ERROR`-level message to warn you that this is not just a brief outage. The amount of time is one minute by default, but can be changed with the new `logDataSourceOutageAsErrorAfter` option in `LoggingConfigurationBuilder`. -- The number of worker threads maintained by the SDK has been reduced so that most intermittent background tasks, such as listener notifications, event flush timers, and polling requests, are now dispatched on a single thread. The delivery of analytics events to LaunchDarkly still has its own thread pool because it is a heavier-weight task with greater need for concurrency. -- In polling mode, the poll requests previously ran on a dedicated worker thread that inherited its priority from the application thread that created the SDK. They are now on the SDK's main worker thread, which has `Thread.MIN_PRIORITY` by default (as all the other SDK threads already did) but the priority can be changed as described above. -- Only relevant for implementing custom components: The `DataStore` and `DataSource` interfaces, and their factories, have been changed to provide a more general mechanism for status reporting. This does not affect the part of a persistent data store implementation that is database-specific, so new beta releases of the Consul/DynamoDB/Redis libraries were not necessary. - -## [5.0.0-rc1] - 2020-04-29 -This beta release is being made available for testing and user feedback, due to the large number of changes from Java SDK 4.x. Features are still subject to change in the final 5.0.0 release. Until the final release, the beta source code will be on the [5.x branch](https://github.com/launchdarkly/java-server-sdk/tree/5.x). Javadocs can be found on [javadoc.io](https://javadoc.io/doc/com.launchdarkly/launchdarkly-server-sdk/5.0.0-rc1/index.html). - -This is a major rewrite that introduces a cleaner API design, adds new features, and makes the SDK code easier to maintain and extend. See the [Java 4.x to 5.0 migration guide](https://docs.launchdarkly.com/sdk/server-side/java/migration-4-to-5) for an in-depth look at the changes in this version; the following is a summary. - -### Added: -- You can tell the SDK to notify you whenever a feature flag's configuration has changed in any way, using `FlagChangeListener` and `LDClient.registerFlagChangeListener()`. -- Or, you can tell the SDK to notify you only if the _value_ of a flag for some particular `LDUser` has changed, using `FlagValueChangeListener` and `Components.flagValueMonitoringListener()`. -- You can monitor the status of a persistent data store (for instance, to get caching statistics, or to be notified if the store's availability changes due to a database outage) with `LDClient.getDataStoreStatusProvider()`. -- The `UserAttribute` class provides a less error-prone way to refer to user attribute names in configuration, and can also be used to get an arbitrary attribute from a user. -- The `LDGson` and `LDJackson` classes allow SDK classes like LDUser to be easily converted to or from JSON using the popular Gson and Jackson frameworks. - -### Changed: -- The minimum supported Java version is now 8. -- Package names have changed: the main SDK classes are now in `com.launchdarkly.sdk` and `com.launchdarkly.sdk.server`. -- Many rarely-used classes and interfaces have been moved out of the main SDK package into `com.launchdarkly.sdk.server.integrations` and `com.launchdarkly.sdk.server.interfaces`. -- The type `java.time.Duration` is now used for configuration properties that represent an amount of time, instead of using a number of milliseconds or seconds. -- When using a persistent data store such as Redis, if there is a database outage, the SDK will wait until the end of the outage and then restart the stream connection to ensure that it has the latest data. Previously, it would try to restart the connection immediately and continue restarting if the database was still not available, causing unnecessary overhead. -- `EvaluationDetail.getVariationIndex()` now returns `int` instead of `Integer`. -- `EvaluationReason` is now a single concrete class rather than an abstract base class. -- The SDK no longer exposes a Gson dependency or any Gson types. -- Third-party libraries like Gson, Guava, and OkHttp that are used internally by the SDK have been updated to newer versions since Java 7 compatibility is no longer required. -- The component interfaces `FeatureStore` and UpdateProcessor have been renamed to `DataStore` and `DataSource`. The factory interfaces for these components now receive SDK configuration options in a different way that does not expose other components' configurations to each other. -- The `PersistentDataStore` interface for creating your own database integrations has been simplified by moving all of the serialization and caching logic into the main SDK code. - -### Removed: -- All types and methods that were deprecated as of Java SDK 4.13.0 have been removed. This includes many `LDConfig.Builder()` methods, which have been replaced by the modular configuration syntax that was already added in the 4.12.0 and 4.13.0 releases. See the [migration guide](https://docs.launchdarkly.com/sdk/server-side/java/migration-4-to-5) for details on how to update your configuration code if you were using the older syntax. -- The Redis integration is no longer built into the main SDK library (see below). -- The deprecated New Relic integration has been removed. - -If you want to test this release and you are using Consul, DynamoDB, or Redis as a persistent data store, you will also need to update to version 2.0.0-rc1 of the [Consul integration](https://github.com/launchdarkly/java-server-sdk-consul/tree/2.x), 3.0.0-rc1 of the [DynamoDB integration](https://github.com/launchdarkly/java-server-sdk-dynamodb/tree/3.x), or 1.0.0-rc1 of the [Redis integration](http://github.com/launchdarkly/java-server-sdk-redis) (previously the Redis integration was built in; now it is a separate module). - ## [4.14.0] - 2020-05-13 ### Added: - `EventSender` interface and `EventsConfigurationBuilder.eventSender()` allow you to specify a custom implementation of how event data is sent. This is mainly to facilitate testing, but could also be used to store and forward event data. From 4dd0822e4a5a1d45aaaba5badabe38ac96daef1a Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 1 Jun 2020 10:12:05 -0700 Subject: [PATCH 511/673] test data source --- .../sdk/server/integrations/TestData.java | 682 ++++++++++++++++++ .../sdk/server/LDClientListenersTest.java | 65 +- .../sdk/server/TestComponents.java | 48 -- .../sdk/server/integrations/TestDataTest.java | 287 ++++++++ .../integrations/TestDataWithClientTest.java | 112 +++ 5 files changed, 1106 insertions(+), 88 deletions(-) create mode 100644 src/main/java/com/launchdarkly/sdk/server/integrations/TestData.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/integrations/TestDataTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/integrations/TestDataWithClientTest.java diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/TestData.java b/src/main/java/com/launchdarkly/sdk/server/integrations/TestData.java new file mode 100644 index 000000000..b7abc5724 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/TestData.java @@ -0,0 +1,682 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.ImmutableSortedSet; +import com.google.common.collect.Iterables; +import com.launchdarkly.sdk.ArrayBuilder; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.ObjectBuilder; +import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.server.DataModel; +import com.launchdarkly.sdk.server.interfaces.ClientContext; +import com.launchdarkly.sdk.server.interfaces.DataSource; +import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.State; +import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.Future; + +import static java.util.concurrent.CompletableFuture.completedFuture; + +/** + * A mechanism for providing dynamically updatable feature flag state in a simplified form to an SDK + * client in test scenarios. + *

    + * Unlike {@link FileData}, this mechanism does not use any external resources. It provides only + * the data that the application has put into it using the {@link #update(FlagBuilder)} method. + * + *

    
    + *     TestData td = TestData.dataSource();
    + *     td.update(testData.flag("flag-key-1").booleanFlag().variationForAllUsers(true));
    + *     
    + *     LDConfig config = new LDConfig.Builder()
    + *         .dataSource(td)
    + *         .build();
    + *     LDClient client = new LDClient(sdkKey, config);
    + *     
    + *     // flags can be updated at any time:
    + *     td.update(testData.flag("flag-key-2")
    + *       .variationForUser("some-user-key", true)
    + *       .fallthroughVariation(false));
    + * 
    + * + * The above example uses a simple boolean flag, but more complex configurations are possible using + * the methods of the {@link FlagBuilder} that is returned by {@link #flag(String)}. + *

    + * If the same {@code TestData} instance is used to configure multiple {@code LDClient} instances, + * any changes made to the data will propagate to all of the {@code LDClient}s. + * + * @since 5.0.0 + */ +public final class TestData implements DataSourceFactory { + private final Object lock = new Object(); + private final Map currentFlags = new HashMap<>(); + private final Map currentBuilders = new HashMap<>(); + private final List instances = new CopyOnWriteArrayList<>(); + + /** + * Creates a new instance of the test data source. + *

    + * See {@link TestData} for details. + * + * @return a new configurable test data source + */ + public static TestData dataSource() { + return new TestData(); + } + + private TestData() {} + + /** + * Creates or copies a {@link FlagBuilder} for building a test flag configuration. + *

    + * If this flag key has already been defined in this {@code TestData} instance, then the builder + * starts with the same configuration that was last provided for this flag. + *

    + * Otherwise, it starts with a new default configuration in which the flag has {@code true} and + * {@code false} variations, is {@code true} for all users when targeting is turned on and + * {@code false} otherwise, and currently has targeting turned on. You can change any of those + * properties, and provide more complex behavior, using the {@link FlagBuilder} methods. + *

    + * Once you have set the desired configuration, pass the builder to {@link #update(FlagBuilder)}. + * + * @param key the flag key + * @return a flag configuration builder + * @see #update(FlagBuilder) + */ + public FlagBuilder flag(String key) { + FlagBuilder existingBuilder; + synchronized (lock) { + existingBuilder = currentBuilders.get(key); + } + if (existingBuilder != null) { + return new FlagBuilder(existingBuilder); + } + return new FlagBuilder(key).booleanFlag(); + } + + /** + * Updates the test data with the specified flag configuration. + *

    + * This has the same effect as if a flag were added or modified on the LaunchDarkly dashboard. + * It immediately propagates the flag change to any {@code LDClient} instance(s) that you have + * already configured to use this {@code TestData}. If no {@code LDClient} has been started yet, + * it simply adds this flag to the test data which will be provided to any {@code LDClient} that + * you subsequently configure. + *

    + * Any subsequent changes to this {@link FlagBuilder} instance do not affect the test data, + * unless you call {@link #update(FlagBuilder)} again. + * + * @param flagBuilder a flag configuration builder + * @return the same {@code TestData} instance + * @see #flag(String) + */ + public TestData update(FlagBuilder flagBuilder) { + String key = flagBuilder.key; + FlagBuilder clonedBuilder = new FlagBuilder(flagBuilder); + ItemDescriptor newItem = null; + + synchronized (lock) { + ItemDescriptor oldItem = currentFlags.get(key); + int oldVersion = oldItem == null ? 0 : oldItem.getVersion(); + newItem = flagBuilder.createFlag(oldVersion + 1); + currentFlags.put(key, newItem); + currentBuilders.put(key, clonedBuilder); + } + + for (DataSourceImpl instance: instances) { + instance.updates.upsert(DataModel.FEATURES, key, newItem); + } + + return this; + } + + /** + * Simulates a change in the data source status. + *

    + * Use this if you want to test the behavior of application code that uses + * {@link com.launchdarkly.sdk.server.LDClient#getDataSourceStatusProvider()} to track whether the data + * source is having problems (for example, a network failure interruptsingthe streaming connection). It + * does not actually stop the {@code TestData} data source from working, so even if you have simulated + * an outage, calling {@link #update(FlagBuilder)} will still send updates. + * + * @param newState one of the constants defined by {@link com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.State} + * @param newError an {@link com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorInfo} instance, + * or null + * @return the same {@code TestData} instance + */ + public TestData updateStatus(DataSourceStatusProvider.State newState, DataSourceStatusProvider.ErrorInfo newError) { + for (DataSourceImpl instance: instances) { + instance.updates.updateStatus(newState, newError); + } + return this; + } + + /** + * Called internally by the SDK to associate this test data source with an {@code LDClient} instance. + * You do not need to call this method. + */ + @Override + public DataSource createDataSource(ClientContext context, DataSourceUpdates dataSourceUpdates) { + DataSourceImpl instance = new DataSourceImpl(dataSourceUpdates); + synchronized (lock) { + instances.add(instance); + } + return instance; + } + + private FullDataSet makeInitData() { + ImmutableMap copiedData; + synchronized (lock) { + copiedData = ImmutableMap.copyOf(currentFlags); + } + return new FullDataSet<>(ImmutableMap.of(DataModel.FEATURES, new KeyedItems<>(copiedData.entrySet())).entrySet()); + } + + private void closedInstance(DataSourceImpl instance) { + synchronized (lock) { + instances.remove(instance); + } + } + + /** + * A builder for feature flag configurations to be used with {@link TestData}. + * + * @see TestData#flag(String) + * @see TestData#update(FlagBuilder) + */ + public static final class FlagBuilder { + private static final int TRUE_VARIATION_FOR_BOOLEAN = 0; + private static final int FALSE_VARIATION_FOR_BOOLEAN = 1; + + final String key; + int offVariation; + boolean on; + int fallthroughVariation; + CopyOnWriteArrayList variations; + Map> targets; + List rules; + + private FlagBuilder(String key) { + this.key = key; + this.on = true; + this.variations = new CopyOnWriteArrayList<>(); + } + + private FlagBuilder(FlagBuilder from) { + this.key = from.key; + this.offVariation = from.offVariation; + this.on = from.on; + this.fallthroughVariation = from.fallthroughVariation; + this.variations = new CopyOnWriteArrayList<>(from.variations); + this.targets = from.targets == null ? null : new HashMap<>(from.targets); + } + + private boolean isBooleanFlag() { + return variations.size() == 2 && + variations.get(TRUE_VARIATION_FOR_BOOLEAN).equals(LDValue.of(true)) && + variations.get(FALSE_VARIATION_FOR_BOOLEAN).equals(LDValue.of(false)); + } + + /** + * A shortcut for setting the flag to use the standard boolean configuration. + *

    + * This is the default for all new flags created with {@link TestData#flag(String)}. The flag + * will have two variations, {@code true} and {@code false} (in that order); it will return + * {@code false} whenever targeting is off, and {@code true} when targeting is on if no other + * settings specify otherwise. + * + * @return the builder + */ + public FlagBuilder booleanFlag() { + if (isBooleanFlag()) { + return this; + } + return variations(LDValue.of(true), LDValue.of(false)) + .fallthroughVariation(TRUE_VARIATION_FOR_BOOLEAN) + .offVariation(FALSE_VARIATION_FOR_BOOLEAN); + } + + /** + * Sets targeting to be on or off for this flag. + *

    + * The effect of this depends on the rest of the flag configuration, just as it does on the + * real LaunchDarkly dashboard. In the default configuration that you get from calling + * {@link TestData#flag(String)} with a new flag key, the flag will return {@code false} + * whenever targeting is off, and {@code true} when targeting is on. + * + * @param on true if targeting should be on + * @return the builder + */ + public FlagBuilder on(boolean on) { + this.on = on; + return this; + } + + /** + * Specifies the fallthrough variation for a boolean flag. The fallthrough is the value + * that is returned if targeting is on and the user was not matched by a more specific + * target or rule. + *

    + * If the flag was previously configured with other variations, this also changes it to a + * boolean flag. + * + * @param value true if the flag should return true by default when targeting is on + * @return the builder + */ + public FlagBuilder fallthroughVariation(boolean value) { + return this.booleanFlag().fallthroughVariation(value ? TRUE_VARIATION_FOR_BOOLEAN : FALSE_VARIATION_FOR_BOOLEAN); + } + + /** + * Specifies the index of the fallthrough variation. The fallthrough is the variation + * that is returned if targeting is on and the user was not matched by a more specific + * target or rule. + * + * @param index the desired fallthrough variation: 0 for the first, 1 for the second, etc. + * @return the builder + */ + public FlagBuilder fallthroughVariation(int index) { + this.fallthroughVariation = index; + return this; + } + + /** + * Specifies the index of the off variation. This is the variation that is returned + * whenever targeting is off. + * + * @param index the desired off variation: 0 for the first, 1 for the second, etc. + * @return the builder + */ + public FlagBuilder offVariation(int index) { + this.offVariation = index; + return this; + } + + /** + * Sets the flag to always return the specified boolean variation for all users. + *

    + * If the flag is a boolean flag, this causes targeting to be switched on or off, and also + * removes any existing targets or rules. If the flag was not already a boolean flag, it is + * the same as calling {@code valueForAllUsers(LDValue.of(true))}. + * + * @param variation the desired true/false variation to be returned for all users + * @return the builder + */ + public FlagBuilder variationForAllUsers(boolean variation) { + if (isBooleanFlag()) { + targets = null; + return offVariation(FALSE_VARIATION_FOR_BOOLEAN) + .fallthroughVariation(TRUE_VARIATION_FOR_BOOLEAN) + .on(variation); + } + return valueForAllUsers(LDValue.of(variation)); + } + + /** + * Sets the flag to always return the specified variation for all users. + *

    + * The variation is specified by number, out of whatever variation values have already been + * defined. The flag will always return this variation regardless of whether targeting is + * on or off (it is used as both the fallthrough variation and the off variation). Any + * existing targets or rules are removed. + * + * @param index the desired variation: 0 for the first, 1 for the second, etc. + * @return the builder + */ + public FlagBuilder variationForAllUsers(int index) { + offVariation = index; + fallthroughVariation = index; + targets = null; + return this; + } + + /** + * Sets the flag to always return the specified variation value for all users. + *

    + * The value may be of any JSON type, as defined by {@link LDValue}. This method changes the + * flag to have only a single variation, which is this value, and to return the same + * variation regardless of whether targeting is on or off. Any existing targets or rules + * are removed. + * + * @param value the desired value to be returned for all users + * @return the builder + */ + public FlagBuilder valueForAllUsers(LDValue value) { + variations.clear(); + variations.add(value); + return variationForAllUsers(0); + } + + /** + * Sets the flag to return the specified boolean variation for a specific user key when + * targeting is on. + *

    + * This has no effect when targeting is turned off for the flag. + *

    + * If the flag was not already a boolean flag, this also changes it to a boolean flag. + * + * @param userKey a user key + * @param variation the desired true/false variation to be returned for this user when + * targeting is on + * @return the builder + */ + public FlagBuilder variationForUser(String userKey, boolean variation) { + return booleanFlag().variationForUser(userKey, + variation ? TRUE_VARIATION_FOR_BOOLEAN : FALSE_VARIATION_FOR_BOOLEAN); + } + + /** + * Sets the flag to return the specified variation for a specific user key when targeting + * is on. + *

    + * This has no effect when targeting is turned off for the flag. + *

    + * The variation is specified by number, out of whatever variation values have already been + * defined. + * + * @param userKey a user key + * @param index the desired variation to be returned for this user when targeting is on: + * 0 for the first, 1 for the second, etc. + * @return the builder + */ + public FlagBuilder variationForUser(String userKey, int index) { + if (targets == null) { + targets = new TreeMap<>(); // TreeMap keeps variations in order for test determinacy + } + for (int i = 0; i < variations.size(); i++) { + ImmutableSet keys = targets.get(i); + if (i == index) { + if (keys == null) { + targets.put(i, ImmutableSortedSet.of(userKey)); + } else if (!keys.contains(userKey)) { + targets.put(i, ImmutableSortedSet.naturalOrder().addAll(keys).add(userKey).build()); + } + } else { + if (keys != null && keys.contains(userKey)) { + targets.put(i, ImmutableSortedSet.copyOf(Iterables.filter(keys, k -> !k.equals(userKey)))); + } + } + } + // Note, we use ImmutableSortedSet just to make the output determinate for our own testing + return this; + } + + /** + * Changes the allowable variation values for the flag. + *

    + * The value may be of any JSON type, as defined by {@link LDValue}. For instance, a boolean flag + * normally has {@code LDValue.of(true), LDValue.of(false)}; a string-valued flag might have + * {@code LDValue.of("red"), LDValue.of("green")}; etc. + * + * @param values the desired variations + * @return the builder + */ + public FlagBuilder variations(LDValue... values) { + variations.clear(); + for (LDValue v: values) { + variations.add(v); + } + return this; + } + + /** + * Starts defining a flag rule, using the "is one of" operator. + *

    + * For example, this creates a rule that returns {@code true} if the name is "Patsy" or "Edina": + * + *

    
    +     *     testData.flag("flag")
    +     *         .ifMatch(UserAttribute.NAME, LDValue.of("Patsy"), LDValue.of("Edina"))
    +     *         .thenReturn(true));
    +     * 
    + * + * @param attribute the user attribute to match against + * @param values values to compare to + * @return a {@link FlagRuleBuilder}; call {@link FlagRuleBuilder#thenReturn(boolean)} or + * {@link FlagRuleBuilder#thenReturn(int)} to finish the rule, or add more tests with another + * method like {@link FlagRuleBuilder#andMatch(UserAttribute, LDValue...)} + */ + public FlagRuleBuilder ifMatch(UserAttribute attribute, LDValue... values) { + return new FlagRuleBuilder().andMatch(attribute, values); + } + + /** + * Starts defining a flag rule, using the "is not one of" operator. + *

    + * For example, this creates a rule that returns {@code true} if the name is neither "Saffron" nor "Bubble": + * + *

    
    +     *     testData.flag("flag")
    +     *         .ifNotMatch(UserAttribute.NAME, LDValue.of("Saffron"), LDValue.of("Bubble"))
    +     *         .thenReturn(true));
    +     * 
    + + * @param attribute the user attribute to match against + * @param values values to compare to + * @return a {@link FlagRuleBuilder}; call {@link FlagRuleBuilder#thenReturn(boolean)} or + * {@link FlagRuleBuilder#thenReturn(int)} to finish the rule, or add more tests with another + * method like {@link FlagRuleBuilder#andMatch(UserAttribute, LDValue...)} + */ + public FlagRuleBuilder ifNotMatch(UserAttribute attribute, LDValue... values) { + return new FlagRuleBuilder().andNotMatch(attribute, values); + } + + /** + * Removes any existing rules from the flag. This undoes the effect of methods like + * {@link #ifMatch(UserAttribute, LDValue...)}. + * + * @return the same builder + */ + public FlagBuilder clearRules() { + rules = null; + return this; + } + + /** + * Removes any existing user targets from the flag. This undoes the effect of methods like + * {@link #variationForUser(String, boolean)}. + * + * @return the same builder + */ + public FlagBuilder clearUserTargets() { + targets = null; + return this; + } + + ItemDescriptor createFlag(int version) { + ObjectBuilder builder = LDValue.buildObject() + .put("key", key) + .put("version", version) + .put("on", on) + .put("offVariation", offVariation) + .put("fallthrough", LDValue.buildObject().put("variation", fallthroughVariation).build()); + ArrayBuilder jsonVariations = LDValue.buildArray(); + for (LDValue v: variations) { + jsonVariations.add(v); + } + builder.put("variations", jsonVariations.build()); + + if (targets != null && !targets.isEmpty()) { + ArrayBuilder jsonTargets = LDValue.buildArray(); + for (Map.Entry> e: targets.entrySet()) { + jsonTargets.add(LDValue.buildObject() + .put("variation", e.getKey().intValue()) + .put("values", LDValue.Convert.String.arrayFrom(e.getValue())) + .build()); + } + builder.put("targets", jsonTargets.build()); + } + + if (rules != null && !rules.isEmpty()) { + ArrayBuilder jsonRules = LDValue.buildArray(); + int ri = 0; + for (FlagRuleBuilder r: rules) { + ArrayBuilder jsonClauses = LDValue.buildArray(); + for (Clause c: r.clauses) { + ArrayBuilder jsonValues = LDValue.buildArray(); + for (LDValue v: c.values) { + jsonValues.add(v); + } + jsonClauses.add(LDValue.buildObject() + .put("attribute", c.attribute.getName()) + .put("op", c.operator) + .put("values", jsonValues.build()) + .put("negate", c.negate) + .build()); + } + jsonRules.add(LDValue.buildObject() + .put("id", "rule" + ri) + .put("variation", r.variation) + .put("clauses", jsonClauses.build()) + .build()); + ri++; + } + builder.put("rules", jsonRules.build()); + } + + String json = builder.build().toJsonString(); + return DataModel.FEATURES.deserialize(json); + } + + /** + * A builder for feature flag rules to be used with {@link FlagBuilder}. + *

    + * In the LaunchDarkly model, a flag can have any number of rules, and a rule can have any number of + * clauses. A clause is an individual test such as "name is 'X'". A rule matches a user if all of the + * rule's clauses match the user. + *

    + * To start defining a rule, use one of the flag builder's matching methods such as + * {@link FlagBuilder#ifMatch(UserAttribute, LDValue...)}. This defines the first clause for the rule. + * Optionally, you may add more clauses with the rule builder's methods such as + * {@link #andMatch(UserAttribute, LDValue...)}. Finally, call {@link #thenReturn(boolean)} or + * {@link #thenReturn(int)} to finish defining the rule. + */ + public final class FlagRuleBuilder { + final List clauses = new ArrayList<>(); + int variation; + + /** + * Adds another clause, using the "is one of" operator. + *

    + * For example, this creates a rule that returns {@code true} if the name is "Patsy" and the + * country is "gb": + * + *

    
    +       *     testData.flag("flag")
    +       *         .ifMatch(UserAttribute.NAME, LDValue.of("Patsy"))
    +       *         .andMatch(UserAttribute.COUNTRY, LDValue.of("gb"))
    +       *         .thenReturn(true));
    +       * 
    + * + * @param attribute the user attribute to match against + * @param values values to compare to + * @return the rule builder + */ + public FlagRuleBuilder andMatch(UserAttribute attribute, LDValue... values) { + clauses.add(new Clause(attribute, "in", values, false)); + return this; + } + + /** + * Adds another clause, using the "is not one of" operator. + *

    + * For example, this creates a rule that returns {@code true} if the name is "Patsy" and the + * country is not "gb": + * + *

    
    +       *     testData.flag("flag")
    +       *         .ifMatch(UserAttribute.NAME, LDValue.of("Patsy"))
    +       *         .andNotMatch(UserAttribute.COUNTRY, LDValue.of("gb"))
    +       *         .thenReturn(true));
    +       * 
    + * + * @param attribute the user attribute to match against + * @param values values to compare to + * @return the rule builder + */ + public FlagRuleBuilder andNotMatch(UserAttribute attribute, LDValue... values) { + clauses.add(new Clause(attribute, "in", values, true)); + return this; + } + + /** + * Finishes defining the rule, specifying the result value as a boolean. + * + * @param variation the value to return if the rule matches the user + * @return the flag builder + */ + public FlagBuilder thenReturn(boolean variation) { + FlagBuilder.this.booleanFlag(); + return thenReturn(variation ? TRUE_VARIATION_FOR_BOOLEAN : FALSE_VARIATION_FOR_BOOLEAN); + } + + /** + * Finishes defining the rule, specifying the result as a variation index. + * + * @param index the variation to return if the rule matches the user: 0 for the first, 1 + * for the second, etc. + * @return the flag builder + */ + public FlagBuilder thenReturn(int index) { + this.variation = index; + if (FlagBuilder.this.rules == null) { + FlagBuilder.this.rules = new ArrayList<>(); + } + FlagBuilder.this.rules.add(this); + return FlagBuilder.this; + } + } + + private static final class Clause { + final UserAttribute attribute; + final String operator; + final LDValue[] values; + final boolean negate; + + Clause(UserAttribute attribute, String operator, LDValue[] values, boolean negate) { + this.attribute = attribute; + this.operator = operator; + this.values = values; + this.negate = negate; + } + } + } + + private final class DataSourceImpl implements DataSource { + final DataSourceUpdates updates; + + DataSourceImpl(DataSourceUpdates updates) { + this.updates = updates; + } + + @Override + public Future start() { + updates.init(makeInitData()); + updates.updateStatus(State.VALID, null); + return completedFuture(null); + } + + @Override + public boolean isInitialized() { + return true; + } + + @Override + public void close() throws IOException { + closedInstance(this); + } + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java index 0b3c7592e..64331c0d2 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java @@ -2,13 +2,10 @@ import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; -import com.launchdarkly.sdk.server.DataModel.FeatureFlag; -import com.launchdarkly.sdk.server.DataStoreTestTypes.DataBuilder; -import com.launchdarkly.sdk.server.TestComponents.DataSourceFactoryThatExposesUpdater; import com.launchdarkly.sdk.server.TestComponents.DataStoreFactoryThatExposesUpdater; import com.launchdarkly.sdk.server.integrations.MockPersistentDataStore; +import com.launchdarkly.sdk.server.integrations.TestData; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; -import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; @@ -23,9 +20,6 @@ import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; -import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; -import static com.launchdarkly.sdk.server.TestComponents.initedDataStore; -import static com.launchdarkly.sdk.server.TestComponents.specificDataStore; import static com.launchdarkly.sdk.server.TestComponents.specificPersistentDataStore; import static com.launchdarkly.sdk.server.TestUtil.awaitValue; import static com.launchdarkly.sdk.server.TestUtil.expectNoMoreValues; @@ -53,13 +47,10 @@ public class LDClientListenersTest extends EasyMockSupport { @Test public void clientSendsFlagChangeEvents() throws Exception { String flagKey = "flagkey"; - DataStore testDataStore = initedDataStore(); - DataBuilder initialData = new DataBuilder().addAny(DataModel.FEATURES, - flagBuilder(flagKey).version(1).build()); - DataSourceFactoryThatExposesUpdater updatableSource = new DataSourceFactoryThatExposesUpdater(initialData.build()); + TestData testData = TestData.dataSource(); + testData.update(testData.flag(flagKey).on(true)); LDConfig config = new LDConfig.Builder() - .dataStore(specificDataStore(testDataStore)) - .dataSource(updatableSource) + .dataSource(testData) .events(Components.noEvents()) .build(); @@ -74,7 +65,7 @@ public void clientSendsFlagChangeEvents() throws Exception { expectNoMoreValues(eventSink1, Duration.ofMillis(100)); expectNoMoreValues(eventSink2, Duration.ofMillis(100)); - updatableSource.updateFlag(flagBuilder(flagKey).version(2).build()); + testData.update(testData.flag(flagKey).on(false)); FlagChangeEvent event1 = awaitValue(eventSink1, Duration.ofSeconds(1)); FlagChangeEvent event2 = awaitValue(eventSink2, Duration.ofSeconds(1)); @@ -85,7 +76,7 @@ public void clientSendsFlagChangeEvents() throws Exception { client.getFlagTracker().removeFlagChangeListener(listener1); - updatableSource.updateFlag(flagBuilder(flagKey).version(3).build()); + testData.update(testData.flag(flagKey).on(true)); FlagChangeEvent event3 = awaitValue(eventSink2, Duration.ofSeconds(1)); assertThat(event3.getKey(), equalTo(flagKey)); @@ -99,16 +90,12 @@ public void clientSendsFlagValueChangeEvents() throws Exception { String flagKey = "important-flag"; LDUser user = new LDUser("important-user"); LDUser otherUser = new LDUser("unimportant-user"); - DataStore testDataStore = initedDataStore(); - - FeatureFlag alwaysFalseFlag = flagBuilder(flagKey).version(1).on(true).variations(false, true) - .fallthroughVariation(0).build(); - DataBuilder initialData = new DataBuilder().addAny(DataModel.FEATURES, alwaysFalseFlag); + + TestData testData = TestData.dataSource(); + testData.update(testData.flag(flagKey).on(false)); - DataSourceFactoryThatExposesUpdater updatableSource = new DataSourceFactoryThatExposesUpdater(initialData.build()); LDConfig config = new LDConfig.Builder() - .dataStore(specificDataStore(testDataStore)) - .dataSource(updatableSource) + .dataSource(testData) .events(Components.noEvents()) .build(); @@ -126,9 +113,10 @@ public void clientSendsFlagValueChangeEvents() throws Exception { expectNoMoreValues(eventSink3, Duration.ofMillis(100)); // make the flag true for the first user only, and broadcast a flag change event - FeatureFlag flagIsTrueForMyUserOnly = flagBuilder(flagKey).version(2).on(true).variations(false, true) - .targets(ModelBuilders.target(1, user.getKey())).fallthroughVariation(0).build(); - updatableSource.updateFlag(flagIsTrueForMyUserOnly); + testData.update(testData.flag(flagKey) + .on(true) + .variationForUser(user.getKey(), true) + .fallthroughVariation(false)); // eventSink1 receives a value change event FlagValueChangeEvent event1 = awaitValue(eventSink1, Duration.ofSeconds(1)); @@ -147,22 +135,22 @@ public void clientSendsFlagValueChangeEvents() throws Exception { @Test public void dataSourceStatusProviderReturnsLatestStatus() throws Exception { - DataSourceFactoryThatExposesUpdater updatableSource = new DataSourceFactoryThatExposesUpdater(new DataBuilder().build()); + TestData testData = TestData.dataSource(); LDConfig config = new LDConfig.Builder() - .dataSource(updatableSource) + .dataSource(testData) .events(Components.noEvents()) .build(); Instant timeBeforeStarting = Instant.now(); try (LDClient client = new LDClient(SDK_KEY, config)) { DataSourceStatusProvider.Status initialStatus = client.getDataSourceStatusProvider().getStatus(); - assertThat(initialStatus.getState(), equalTo(DataSourceStatusProvider.State.INITIALIZING)); + assertThat(initialStatus.getState(), equalTo(DataSourceStatusProvider.State.VALID)); assertThat(initialStatus.getStateSince(), greaterThanOrEqualTo(timeBeforeStarting)); assertThat(initialStatus.getLastError(), nullValue()); DataSourceStatusProvider.ErrorInfo errorInfo = new DataSourceStatusProvider.ErrorInfo( DataSourceStatusProvider.ErrorKind.ERROR_RESPONSE, 401, null, Instant.now()); - updatableSource.dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.OFF, errorInfo); + testData.updateStatus(DataSourceStatusProvider.State.OFF, errorInfo); DataSourceStatusProvider.Status newStatus = client.getDataSourceStatusProvider().getStatus(); assertThat(newStatus.getState(), equalTo(DataSourceStatusProvider.State.OFF)); @@ -173,9 +161,9 @@ public void dataSourceStatusProviderReturnsLatestStatus() throws Exception { @Test public void dataSourceStatusProviderSendsStatusUpdates() throws Exception { - DataSourceFactoryThatExposesUpdater updatableSource = new DataSourceFactoryThatExposesUpdater(new DataBuilder().build()); + TestData testData = TestData.dataSource(); LDConfig config = new LDConfig.Builder() - .dataSource(updatableSource) + .dataSource(testData) .events(Components.noEvents()) .build(); @@ -185,7 +173,7 @@ public void dataSourceStatusProviderSendsStatusUpdates() throws Exception { DataSourceStatusProvider.ErrorInfo errorInfo = new DataSourceStatusProvider.ErrorInfo( DataSourceStatusProvider.ErrorKind.ERROR_RESPONSE, 401, null, Instant.now()); - updatableSource.dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.OFF, errorInfo); + testData.updateStatus(DataSourceStatusProvider.State.OFF, errorInfo); DataSourceStatusProvider.Status newStatus = statuses.take(); assertThat(newStatus.getState(), equalTo(DataSourceStatusProvider.State.OFF)); @@ -264,13 +252,10 @@ public void eventsAreDispatchedOnTaskThread() throws Exception { int desiredPriority = Thread.MAX_PRIORITY - 1; BlockingQueue capturedThreads = new LinkedBlockingQueue<>(); - DataStore testDataStore = initedDataStore(); - DataBuilder initialData = new DataBuilder().addAny(DataModel.FEATURES, - flagBuilder("flagkey").version(1).build()); - DataSourceFactoryThatExposesUpdater updatableSource = new DataSourceFactoryThatExposesUpdater(initialData.build()); + TestData testData = TestData.dataSource(); + testData.update(testData.flag("flagkey").on(true)); LDConfig config = new LDConfig.Builder() - .dataStore(specificDataStore(testDataStore)) - .dataSource(updatableSource) + .dataSource(testData) .events(Components.noEvents()) .threadPriority(desiredPriority) .build(); @@ -280,7 +265,7 @@ public void eventsAreDispatchedOnTaskThread() throws Exception { capturedThreads.add(Thread.currentThread()); }); - updatableSource.updateFlag(flagBuilder("flagkey").version(2).build()); + testData.update(testData.flag("flagkey").on(false)); Thread handlerThread = capturedThreads.take(); diff --git a/src/test/java/com/launchdarkly/sdk/server/TestComponents.java b/src/test/java/com/launchdarkly/sdk/server/TestComponents.java index 125d7320f..04be2a497 100644 --- a/src/test/java/com/launchdarkly/sdk/server/TestComponents.java +++ b/src/test/java/com/launchdarkly/sdk/server/TestComponents.java @@ -2,7 +2,6 @@ import com.launchdarkly.eventsource.EventSource; import com.launchdarkly.sdk.UserAttribute; -import com.launchdarkly.sdk.server.DataModel.FeatureFlag; import com.launchdarkly.sdk.server.integrations.EventProcessorBuilder; import com.launchdarkly.sdk.server.interfaces.ClientContext; import com.launchdarkly.sdk.server.interfaces.DataSource; @@ -41,8 +40,6 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; -import static com.launchdarkly.sdk.server.DataModel.FEATURES; - @SuppressWarnings("javadoc") public class TestComponents { static ScheduledExecutorService sharedExecutor = Executors.newSingleThreadScheduledExecutor(); @@ -55,10 +52,6 @@ public static ClientContext clientContext(final String sdkKey, final LDConfig co return new ClientContextImpl(sdkKey, config, sharedExecutor, diagnosticAccumulator); } - public static DataSourceFactory dataSourceWithData(FullDataSet data) { - return (context, dataSourceUpdates) -> new DataSourceWithData(data, dataSourceUpdates); - } - public static DataStore dataStoreThatThrowsException(RuntimeException e) { return new DataStoreThatThrowsException(e); } @@ -139,25 +132,6 @@ public void flush() { } } - public static class DataSourceFactoryThatExposesUpdater implements DataSourceFactory { - private final FullDataSet initialData; - DataSourceUpdates dataSourceUpdates; - - public DataSourceFactoryThatExposesUpdater(FullDataSet initialData) { - this.initialData = initialData; - } - - @Override - public DataSource createDataSource(ClientContext context, DataSourceUpdates dataSourceUpdates) { - this.dataSourceUpdates = dataSourceUpdates; - return dataSourceWithData(initialData).createDataSource(context, dataSourceUpdates); - } - - public void updateFlag(FeatureFlag flag) { - dataSourceUpdates.upsert(FEATURES, flag.getKey(), new ItemDescriptor(flag.getVersion(), flag)); - } - } - private static class DataSourceThatNeverInitializes implements DataSource { public Future start() { return new CompletableFuture<>(); @@ -171,28 +145,6 @@ public void close() throws IOException { } }; - private static class DataSourceWithData implements DataSource { - private final FullDataSet data; - private final DataSourceUpdates dataSourceUpdates; - - DataSourceWithData(FullDataSet data, DataSourceUpdates dataSourceUpdates) { - this.data = data; - this.dataSourceUpdates = dataSourceUpdates; - } - - public Future start() { - dataSourceUpdates.init(data); - return CompletableFuture.completedFuture(null); - } - - public boolean isInitialized() { - return true; - } - - public void close() throws IOException { - } - } - public static class MockDataSourceUpdates implements DataSourceUpdates { private final DataSourceUpdatesImpl wrappedInstance; private final DataStoreStatusProvider dataStoreStatusProvider; diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataTest.java new file mode 100644 index 000000000..0b57f203f --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataTest.java @@ -0,0 +1,287 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.google.common.collect.ImmutableMap; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.server.DataModel; +import com.launchdarkly.sdk.server.interfaces.DataSource; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorInfo; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.State; +import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; +import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; + +import org.junit.Test; + +import java.util.Map; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.Future; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.function.Function; + +import static com.google.common.collect.Iterables.get; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.emptyIterable; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.iterableWithSize; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.nullValue; + +@SuppressWarnings("javadoc") +public class TestDataTest { + private static final LDValue[] THREE_STRING_VALUES = + new LDValue[] { LDValue.of("red"), LDValue.of("green"), LDValue.of("blue") }; + + private CapturingDataSourceUpdates updates = new CapturingDataSourceUpdates(); + + @Test + public void initializesWithEmptyData() throws Exception { + TestData td = TestData.dataSource(); + DataSource ds = td.createDataSource(null, updates); + Future started = ds.start(); + + assertThat(started.isDone(), is(true)); + assertThat(updates.valid, is(true)); + + assertThat(updates.inits.size(), equalTo(1)); + FullDataSet data = updates.inits.take(); + assertThat(data.getData(), iterableWithSize(1)); + assertThat(get(data.getData(), 0).getKey(), equalTo(DataModel.FEATURES)); + assertThat(get(data.getData(), 0).getValue().getItems(), emptyIterable()); + } + + @Test + public void initializesWithFlags() throws Exception { + TestData td = TestData.dataSource(); + + td.update(td.flag("flag1").on(true)) + .update(td.flag("flag2").on(false)); + + DataSource ds = td.createDataSource(null, updates); + Future started = ds.start(); + + assertThat(started.isDone(), is(true)); + assertThat(updates.valid, is(true)); + + assertThat(updates.inits.size(), equalTo(1)); + FullDataSet data = updates.inits.take(); + assertThat(data.getData(), iterableWithSize(1)); + assertThat(get(data.getData(), 0).getKey(), equalTo(DataModel.FEATURES)); + assertThat(get(data.getData(), 0).getValue().getItems(), iterableWithSize(2)); + + Map flags = ImmutableMap.copyOf(get(data.getData(), 0).getValue().getItems()); + ItemDescriptor flag1 = flags.get("flag1"); + ItemDescriptor flag2 = flags.get("flag2"); + assertThat(flag1, not(nullValue())); + assertThat(flag2, not(nullValue())); + assertThat(flag1.getVersion(), equalTo(1)); + assertThat(flag2.getVersion(), equalTo(1)); + assertThat(flagJson(flag1).get("on").booleanValue(), is(true)); + assertThat(flagJson(flag2).get("on").booleanValue(), is(false)); + } + + @Test + public void addsFlag() throws Exception { + TestData td = TestData.dataSource(); + DataSource ds = td.createDataSource(null, updates); + Future started = ds.start(); + + assertThat(started.isDone(), is(true)); + assertThat(updates.valid, is(true)); + + td.update(td.flag("flag1").on(true)); + + assertThat(updates.upserts.size(), equalTo(1)); + UpsertParams up = updates.upserts.take(); + assertThat(up.kind, is(DataModel.FEATURES)); + assertThat(up.key, equalTo("flag1")); + ItemDescriptor flag1 = up.item; + assertThat(flag1.getVersion(), equalTo(1)); + assertThat(flagJson(flag1).get("on").booleanValue(), is(true)); + } + + @Test + public void updatesFlag() throws Exception { + TestData td = TestData.dataSource(); + td.update(td.flag("flag1").on(false)); + + DataSource ds = td.createDataSource(null, updates); + Future started = ds.start(); + + assertThat(started.isDone(), is(true)); + assertThat(updates.valid, is(true)); + + td.update(td.flag("flag1").on(true)); + + assertThat(updates.upserts.size(), equalTo(1)); + UpsertParams up = updates.upserts.take(); + assertThat(up.kind, is(DataModel.FEATURES)); + assertThat(up.key, equalTo("flag1")); + ItemDescriptor flag1 = up.item; + assertThat(flag1.getVersion(), equalTo(2)); + assertThat(flagJson(flag1).get("on").booleanValue(), is(true)); + } + + @Test + public void flagConfigSimpleBoolean() throws Exception { + String basicProps = "\"key\":\"flag\",\"version\":1,\"variations\":[true,false]" + + ",\"offVariation\":1,\"fallthrough\":{\"variation\":0}"; + String onProps = basicProps + ",\"on\":true"; + String offProps = basicProps + ",\"on\":false"; + + verifyFlag(td -> td.flag("flag"), onProps); + verifyFlag(td -> td.flag("flag").booleanFlag(), onProps); + verifyFlag(td -> td.flag("flag").on(true), onProps); + verifyFlag(td -> td.flag("flag").on(false), offProps); + verifyFlag(td -> td.flag("flag").variationForAllUsers(false), offProps); + verifyFlag(td -> td.flag("flag").variationForAllUsers(true), onProps); + } + + @Test + public void flagConfigStringVariations() throws Exception { + String basicProps = "\"key\":\"flag\",\"version\":1,\"variations\":[\"red\",\"green\",\"blue\"],\"on\":true" + + ",\"offVariation\":0,\"fallthrough\":{\"variation\":2}"; + + verifyFlag( + td -> td.flag("flag").variations(THREE_STRING_VALUES).offVariation(0).fallthroughVariation(2), + basicProps + ); + } + + @Test + public void userTargets() throws Exception { + String booleanFlagBasicProps = "\"key\":\"flag\",\"version\":1,\"on\":true,\"variations\":[true,false]" + + ",\"offVariation\":1,\"fallthrough\":{\"variation\":0}"; + verifyFlag( + td -> td.flag("flag").variationForUser("a", true).variationForUser("b", true), + booleanFlagBasicProps + ",\"targets\":[{\"variation\":0,\"values\":[\"a\",\"b\"]}]" + ); + verifyFlag( + td -> td.flag("flag").variationForUser("a", false).variationForUser("b", true).variationForUser("c", false), + booleanFlagBasicProps + ",\"targets\":[{\"variation\":0,\"values\":[\"b\"]}" + + ",{\"variation\":1,\"values\":[\"a\",\"c\"]}]" + ); + verifyFlag( + td -> td.flag("flag").variationForUser("a", true).variationForUser("b", true).variationForUser("a", false), + booleanFlagBasicProps + ",\"targets\":[{\"variation\":0,\"values\":[\"b\"]}" + + ",{\"variation\":1,\"values\":[\"a\"]}]" + ); + + String stringFlagBasicProps = "\"key\":\"flag\",\"version\":1,\"variations\":[\"red\",\"green\",\"blue\"],\"on\":true" + + ",\"offVariation\":0,\"fallthrough\":{\"variation\":2}"; + verifyFlag( + td -> td.flag("flag").variations(THREE_STRING_VALUES).offVariation(0).fallthroughVariation(2) + .variationForUser("a", 2).variationForUser("b", 2), + stringFlagBasicProps + ",\"targets\":[{\"variation\":2,\"values\":[\"a\",\"b\"]}]" + ); + verifyFlag( + td -> td.flag("flag").variations(THREE_STRING_VALUES).offVariation(0).fallthroughVariation(2) + .variationForUser("a", 2).variationForUser("b", 1).variationForUser("c", 2), + stringFlagBasicProps + ",\"targets\":[{\"variation\":1,\"values\":[\"b\"]}" + + ",{\"variation\":2,\"values\":[\"a\",\"c\"]}]" + ); + } + + @Test + public void flagRules() throws Exception { + String basicProps = "\"key\":\"flag\",\"version\":1,\"variations\":[true,false]" + + ",\"on\":true,\"offVariation\":1,\"fallthrough\":{\"variation\":0}"; + + String matchReturnsVariation0 = basicProps + + ",\"rules\":[{\"id\":\"rule0\",\"variation\":0,\"trackEvents\":false,\"clauses\":[" + + "{\"attribute\":\"name\",\"op\":\"in\",\"values\":[\"Lucy\"],\"negate\":false}" + + "]}]"; + verifyFlag( + td -> td.flag("flag").ifMatch(UserAttribute.NAME, LDValue.of("Lucy")).thenReturn(true), + matchReturnsVariation0 + ); + verifyFlag( + td -> td.flag("flag").ifMatch(UserAttribute.NAME, LDValue.of("Lucy")).thenReturn(0), + matchReturnsVariation0 + ); + + String matchReturnsVariation1 = basicProps + + ",\"rules\":[{\"id\":\"rule0\",\"variation\":1,\"trackEvents\":false,\"clauses\":[" + + "{\"attribute\":\"name\",\"op\":\"in\",\"values\":[\"Lucy\"],\"negate\":false}" + + "]}]"; + verifyFlag( + td -> td.flag("flag").ifMatch(UserAttribute.NAME, LDValue.of("Lucy")).thenReturn(false), + matchReturnsVariation1 + ); + verifyFlag( + td -> td.flag("flag").ifMatch(UserAttribute.NAME, LDValue.of("Lucy")).thenReturn(1), + matchReturnsVariation1 + ); + + verifyFlag( + td -> td.flag("flag").ifNotMatch(UserAttribute.NAME, LDValue.of("Lucy")).thenReturn(true), + basicProps + ",\"rules\":[{\"id\":\"rule0\",\"variation\":0,\"trackEvents\":false,\"clauses\":[" + + "{\"attribute\":\"name\",\"op\":\"in\",\"values\":[\"Lucy\"],\"negate\":true}" + + "]}]" + ); + } + + private void verifyFlag(Function makeFlag, String expectedProps) throws Exception { + String expectedJson = "{" + expectedProps + + ",\"clientSide\":false,\"deleted\":false,\"trackEvents\":false,\"trackEventsFallthrough\":false}"; + + TestData td = TestData.dataSource(); + + DataSource ds = td.createDataSource(null, updates); + ds.start(); + + td.update(makeFlag.apply(td)); + + assertThat(updates.upserts.size(), equalTo(1)); + UpsertParams up = updates.upserts.take(); + ItemDescriptor flag = up.item; + assertThat(flagJson(flag), equalTo(LDValue.parse(expectedJson))); + } + + private static LDValue flagJson(ItemDescriptor flag) { + return LDValue.parse(DataModel.FEATURES.serialize(flag)); + } + + private static class UpsertParams { + final DataKind kind; + final String key; + final ItemDescriptor item; + + UpsertParams(DataKind kind, String key, ItemDescriptor item) { + this.kind = kind; + this.key = key; + this.item = item; + } + } + + private static class CapturingDataSourceUpdates implements DataSourceUpdates { + BlockingQueue> inits = new LinkedBlockingQueue<>(); + BlockingQueue upserts = new LinkedBlockingQueue<>(); + boolean valid; + + @Override + public boolean init(FullDataSet allData) { + inits.add(allData); + return true; + } + + @Override + public boolean upsert(DataKind kind, String key, ItemDescriptor item) { + upserts.add(new UpsertParams(kind, key, item)); + return true; + } + + @Override + public DataStoreStatusProvider getDataStoreStatusProvider() { + return null; + } + + @Override + public void updateStatus(State newState, ErrorInfo newError) { + valid = newState == State.VALID; + } + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataWithClientTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataWithClientTest.java new file mode 100644 index 000000000..fc8bcf747 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataWithClientTest.java @@ -0,0 +1,112 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.LDClient; +import com.launchdarkly.sdk.server.LDConfig; + +import org.junit.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; + +@SuppressWarnings("javadoc") +public class TestDataWithClientTest { + private static final String SDK_KEY = "sdk-key"; + + private TestData td = TestData.dataSource(); + private LDConfig config = new LDConfig.Builder() + .dataSource(td) + .events(Components.noEvents()) + .build(); + + @Test + public void initializesWithEmptyData() throws Exception { + try (LDClient client = new LDClient(SDK_KEY, config)) { + assertThat(client.isInitialized(), is(true)); + } + } + + @Test + public void initializesWithFlag() throws Exception { + td.update(td.flag("flag").on(true)); + + try (LDClient client = new LDClient(SDK_KEY, config)) { + assertThat(client.boolVariation("flag", new LDUser("user"), false), is(true)); + } + } + + @Test + public void updatesFlag() throws Exception { + td.update(td.flag("flag").on(false)); + + try (LDClient client = new LDClient(SDK_KEY, config)) { + assertThat(client.boolVariation("flag", new LDUser("user"), false), is(false)); + + td.update(td.flag("flag").on(true)); + + assertThat(client.boolVariation("flag", new LDUser("user"), false), is(true)); + } + } + + @Test + public void usesTargets() throws Exception { + td.update(td.flag("flag").fallthroughVariation(false).variationForUser("user1", true)); + + try (LDClient client = new LDClient(SDK_KEY, config)) { + assertThat(client.boolVariation("flag", new LDUser("user1"), false), is(true)); + assertThat(client.boolVariation("flag", new LDUser("user2"), false), is(false)); + } + } + + @Test + public void usesRules() throws Exception { + td.update(td.flag("flag").fallthroughVariation(false) + .ifMatch(UserAttribute.NAME, LDValue.of("Lucy")).thenReturn(true) + .ifMatch(UserAttribute.NAME, LDValue.of("Mina")).thenReturn(true)); + + try (LDClient client = new LDClient(SDK_KEY, config)) { + assertThat(client.boolVariation("flag", new LDUser.Builder("user1").name("Lucy").build(), false), is(true)); + assertThat(client.boolVariation("flag", new LDUser.Builder("user2").name("Mina").build(), false), is(true)); + assertThat(client.boolVariation("flag", new LDUser.Builder("user3").name("Quincy").build(), false), is(false)); + } + } + + @Test + public void nonBooleanFlags() throws Exception { + td.update(td.flag("flag").variations(LDValue.of("red"), LDValue.of("green"), LDValue.of("blue")) + .offVariation(0).fallthroughVariation(2) + .variationForUser("user1", 1) + .ifMatch(UserAttribute.NAME, LDValue.of("Mina")).thenReturn(1)); + + try (LDClient client = new LDClient(SDK_KEY, config)) { + assertThat(client.stringVariation("flag", new LDUser.Builder("user1").name("Lucy").build(), ""), equalTo("green")); + assertThat(client.stringVariation("flag", new LDUser.Builder("user2").name("Mina").build(), ""), equalTo("green")); + assertThat(client.stringVariation("flag", new LDUser.Builder("user3").name("Quincy").build(), ""), equalTo("blue")); + + td.update(td.flag("flag").on(false)); + + assertThat(client.stringVariation("flag", new LDUser.Builder("user1").name("Lucy").build(), ""), equalTo("red")); + } + } + + @Test + public void dataSourcePropagatesToMultipleClients() throws Exception { + td.update(td.flag("flag").on(true)); + + try (LDClient client1 = new LDClient(SDK_KEY, config)) { + try (LDClient client2 = new LDClient(SDK_KEY, config)) { + assertThat(client1.boolVariation("flag", new LDUser("user"), false), is(true)); + assertThat(client2.boolVariation("flag", new LDUser("user"), false), is(true)); + + td.update(td.flag("flag").on(false)); + + assertThat(client1.boolVariation("flag", new LDUser("user"), false), is(false)); + assertThat(client2.boolVariation("flag", new LDUser("user"), false), is(false)); + } + } + } +} From 907882d8d0fcb4f2ab9e718572d9130f6f85f139 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 1 Jun 2020 10:32:16 -0700 Subject: [PATCH 512/673] more info about coverage in CONTRIBUTING.md --- CONTRIBUTING.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 41af06baf..cd8bcc251 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,10 +48,10 @@ The project in the `benchmarks` subdirectory uses [JMH](https://openjdk.java.net ## Code coverage -It is important to keep unit test coverage as close to 100% as possible in this project. You can view the latest code coverage report in CircleCI, as `coverage/html/index.html` in the artifacts for the "Java 11 - Linux - OpenJDK" job. You can also run the report locally with `./gradlew jacocoTestCoverage` and view `./build/reports/jacoco/test`. +It is important to keep unit test coverage as close to 100% as possible in this project. You can view the latest code coverage report in CircleCI, as `coverage/html/index.html` in the artifacts for the "Java 11 - Linux - OpenJDK" job. You can also run the report locally with `./gradlew jacocoTestCoverage` and view `./build/reports/jacoco/test`. _The CircleCI build will fail if you commit a change that increases the number of uncovered lines_, unless you explicitly add an override as shown below. Sometimes a gap in coverage is unavoidable, usually because the compiler requires us to provide a code path for some condition that in practice can't happen and can't be tested, or because of a known issue with the code coverage tool. Please handle all such cases as follows: * Mark the code with an explanatory comment beginning with "COVERAGE:". * Run the code coverage task with `./gradlew jacocoTestCoverageVerification`. It should fail and indicate how many lines of missed coverage exist in the method you modified. -* Add an item in the `knownMissedLinesForMethods` map in `build.gradle` that specifies that number of missed lines for that method signature. +* Add an item in the `knownMissedLinesForMethods` map in `build.gradle` that specifies that number of missed lines for that method signature. For instance, if the method `com.launchdarkly.sdk.server.SomeClass.someMethod(java.lang.String)` has two missed lines that cannot be covered, you would add `"SomeClass.someMethod(java.lang.String)": 2`. From fa2b39c9682c278f63f8ecd581a5dc8979b83a95 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 1 Jun 2020 20:46:09 -0700 Subject: [PATCH 513/673] misc fixes/tests --- .../sdk/server/integrations/FileData.java | 6 +- .../sdk/server/integrations/TestData.java | 33 ++++++--- .../sdk/server/integrations/TestDataTest.java | 70 +++++++++++++++++++ .../integrations/TestDataWithClientTest.java | 15 ++++ 4 files changed, 112 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/FileData.java b/src/main/java/com/launchdarkly/sdk/server/integrations/FileData.java index 40683fbdb..a6081eb98 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/FileData.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/FileData.java @@ -6,8 +6,12 @@ * 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. - * + *

    + * This is different from {@link TestData}, which allows you to simulate flag configurations + * programmatically rather than using a file. + * * @since 4.12.0 + * @see TestData */ public abstract class FileData { /** diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/TestData.java b/src/main/java/com/launchdarkly/sdk/server/integrations/TestData.java index b7abc5724..5973e34e6 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/TestData.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/TestData.java @@ -58,7 +58,8 @@ * If the same {@code TestData} instance is used to configure multiple {@code LDClient} instances, * any changes made to the data will propagate to all of the {@code LDClient}s. * - * @since 5.0.0 + * @since 5.1.0 + * @see FileData */ public final class TestData implements DataSourceFactory { private final Object lock = new Object(); @@ -293,6 +294,17 @@ public FlagBuilder fallthroughVariation(int index) { return this; } + /** + * Specifies the off variation for a boolean flag. This is the variation that is returned + * whenever targeting is off. + * + * @param value true if the flag should return true when targeting is off + * @return the builder + */ + public FlagBuilder offVariation(boolean value) { + return this.booleanFlag().offVariation(value ? TRUE_VARIATION_FOR_BOOLEAN : FALSE_VARIATION_FOR_BOOLEAN); + } + /** * Specifies the index of the off variation. This is the variation that is returned * whenever targeting is off. @@ -310,19 +322,18 @@ public FlagBuilder offVariation(int index) { *

    * If the flag is a boolean flag, this causes targeting to be switched on or off, and also * removes any existing targets or rules. If the flag was not already a boolean flag, it is - * the same as calling {@code valueForAllUsers(LDValue.of(true))}. + * converted to a boolean flag. * * @param variation the desired true/false variation to be returned for all users * @return the builder */ public FlagBuilder variationForAllUsers(boolean variation) { - if (isBooleanFlag()) { - targets = null; - return offVariation(FALSE_VARIATION_FOR_BOOLEAN) - .fallthroughVariation(TRUE_VARIATION_FOR_BOOLEAN) - .on(variation); - } - return valueForAllUsers(LDValue.of(variation)); + return clearRules() + .clearUserTargets() + .booleanFlag() + .offVariation(FALSE_VARIATION_FOR_BOOLEAN) + .fallthroughVariation(TRUE_VARIATION_FOR_BOOLEAN) + .on(variation); } /** @@ -509,7 +520,7 @@ ItemDescriptor createFlag(int version) { } builder.put("variations", jsonVariations.build()); - if (targets != null && !targets.isEmpty()) { + if (targets != null) { ArrayBuilder jsonTargets = LDValue.buildArray(); for (Map.Entry> e: targets.entrySet()) { jsonTargets.add(LDValue.buildObject() @@ -520,7 +531,7 @@ ItemDescriptor createFlag(int version) { builder.put("targets", jsonTargets.build()); } - if (rules != null && !rules.isEmpty()) { + if (rules != null) { ArrayBuilder jsonRules = LDValue.buildArray(); int ri = 0; for (FlagRuleBuilder r: rules) { diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataTest.java index 0b57f203f..9fd2c864b 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataTest.java @@ -138,8 +138,43 @@ public void flagConfigSimpleBoolean() throws Exception { verifyFlag(td -> td.flag("flag").on(false), offProps); verifyFlag(td -> td.flag("flag").variationForAllUsers(false), offProps); verifyFlag(td -> td.flag("flag").variationForAllUsers(true), onProps); + + verifyFlag( + td -> td.flag("flag").fallthroughVariation(true).offVariation(false), + onProps + ); + + verifyFlag( + td -> td.flag("flag").fallthroughVariation(false).offVariation(true), + "\"key\":\"flag\",\"version\":1,\"variations\":[true,false],\"on\":true" + + ",\"offVariation\":0,\"fallthrough\":{\"variation\":1}" + ); } + @Test + public void usingBooleanConfigMethodsForcesFlagToBeBoolean() throws Exception { + String booleanProps = "\"key\":\"flag\",\"version\":1,\"on\":true" + + ",\"variations\":[true,false],\"offVariation\":1,\"fallthrough\":{\"variation\":0}"; + + verifyFlag( + td -> td.flag("flag") + .variations(LDValue.of(1), LDValue.of(2)) + .booleanFlag(), + booleanProps + ); + verifyFlag( + td -> td.flag("flag") + .variations(LDValue.of(true), LDValue.of(2)) + .booleanFlag(), + booleanProps + ); + verifyFlag( + td -> td.flag("flag").valueForAllUsers(LDValue.of("x")) + .booleanFlag(), + booleanProps + ); + } + @Test public void flagConfigStringVariations() throws Exception { String basicProps = "\"key\":\"flag\",\"version\":1,\"variations\":[\"red\",\"green\",\"blue\"],\"on\":true" @@ -159,6 +194,10 @@ public void userTargets() throws Exception { td -> td.flag("flag").variationForUser("a", true).variationForUser("b", true), booleanFlagBasicProps + ",\"targets\":[{\"variation\":0,\"values\":[\"a\",\"b\"]}]" ); + verifyFlag( + td -> td.flag("flag").variationForUser("a", true).variationForUser("a", true), + booleanFlagBasicProps + ",\"targets\":[{\"variation\":0,\"values\":[\"a\"]}]" + ); verifyFlag( td -> td.flag("flag").variationForUser("a", false).variationForUser("b", true).variationForUser("c", false), booleanFlagBasicProps + ",\"targets\":[{\"variation\":0,\"values\":[\"b\"]}" + @@ -190,6 +229,7 @@ public void flagRules() throws Exception { String basicProps = "\"key\":\"flag\",\"version\":1,\"variations\":[true,false]" + ",\"on\":true,\"offVariation\":1,\"fallthrough\":{\"variation\":0}"; + // match that returns variation 0/true String matchReturnsVariation0 = basicProps + ",\"rules\":[{\"id\":\"rule0\",\"variation\":0,\"trackEvents\":false,\"clauses\":[" + "{\"attribute\":\"name\",\"op\":\"in\",\"values\":[\"Lucy\"],\"negate\":false}" + @@ -203,6 +243,7 @@ public void flagRules() throws Exception { matchReturnsVariation0 ); + // match that returns variation 1/false String matchReturnsVariation1 = basicProps + ",\"rules\":[{\"id\":\"rule0\",\"variation\":1,\"trackEvents\":false,\"clauses\":[" + "{\"attribute\":\"name\",\"op\":\"in\",\"values\":[\"Lucy\"],\"negate\":false}" + @@ -216,12 +257,41 @@ public void flagRules() throws Exception { matchReturnsVariation1 ); + // negated match verifyFlag( td -> td.flag("flag").ifNotMatch(UserAttribute.NAME, LDValue.of("Lucy")).thenReturn(true), basicProps + ",\"rules\":[{\"id\":\"rule0\",\"variation\":0,\"trackEvents\":false,\"clauses\":[" + "{\"attribute\":\"name\",\"op\":\"in\",\"values\":[\"Lucy\"],\"negate\":true}" + "]}]" ); + + // multiple clauses + verifyFlag( + td -> td.flag("flag") + .ifMatch(UserAttribute.NAME, LDValue.of("Lucy")) + .andMatch(UserAttribute.COUNTRY, LDValue.of("gb")) + .thenReturn(true), + basicProps + ",\"rules\":[{\"id\":\"rule0\",\"variation\":0,\"trackEvents\":false,\"clauses\":[" + + "{\"attribute\":\"name\",\"op\":\"in\",\"values\":[\"Lucy\"],\"negate\":false}," + + "{\"attribute\":\"country\",\"op\":\"in\",\"values\":[\"gb\"],\"negate\":false}" + + "]}]" + ); + + // multiple rules + verifyFlag( + td -> td.flag("flag") + .ifMatch(UserAttribute.NAME, LDValue.of("Lucy")).thenReturn(true) + .ifMatch(UserAttribute.NAME, LDValue.of("Mina")).thenReturn(true), + basicProps + ",\"rules\":[" + + "{\"id\":\"rule0\",\"variation\":0,\"trackEvents\":false,\"clauses\":[" + + "{\"attribute\":\"name\",\"op\":\"in\",\"values\":[\"Lucy\"],\"negate\":false}" + + "]}," + + "{\"id\":\"rule1\",\"variation\":0,\"trackEvents\":false,\"clauses\":[" + + "{\"attribute\":\"name\",\"op\":\"in\",\"values\":[\"Mina\"],\"negate\":false}" + + "]}" + + "]" + ); + } private void verifyFlag(Function makeFlag, String expectedProps) throws Exception { diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataWithClientTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataWithClientTest.java index fc8bcf747..452b82c17 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataWithClientTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataWithClientTest.java @@ -6,6 +6,8 @@ import com.launchdarkly.sdk.server.Components; import com.launchdarkly.sdk.server.LDClient; import com.launchdarkly.sdk.server.LDConfig; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorInfo; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.State; import org.junit.Test; @@ -93,6 +95,19 @@ public void nonBooleanFlags() throws Exception { } } + @Test + public void canUpdateStatus() throws Exception { + try (LDClient client = new LDClient(SDK_KEY, config)) { + assertThat(client.getDataSourceStatusProvider().getStatus().getState(), equalTo(State.VALID)); + + ErrorInfo ei = ErrorInfo.fromHttpError(500); + td.updateStatus(State.INTERRUPTED, ei); + + assertThat(client.getDataSourceStatusProvider().getStatus().getState(), equalTo(State.INTERRUPTED)); + assertThat(client.getDataSourceStatusProvider().getStatus().getLastError(), equalTo(ei)); + } + } + @Test public void dataSourcePropagatesToMultipleClients() throws Exception { td.update(td.flag("flag").on(true)); From 705d25bd0b8ed652421c888c5d41949c240a5adf Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 1 Jun 2020 21:15:30 -0700 Subject: [PATCH 514/673] use java-sdk-common 1.0.0 --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 74b7c0428..0f020586e 100644 --- a/build.gradle +++ b/build.gradle @@ -72,7 +72,7 @@ ext.versions = [ "gson": "2.7", "guava": "28.2-jre", "jackson": "2.10.0", - "launchdarklyJavaSdkCommon": "1.0.0-rc1", + "launchdarklyJavaSdkCommon": "1.0.0", "okhttpEventsource": "2.3.0-SNAPSHOT", "slf4j": "1.7.21", "snakeyaml": "1.19", From 2a44530db77cc3739b1adf1f98c925b8b5abeaab Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 2 Jun 2020 12:34:14 -0700 Subject: [PATCH 515/673] use okhttp-eventsource 2.3.0 --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 0f020586e..a1e09f426 100644 --- a/build.gradle +++ b/build.gradle @@ -73,7 +73,7 @@ ext.versions = [ "guava": "28.2-jre", "jackson": "2.10.0", "launchdarklyJavaSdkCommon": "1.0.0", - "okhttpEventsource": "2.3.0-SNAPSHOT", + "okhttpEventsource": "2.3.0", "slf4j": "1.7.21", "snakeyaml": "1.19", "jedis": "2.9.0" From ddd886ab3c1c6621866759939d044594e2e4605b Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 18 Jun 2020 15:17:25 -0700 Subject: [PATCH 516/673] use okhttp-eventsource 2.3.1 for thread fix --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index a1e09f426..9c6e53740 100644 --- a/build.gradle +++ b/build.gradle @@ -73,7 +73,7 @@ ext.versions = [ "guava": "28.2-jre", "jackson": "2.10.0", "launchdarklyJavaSdkCommon": "1.0.0", - "okhttpEventsource": "2.3.0", + "okhttpEventsource": "2.3.1", "slf4j": "1.7.21", "snakeyaml": "1.19", "jedis": "2.9.0" From 6fb600d0d2af8f420310a89687fa08e866fdd7a2 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 19 Jun 2020 11:34:29 -0700 Subject: [PATCH 517/673] fix flaky tests due to change in EventSource error reporting --- .../com/launchdarkly/sdk/server/StreamProcessorTest.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java index 200cd34d6..7ead898fc 100644 --- a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java @@ -26,6 +26,7 @@ import org.junit.Before; import org.junit.Test; +import java.io.EOFException; import java.io.IOException; import java.net.URI; import java.time.Duration; @@ -826,7 +827,9 @@ static class ConnectionErrorSink implements ConnectionErrorHandler { final BlockingQueue errors = new LinkedBlockingQueue<>(); public Action onConnectionError(Throwable t) { - errors.add(t); + if (!(t instanceof EOFException)) { + errors.add(t); + } return Action.SHUTDOWN; } } From 8873f4429110772f74959c7d90e4ea29f36f0892 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 23 Jun 2020 11:45:16 -0700 Subject: [PATCH 518/673] remove support for indirect put and indirect patch --- .../sdk/server/ComponentsImpl.java | 19 +-- .../sdk/server/StreamProcessor.java | 49 ------ .../StreamingDataSourceBuilder.java | 13 +- .../sdk/server/DiagnosticEventTest.java | 19 +-- .../sdk/server/StreamProcessorTest.java | 158 +----------------- .../StreamingDataSourceBuilderTest.java | 11 +- 6 files changed, 16 insertions(+), 253 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java b/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java index 7b6a3008a..69e3499a5 100644 --- a/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java @@ -139,24 +139,9 @@ public DataSource createDataSource(ClientContext context, DataSourceUpdates data Loggers.DATA_SOURCE.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( - context.getHttp(), - pollUri, - false - ); return new StreamProcessor( context.getHttp(), - requestor, dataSourceUpdates, null, context.getBasic().getThreadPriority(), @@ -170,9 +155,7 @@ public DataSource createDataSource(ClientContext context, DataSourceUpdates data public LDValue describeConfiguration(BasicConfiguration basicConfiguration) { 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))) + .put(ConfigProperty.CUSTOM_BASE_URI.name, false) .put(ConfigProperty.CUSTOM_STREAM_URI.name, baseURI != null && !baseURI.equals(LDConfig.DEFAULT_STREAM_URI)) .put(ConfigProperty.RECONNECT_TIME_MILLIS.name, initialReconnectDelay.toMillis()) diff --git a/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java index 7ba8f999c..7f5ccc53f 100644 --- a/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java @@ -71,8 +71,6 @@ final class StreamProcessor implements DataSource { private static final String PUT = "put"; private static final String PATCH = "patch"; private static final String DELETE = "delete"; - private static final String INDIRECT_PUT = "indirect/put"; - private static final String INDIRECT_PATCH = "indirect/patch"; private static final Logger logger = Loggers.DATA_SOURCE; private static final Duration DEAD_CONNECTION_INTERVAL = Duration.ofSeconds(300); private static final String ERROR_CONTEXT_MESSAGE = "in stream connection"; @@ -83,7 +81,6 @@ final class StreamProcessor implements DataSource { private final Headers headers; @VisibleForTesting final URI streamUri; @VisibleForTesting final Duration initialReconnectDelay; - @VisibleForTesting final FeatureRequestor requestor; private final DiagnosticAccumulator diagnosticAccumulator; private final EventSourceCreator eventSourceCreator; private final int threadPriority; @@ -121,7 +118,6 @@ static interface EventSourceCreator { StreamProcessor( HttpConfiguration httpConfig, - FeatureRequestor requestor, DataSourceUpdates dataSourceUpdates, EventSourceCreator eventSourceCreator, int threadPriority, @@ -131,7 +127,6 @@ static interface EventSourceCreator { ) { this.dataSourceUpdates = dataSourceUpdates; this.httpConfig = httpConfig; - this.requestor = requestor; this.diagnosticAccumulator = diagnosticAccumulator; this.eventSourceCreator = eventSourceCreator != null ? eventSourceCreator : this::defaultEventSourceCreator; this.threadPriority = threadPriority; @@ -232,7 +227,6 @@ public void close() throws IOException { if (es != null) { es.close(); } - requestor.close(); dataSourceUpdates.updateStatus(State.OFF, null); } @@ -271,14 +265,6 @@ public void onMessage(String name, MessageEvent event) throws Exception { case DELETE: handleDelete(event.getData()); break; - - case INDIRECT_PUT: - handleIndirectPut(); - break; - - case INDIRECT_PATCH: - handleIndirectPatch(event.getData()); - break; default: logger.warn("Unexpected event found in stream: " + name); @@ -356,41 +342,6 @@ private void handleDelete(String eventData) throws StreamInputException, StreamS } } - private void handleIndirectPut() throws StreamInputException, StreamStoreException { - FeatureRequestor.AllData putData; - try { - putData = requestor.getAllData(); - } catch (Exception e) { - throw new StreamInputException(e); - } - FullDataSet allData = putData.toFullDataSet(); - if (!dataSourceUpdates.init(allData)) { - throw new StreamStoreException(); - } - if (!initialized.getAndSet(true)) { - initFuture.complete(null); - logger.info("Initialized LaunchDarkly client."); - } - } - - private void handleIndirectPatch(String path) throws StreamInputException, StreamStoreException { - Map.Entry kindAndKey = getKindAndKeyFromStreamApiPath(path); - DataKind kind = kindAndKey.getKey(); - String key = kindAndKey.getValue(); - VersionedData item; - try { - item = kind == SEGMENTS ? requestor.getSegment(key) : requestor.getFlag(key); - } catch (Exception e) { - throw new StreamInputException(e); - // In this case, StreamInputException doesn't necessarily represent malformed data from the service - it - // could be that the request to the polling endpoint failed in some other way. But either way, we must - // assume that we did not get valid data from LD so we have missed an update. - } - if (!dataSourceUpdates.upsert(kind, key, new ItemDescriptor(item.getVersion(), item))) { - throw new StreamStoreException(); - } - } - @Override public void onComment(String comment) { logger.debug("Received a heartbeat"); diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/StreamingDataSourceBuilder.java b/src/main/java/com/launchdarkly/sdk/server/integrations/StreamingDataSourceBuilder.java index 4943858d2..f09de74c2 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/StreamingDataSourceBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/StreamingDataSourceBuilder.java @@ -29,7 +29,6 @@ public abstract class StreamingDataSourceBuilder implements DataSourceFactory { public static final Duration DEFAULT_INITIAL_RECONNECT_DELAY = Duration.ofMillis(1000); protected URI baseURI; - protected URI pollingBaseURI; protected Duration initialReconnectDelay = DEFAULT_INITIAL_RECONNECT_DELAY; /** @@ -69,18 +68,18 @@ public StreamingDataSourceBuilder initialReconnectDelay(Duration initialReconnec } /** - * Sets a custom base URI for special polling requests. + * Obsolete method for setting a different 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 endpoint for the - * LaunchDarkly service. If you are using the Relay Proxy, - * you only need to set {@link #baseURI(URI)}. + * Previously, LaunchDarkly sometimes required the SDK to temporarily do a polling request even in + * streaming mode (based on the size of the updated data item); this property specified the base URI + * for such requests. However, the system no longer has this behavior so this property is ignored. * * @param pollingBaseURI the polling endpoint URI; null to use the default * @return the builder + * @deprecated this method no longer affects anything and will be removed in the future */ + @Deprecated public StreamingDataSourceBuilder pollingBaseURI(URI pollingBaseURI) { - this.pollingBaseURI = pollingBaseURI; return this; } } diff --git a/src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java b/src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java index cc0407ae4..5472408c0 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java @@ -127,12 +127,11 @@ public void testCustomDiagnosticConfigurationForStreaming() { .dataSource( Components.streamingDataSource() .baseURI(CUSTOM_URI) - .pollingBaseURI(CUSTOM_URI) .initialReconnectDelay(Duration.ofSeconds(2)) ) .build(); LDValue expected1 = expectedDefaultPropertiesWithoutStreaming() - .put("customBaseURI", true) + .put("customBaseURI", false) .put("customStreamURI", true) .put("reconnectTimeMillis", 2_000) .build(); @@ -150,25 +149,11 @@ public void testCustomDiagnosticConfigurationForStreaming() { LDValue expected3 = expectedDefaultProperties().build(); assertEquals(expected3, makeConfigData(ldConfig3)); - LDConfig ldConfig4 = new LDConfig.Builder() - .dataSource(Components.streamingDataSource().pollingBaseURI(CUSTOM_URI)) - .build(); - LDValue expected4 = expectedDefaultProperties() - .put("customBaseURI", true) - .build(); - assertEquals(expected4, makeConfigData(ldConfig4)); - - LDConfig ldConfig5 = new LDConfig.Builder() - .dataSource(Components.streamingDataSource().pollingBaseURI(LDConfig.DEFAULT_BASE_URI)) - .build(); - LDValue expected5 = expectedDefaultProperties().build(); - assertEquals(expected5, makeConfigData(ldConfig5)); - LDConfig ldConfig6 = new LDConfig.Builder() .dataSource(Components.streamingDataSource().baseURI(CUSTOM_URI)) .build(); LDValue expected6 = expectedDefaultProperties() - .put("customBaseURI", true) + .put("customBaseURI", false) .put("customStreamURI", true) .build(); assertEquals(expected6, makeConfigData(ldConfig6)); diff --git a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java index 7ead898fc..338ca289a 100644 --- a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java @@ -30,7 +30,6 @@ import java.io.IOException; import java.net.URI; import java.time.Duration; -import java.util.Collections; import java.util.Map; import java.util.concurrent.BlockingQueue; import java.util.concurrent.CompletableFuture; @@ -53,7 +52,6 @@ import static com.launchdarkly.sdk.server.TestUtil.shouldNotTimeOut; import static com.launchdarkly.sdk.server.TestUtil.shouldTimeOut; import static com.launchdarkly.sdk.server.integrations.StreamingDataSourceBuilder.DEFAULT_INITIAL_RECONNECT_DELAY; -import static org.easymock.EasyMock.expect; import static org.easymock.EasyMock.expectLastCall; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; @@ -88,7 +86,6 @@ public class StreamProcessorTest extends EasyMockSupport { private InMemoryDataStore dataStore; private MockDataSourceUpdates dataSourceUpdates; private MockDataStoreStatusProvider dataStoreStatusProvider; - private FeatureRequestor mockRequestor; private EventSource mockEventSource; private MockEventSourceCreator mockEventSourceCreator; @@ -97,7 +94,6 @@ public void setup() { dataStore = new InMemoryDataStore(); dataStoreStatusProvider = new MockDataStoreStatusProvider(); dataSourceUpdates = TestComponents.dataSourceUpdates(dataStore, dataStoreStatusProvider); - mockRequestor = createStrictMock(FeatureRequestor.class); mockEventSource = createMock(EventSource.class); mockEventSourceCreator = new MockEventSourceCreator(mockEventSource); } @@ -109,23 +105,19 @@ public void builderHasDefaultConfiguration() throws Exception { dataSourceUpdates)) { assertThat(sp.initialReconnectDelay, equalTo(StreamingDataSourceBuilder.DEFAULT_INITIAL_RECONNECT_DELAY)); 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"); DataSourceFactory f = Components.streamingDataSource() .baseURI(streamUri) - .initialReconnectDelay(Duration.ofMillis(5555)) - .pollingBaseURI(pollUri); + .initialReconnectDelay(Duration.ofMillis(5555)); try (StreamProcessor sp = (StreamProcessor)f.createDataSource(clientContext(SDK_KEY, LDConfig.DEFAULT), dataSourceUpdates(dataStore))) { assertThat(sp.initialReconnectDelay, equalTo(Duration.ofMillis(5555))); assertThat(sp.streamUri, equalTo(streamUri)); - assertThat(((DefaultFeatureRequestor)sp.requestor).baseUri, equalTo(pollUri)); } } @@ -281,94 +273,6 @@ private void doDeleteSuccessTest(DataKind kind, VersionedData item, String path) assertEquals(ItemDescriptor.deletedItem(item.getVersion() + 1), dataStore.get(kind, item.getKey())); } - @Test - public void indirectPutRequestsAndStoresFeature() throws Exception { - setupRequestorToReturnAllDataWithFlag(FEATURE); - expectNoStreamRestart(); - replayAll(); - - try (StreamProcessor sp = createStreamProcessor(STREAM_URI)) { - sp.start(); - - EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; - handler.onMessage("indirect/put", new MessageEvent("")); - - assertFeatureInStore(FEATURE); - } - } - - @Test - public void indirectPutInitializesStore() throws Exception { - createStreamProcessor(STREAM_URI).start(); - setupRequestorToReturnAllDataWithFlag(FEATURE); - replayAll(); - - EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; - handler.onMessage("indirect/put", new MessageEvent("")); - - assertTrue(dataStore.isInitialized()); - } - - @Test - public void indirectPutInitializesProcessor() throws Exception { - StreamProcessor sp = createStreamProcessor(STREAM_URI); - sp.start(); - setupRequestorToReturnAllDataWithFlag(FEATURE); - replayAll(); - - EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; - handler.onMessage("indirect/put", new MessageEvent("")); - - assertTrue(dataStore.isInitialized()); - } - - @Test - public void indirectPutSetsFuture() throws Exception { - StreamProcessor sp = createStreamProcessor(STREAM_URI); - Future future = sp.start(); - setupRequestorToReturnAllDataWithFlag(FEATURE); - replayAll(); - - EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; - handler.onMessage("indirect/put", new MessageEvent("")); - - assertTrue(future.isDone()); - } - - @Test - public void indirectPatchRequestsAndUpdatesFeature() throws Exception { - expect(mockRequestor.getFlag(FEATURE1_KEY)).andReturn(FEATURE); - expectNoStreamRestart(); - replayAll(); - - try (StreamProcessor sp = createStreamProcessor(STREAM_URI)) { - sp.start(); - - EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; - handler.onMessage("put", emptyPutEvent()); - handler.onMessage("indirect/patch", new MessageEvent("/flags/" + FEATURE1_KEY)); - - assertFeatureInStore(FEATURE); - } - } - - @Test - public void indirectPatchRequestsAndUpdatesSegment() throws Exception { - expect(mockRequestor.getSegment(SEGMENT1_KEY)).andReturn(SEGMENT); - expectNoStreamRestart(); - replayAll(); - - try (StreamProcessor sp = createStreamProcessor(STREAM_URI)) { - sp.start(); - - EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; - handler.onMessage("put", emptyPutEvent()); - handler.onMessage("indirect/patch", new MessageEvent("/segments/" + SEGMENT1_KEY)); - - assertSegmentInStore(SEGMENT); - } - } - @Test public void unknownEventTypeDoesNotThrowException() throws Exception { createStreamProcessor(STREAM_URI).start(); @@ -532,18 +436,6 @@ public void indirectPatchEventWithInvalidPathDoesNotCauseStreamRestart() throws verifyEventCausesNoStreamRestart("indirect/patch", "/wrong"); } - @Test - public void indirectPutWithFailedPollCausesStreamRestart() throws Exception { - expect(mockRequestor.getAllData()).andThrow(new IOException("sorry")); - verifyEventCausesStreamRestartWithInMemoryStore("indirect/put", ""); - } - - @Test - public void indirectPatchWithFailedPollCausesStreamRestart() throws Exception { - expect(mockRequestor.getFlag("flagkey")).andThrow(new IOException("sorry")); - verifyEventCausesStreamRestartWithInMemoryStore("indirect/patch", "/flags/flagkey"); - } - @Test public void restartsStreamIfStoreNeedsRefresh() throws Exception { CompletableFuture restarted = new CompletableFuture<>(); @@ -556,8 +448,6 @@ public void restartsStreamIfStoreNeedsRefresh() throws Exception { }); mockEventSource.close(); expectLastCall(); - mockRequestor.close(); - expectLastCall(); replayAll(); @@ -583,8 +473,6 @@ public void doesNotRestartStreamIfStoreHadOutageButDoesNotNeedRefresh() throws E }); mockEventSource.close(); expectLastCall(); - mockRequestor.close(); - expectLastCall(); replayAll(); @@ -646,37 +534,6 @@ public void storeFailureOnDeleteCausesStreamRestart() throws Exception { verifyAll(); } - @Test - public void storeFailureOnIndirectPutCausesStreamRestart() throws Exception { - MockDataSourceUpdates badUpdates = dataSourceUpdatesThatMakesUpdatesFailAndDoesNotSupportStatusMonitoring(); - setupRequestorToReturnAllDataWithFlag(FEATURE); - expectStreamRestart(); - replayAll(); - - try (StreamProcessor sp = createStreamProcessorWithStoreUpdates(badUpdates)) { - sp.start(); - EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; - handler.onMessage("indirect/put", new MessageEvent("")); - } - verifyAll(); - } - - @Test - public void storeFailureOnIndirectPatchCausesStreamRestart() throws Exception { - MockDataSourceUpdates badUpdates = dataSourceUpdatesThatMakesUpdatesFailAndDoesNotSupportStatusMonitoring(); - expect(mockRequestor.getFlag(FEATURE1_KEY)).andReturn(FEATURE); - - expectStreamRestart(); - replayAll(); - - try (StreamProcessor sp = createStreamProcessorWithStoreUpdates(badUpdates)) { - sp.start(); - EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; - handler.onMessage("indirect/patch", new MessageEvent("/flags/" + FEATURE1_KEY)); - } - verifyAll(); - } - @Test public void onCommentIsIgnored() throws Exception { // This just verifies that we are not doing anything with comment data, by passing a null instead of a string @@ -744,8 +601,6 @@ private void expectNoStreamRestart() throws Exception { expectLastCall().times(1); mockEventSource.close(); expectLastCall().times(1); - mockRequestor.close(); - expectLastCall().times(1); } private void expectStreamRestart() throws Exception { @@ -755,8 +610,6 @@ private void expectStreamRestart() throws Exception { 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 @@ -908,7 +761,6 @@ private StreamProcessor createStreamProcessor(URI streamUri) { private StreamProcessor createStreamProcessor(LDConfig config, URI streamUri, DiagnosticAccumulator diagnosticAccumulator) { return new StreamProcessor( clientContext(SDK_KEY, config).getHttp(), - mockRequestor, dataSourceUpdates, mockEventSourceCreator, Thread.MIN_PRIORITY, @@ -921,7 +773,6 @@ private StreamProcessor createStreamProcessor(LDConfig config, URI streamUri, Di private StreamProcessor createStreamProcessorWithRealHttp(LDConfig config, URI streamUri) { return new StreamProcessor( clientContext(SDK_KEY, config).getHttp(), - mockRequestor, dataSourceUpdates, null, Thread.MIN_PRIORITY, @@ -934,7 +785,6 @@ private StreamProcessor createStreamProcessorWithRealHttp(LDConfig config, URI s private StreamProcessor createStreamProcessorWithStoreUpdates(DataSourceUpdates storeUpdates) { return new StreamProcessor( clientContext(SDK_KEY, LDConfig.DEFAULT).getHttp(), - mockRequestor, storeUpdates, mockEventSourceCreator, Thread.MIN_PRIORITY, @@ -956,12 +806,6 @@ private MessageEvent emptyPutEvent() { return new MessageEvent("{\"data\":{\"flags\":{},\"segments\":{}}}"); } - private void setupRequestorToReturnAllDataWithFlag(DataModel.FeatureFlag feature) throws Exception { - FeatureRequestor.AllData data = new FeatureRequestor.AllData( - Collections.singletonMap(feature.getKey(), feature), Collections.emptyMap()); - expect(mockRequestor.getAllData()).andReturn(data); - } - private void assertFeatureInStore(DataModel.FeatureFlag feature) { assertEquals(feature.getVersion(), dataStore.get(FEATURES, feature.getKey()).getVersion()); } diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/StreamingDataSourceBuilderTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/StreamingDataSourceBuilderTest.java index 1fd337d6e..12452b392 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/StreamingDataSourceBuilderTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/StreamingDataSourceBuilderTest.java @@ -32,12 +32,13 @@ public void initialReconnectDelay() { streamingDataSource().initialReconnectDelay(Duration.ofMillis(222)).initialReconnectDelay(null).initialReconnectDelay); } + @SuppressWarnings("deprecation") @Test public void pollingBaseURI() { - assertNull(streamingDataSource().pollingBaseURI); - - assertEquals(URI.create("x"), streamingDataSource().pollingBaseURI(URI.create("x")).pollingBaseURI); - - assertNull(streamingDataSource().pollingBaseURI(URI.create("x")).pollingBaseURI(null).pollingBaseURI); + // The pollingBaseURI option is now ignored, so this test just verifies that changing it does *not* + // change the stream's regular baseURI property. + StreamingDataSourceBuilder b = streamingDataSource(); + b.pollingBaseURI(URI.create("x")); + assertNull(b.baseURI); } } From 0293f585d8ebbbb06b63e1b86b18d05be6272ae6 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 23 Jun 2020 18:46:19 -0700 Subject: [PATCH 519/673] fix typo in javadoc example code --- .../sdk/server/interfaces/FlagValueChangeListener.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeListener.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeListener.java index 854cb9455..2981a3633 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeListener.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagValueChangeListener.java @@ -18,7 +18,7 @@ * } * }; * client.getFlagTracker().addFlagValueChangeListener(flagKey, - * userForEvaluation, listenForNewValue)); + * userForFlagEvaluation, listenForNewValue); * * * In the above example, the value provided in {@code event.getNewValue()} is the result of calling From 4ad935115c4153d0074994c344ca2fdb138b01f4 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 25 Jun 2020 15:08:07 -0700 Subject: [PATCH 520/673] clean up polling logic, fix status updating after an outage, don't reinit store unnecessarily (#256) --- .../sdk/server/ComponentsImpl.java | 3 +- .../sdk/server/DefaultFeatureRequestor.java | 64 +++--- .../sdk/server/FeatureRequestor.java | 20 +- .../sdk/server/PollingProcessor.java | 27 ++- .../com/launchdarkly/sdk/server/Util.java | 27 +++ .../StreamingDataSourceBuilder.java | 3 +- ....java => DefaultFeatureRequestorTest.java} | 155 ++++++-------- .../sdk/server/PollingProcessorTest.java | 189 ++++++++++++------ .../com/launchdarkly/sdk/server/TestUtil.java | 5 + .../StreamingDataSourceBuilderTest.java | 1 - 10 files changed, 280 insertions(+), 214 deletions(-) rename src/test/java/com/launchdarkly/sdk/server/{FeatureRequestorTest.java => DefaultFeatureRequestorTest.java} (58%) diff --git a/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java b/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java index 69e3499a5..9f81c35da 100644 --- a/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java @@ -174,8 +174,7 @@ public DataSource createDataSource(ClientContext context, DataSourceUpdates data DefaultFeatureRequestor requestor = new DefaultFeatureRequestor( context.getHttp(), - baseURI == null ? LDConfig.DEFAULT_BASE_URI : baseURI, - true + baseURI == null ? LDConfig.DEFAULT_BASE_URI : baseURI ); return new PollingProcessor( requestor, diff --git a/src/main/java/com/launchdarkly/sdk/server/DefaultFeatureRequestor.java b/src/main/java/com/launchdarkly/sdk/server/DefaultFeatureRequestor.java index 5d10122b4..8aff2e66a 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DefaultFeatureRequestor.java +++ b/src/main/java/com/launchdarkly/sdk/server/DefaultFeatureRequestor.java @@ -1,15 +1,15 @@ package com.launchdarkly.sdk.server; import com.google.common.annotations.VisibleForTesting; -import com.google.common.io.Files; import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; import com.launchdarkly.sdk.server.interfaces.SerializationException; import org.slf4j.Logger; -import java.io.File; import java.io.IOException; import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; import static com.launchdarkly.sdk.server.Util.configureHttpClientBuilder; import static com.launchdarkly.sdk.server.Util.getHeadersBuilderFor; @@ -26,57 +26,42 @@ */ final class DefaultFeatureRequestor implements FeatureRequestor { private static final Logger logger = Loggers.DATA_SOURCE; - 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 @VisibleForTesting final URI baseUri; private final OkHttpClient httpClient; + private final URI pollingUri; private final Headers headers; - private final boolean useCache; + private final Path cacheDir; - DefaultFeatureRequestor(HttpConfiguration httpConfig, URI baseUri, boolean useCache) { + DefaultFeatureRequestor(HttpConfiguration httpConfig, URI baseUri) { this.baseUri = baseUri; - this.useCache = useCache; + this.pollingUri = baseUri.resolve(GET_LATEST_ALL_PATH); OkHttpClient.Builder httpBuilder = new OkHttpClient.Builder(); configureHttpClientBuilder(httpConfig, httpBuilder); this.headers = getHeadersBuilderFor(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. - if (useCache) { - File cacheDir = Files.createTempDir(); - Cache cache = new Cache(cacheDir, MAX_HTTP_CACHE_SIZE_BYTES); - httpBuilder.cache(cache); + try { + cacheDir = Files.createTempDirectory("LaunchDarklySDK"); + } catch (IOException e) { + throw new RuntimeException("unable to create cache directory for polling", e); } + Cache cache = new Cache(cacheDir.toFile(), MAX_HTTP_CACHE_SIZE_BYTES); + httpBuilder.cache(cache); httpClient = httpBuilder.build(); } public void close() { shutdownHttpClient(httpClient); + Util.deleteDirectory(cacheDir); } - public DataModel.FeatureFlag getFlag(String featureKey) throws IOException, HttpErrorException, SerializationException { - String body = get(GET_LATEST_FLAGS_PATH + "/" + featureKey); - return JsonHelpers.deserialize(body, DataModel.FeatureFlag.class); - } - - public DataModel.Segment getSegment(String segmentKey) throws IOException, HttpErrorException, SerializationException { - String body = get(GET_LATEST_SEGMENTS_PATH + "/" + segmentKey); - return JsonHelpers.deserialize(body, DataModel.Segment.class); - } - - public AllData getAllData() throws IOException, HttpErrorException, SerializationException { - String body = get(GET_LATEST_ALL_PATH); - return JsonHelpers.deserialize(body, AllData.class); - } - - private String get(String path) throws IOException, HttpErrorException { + public AllData getAllData(boolean returnDataEvenIfCached) throws IOException, HttpErrorException, SerializationException { Request request = new Request.Builder() - .url(baseUri.resolve(path).toURL()) + .url(pollingUri.toURL()) .headers(headers) .get() .build(); @@ -84,6 +69,13 @@ private String get(String path) throws IOException, HttpErrorException { logger.debug("Making request: " + request); try (Response response = httpClient.newCall(request).execute()) { + boolean wasCached = response.networkResponse() == null || response.networkResponse().code() == 304; + if (wasCached && !returnDataEvenIfCached) { + logger.debug("Get flag(s) got cached response, will not parse"); + logger.debug("Cache hit count: " + httpClient.cache().hitCount() + " Cache network Count: " + httpClient.cache().networkCount()); + return null; + } + String body = response.body().string(); if (!response.isSuccessful()) { @@ -91,12 +83,10 @@ 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 (useCache) { - logger.debug("Cache hit count: " + httpClient.cache().hitCount() + " Cache network Count: " + httpClient.cache().networkCount()); - logger.debug("Cache response: " + response.cacheResponse()); - } - - return body; + logger.debug("Cache hit count: " + httpClient.cache().hitCount() + " Cache network Count: " + httpClient.cache().networkCount()); + logger.debug("Cache response: " + response.cacheResponse()); + + return JsonHelpers.deserialize(body, AllData.class); } } -} \ No newline at end of file +} diff --git a/src/main/java/com/launchdarkly/sdk/server/FeatureRequestor.java b/src/main/java/com/launchdarkly/sdk/server/FeatureRequestor.java index e1e5b3003..37cc0d780 100644 --- a/src/main/java/com/launchdarkly/sdk/server/FeatureRequestor.java +++ b/src/main/java/com/launchdarkly/sdk/server/FeatureRequestor.java @@ -16,12 +16,22 @@ import static com.launchdarkly.sdk.server.DataModel.FEATURES; import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; +/** + * Internal abstraction for polling requests. Currently this is only used by PollingProcessor, and + * the only implementation is DefaultFeatureRequestor, but using an interface allows us to mock out + * the HTTP behavior and test the rest of PollingProcessor separately. + */ interface FeatureRequestor extends Closeable { - DataModel.FeatureFlag getFlag(String featureKey) throws IOException, HttpErrorException; - - DataModel.Segment getSegment(String segmentKey) throws IOException, HttpErrorException; - - AllData getAllData() throws IOException, HttpErrorException; + /** + * Makes a request to the LaunchDarkly server-side SDK polling endpoint, + * + * @param returnDataEvenIfCached true if the method should return non-nil data no matter what; + * false if it should return {@code null} when the latest data is already in the cache + * @return the data, or {@code null} as above + * @throws IOException for network errors + * @throws HttpErrorException for HTTP error responses + */ + AllData getAllData(boolean returnDataEvenIfCached) throws IOException, HttpErrorException; static class AllData { final Map flags; diff --git a/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java b/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java index 638cc6050..772a332d7 100644 --- a/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java @@ -84,10 +84,23 @@ public Future start() { } private void poll() { - FeatureRequestor.AllData allData = null; - try { - allData = requestor.getAllData(); + // If we already obtained data earlier, and the poll request returns a cached response, then we don't + // want to bother parsing the data or reinitializing the data store. But if we never succeeded in + // storing any data, then we would still want to parse and try to store it even if it's cached. + boolean alreadyInited = initialized.get(); + FeatureRequestor.AllData allData = requestor.getAllData(!alreadyInited); + if (allData == null) { + // This means it was cached, and alreadyInited was true + dataSourceUpdates.updateStatus(State.VALID, null); + } else { + if (dataSourceUpdates.init(allData.toFullDataSet())) { + dataSourceUpdates.updateStatus(State.VALID, null); + logger.info("Initialized LaunchDarkly client."); + initialized.getAndSet(true); + initFuture.complete(null); + } + } } catch (HttpErrorException e) { ErrorInfo errorInfo = ErrorInfo.fromHttpError(e.getStatus()); boolean recoverable = checkIfErrorIsRecoverableAndLog(logger, httpErrorDescription(e.getStatus()), @@ -109,13 +122,5 @@ private void poll() { logger.debug(e.toString(), e); dataSourceUpdates.updateStatus(State.INTERRUPTED, ErrorInfo.fromException(ErrorKind.UNKNOWN, e)); } - - if (allData != null && dataSourceUpdates.init(allData.toFullDataSet())) { - if (!initialized.getAndSet(true)) { - logger.info("Initialized LaunchDarkly client."); - dataSourceUpdates.updateStatus(State.VALID, null); - initFuture.complete(null); - } - } } } diff --git a/src/main/java/com/launchdarkly/sdk/server/Util.java b/src/main/java/com/launchdarkly/sdk/server/Util.java index 79971630c..06864a4d6 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Util.java +++ b/src/main/java/com/launchdarkly/sdk/server/Util.java @@ -6,6 +6,11 @@ import org.slf4j.Logger; import java.io.IOException; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; import java.time.Duration; import java.util.Map; import java.util.concurrent.TimeUnit; @@ -150,4 +155,26 @@ static String describeDuration(Duration d) { } return d.toMillis() + " milliseconds"; } + + static void deleteDirectory(Path path) { + try { + Files.walkFileTree(path, new SimpleFileVisitor() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { + try { + Files.delete(file); + } catch (IOException e) {} + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path dir, IOException exc) { + try { + Files.delete(dir); + } catch (IOException e) {} + return FileVisitResult.CONTINUE; + } + }); + } catch (IOException e) {} + } } diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/StreamingDataSourceBuilder.java b/src/main/java/com/launchdarkly/sdk/server/integrations/StreamingDataSourceBuilder.java index f09de74c2..d00bf9e8f 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/StreamingDataSourceBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/StreamingDataSourceBuilder.java @@ -73,12 +73,11 @@ public StreamingDataSourceBuilder initialReconnectDelay(Duration initialReconnec * Previously, LaunchDarkly sometimes required the SDK to temporarily do a polling request even in * streaming mode (based on the size of the updated data item); this property specified the base URI * for such requests. However, the system no longer has this behavior so this property is ignored. + * It will be deprecated and then removed in a future release. * * @param pollingBaseURI the polling endpoint URI; null to use the default * @return the builder - * @deprecated this method no longer affects anything and will be removed in the future */ - @Deprecated public StreamingDataSourceBuilder pollingBaseURI(URI pollingBaseURI) { return this; } diff --git a/src/test/java/com/launchdarkly/sdk/server/FeatureRequestorTest.java b/src/test/java/com/launchdarkly/sdk/server/DefaultFeatureRequestorTest.java similarity index 58% rename from src/test/java/com/launchdarkly/sdk/server/FeatureRequestorTest.java rename to src/test/java/com/launchdarkly/sdk/server/DefaultFeatureRequestorTest.java index a39c6146a..ed98993c0 100644 --- a/src/test/java/com/launchdarkly/sdk/server/FeatureRequestorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultFeatureRequestorTest.java @@ -3,12 +3,10 @@ import com.launchdarkly.sdk.server.interfaces.BasicConfiguration; import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; -import org.junit.Assert; import org.junit.Test; import java.net.URI; import java.util.Map; -import java.util.concurrent.TimeUnit; import javax.net.ssl.SSLHandshakeException; @@ -29,7 +27,7 @@ import okhttp3.mockwebserver.RecordedRequest; @SuppressWarnings("javadoc") -public class FeatureRequestorTest { +public class DefaultFeatureRequestorTest { private static final String sdkKey = "sdk-key"; private static final String flag1Key = "flag1"; private static final String flag1Json = "{\"key\":\"" + flag1Key + "\"}"; @@ -47,133 +45,104 @@ private DefaultFeatureRequestor makeRequestor(MockWebServer server) { private DefaultFeatureRequestor makeRequestor(MockWebServer server, LDConfig config) { URI uri = server.url("").uri(); - return new DefaultFeatureRequestor(makeHttpConfig(config), uri, true); + return new DefaultFeatureRequestor(makeHttpConfig(config), uri); } private HttpConfiguration makeHttpConfig(LDConfig config) { return config.httpConfigFactory.createHttpConfiguration(new BasicConfiguration(sdkKey, false, 0)); } + private void verifyExpectedData(FeatureRequestor.AllData data) { + 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 requestAllData() throws Exception { MockResponse resp = jsonResponse(allDataJson); try (MockWebServer server = makeStartedServer(resp)) { try (DefaultFeatureRequestor r = makeRequestor(server)) { - FeatureRequestor.AllData data = r.getAllData(); + FeatureRequestor.AllData data = r.getAllData(true); 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); + verifyExpectedData(data); } } } @Test - public void requestFlag() throws Exception { - MockResponse resp = jsonResponse(flag1Json); - - try (MockWebServer server = makeStartedServer(resp)) { - try (DefaultFeatureRequestor r = makeRequestor(server)) { - DataModel.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 = jsonResponse(segment1Json); - - try (MockWebServer server = makeStartedServer(resp)) { - try (DefaultFeatureRequestor r = makeRequestor(server)) { - DataModel.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); + public void responseIsCached() throws Exception { + MockResponse cacheableResp = jsonResponse(allDataJson) + .setHeader("ETag", "aaa") + .setHeader("Cache-Control", "max-age=0"); + MockResponse cachedResp = new MockResponse().setResponseCode(304); - try (MockWebServer server = makeStartedServer(notFoundResp)) { - try (DefaultFeatureRequestor r = makeRequestor(server)) { - try { - r.getFlag(flag1Key); - Assert.fail("expected exception"); - } catch (HttpErrorException e) { - assertEquals(404, e.getStatus()); - } - } - } - } + try (MockWebServer server = makeStartedServer(cacheableResp, cachedResp)) { + try (DefaultFeatureRequestor r = makeRequestor(server)) { + FeatureRequestor.AllData data1 = r.getAllData(true); + verifyExpectedData(data1); + + RecordedRequest req1 = server.takeRequest(); + assertEquals("/sdk/latest-all", req1.getPath()); + verifyHeaders(req1); + assertNull(req1.getHeader("If-None-Match")); + + FeatureRequestor.AllData data2 = r.getAllData(false); + assertNull(data2); - @Test - public void requestSegmentNotFound() throws Exception { - MockResponse notFoundResp = new MockResponse().setResponseCode(404); - - try (MockWebServer server = makeStartedServer(notFoundResp)) { - try (DefaultFeatureRequestor r = makeRequestor(server)) { - try { - r.getSegment(segment1Key); - fail("expected exception"); - } catch (HttpErrorException e) { - assertEquals(404, e.getStatus()); - } + RecordedRequest req2 = server.takeRequest(); + assertEquals("/sdk/latest-all", req2.getPath()); + verifyHeaders(req2); + assertEquals("aaa", req2.getHeader("If-None-Match")); } } } @Test - public void requestsAreCached() throws Exception { - MockResponse cacheableResp = jsonResponse(flag1Json) + public void responseIsCachedButWeWantDataAnyway() throws Exception { + MockResponse cacheableResp = jsonResponse(allDataJson) .setHeader("ETag", "aaa") - .setHeader("Cache-Control", "max-age=1000"); + .setHeader("Cache-Control", "max-age=0"); + MockResponse cachedResp = new MockResponse().setResponseCode(304); - try (MockWebServer server = makeStartedServer(cacheableResp)) { + try (MockWebServer server = makeStartedServer(cacheableResp, cachedResp)) { try (DefaultFeatureRequestor r = makeRequestor(server)) { - DataModel.FeatureFlag flag1a = r.getFlag(flag1Key); - + FeatureRequestor.AllData data1 = r.getAllData(true); + verifyExpectedData(data1); + RecordedRequest req1 = server.takeRequest(); - assertEquals("/sdk/latest-flags/" + flag1Key, req1.getPath()); + assertEquals("/sdk/latest-all", req1.getPath()); verifyHeaders(req1); - - verifyFlag(flag1a, flag1Key); - - DataModel.FeatureFlag flag1b = r.getFlag(flag1Key); - verifyFlag(flag1b, flag1Key); - assertNull(server.takeRequest(0, TimeUnit.SECONDS)); // there was no second request, due to the cache hit + assertNull(req1.getHeader("If-None-Match")); + + FeatureRequestor.AllData data2 = r.getAllData(true); + verifyExpectedData(data2); + + RecordedRequest req2 = server.takeRequest(); + assertEquals("/sdk/latest-all", req2.getPath()); + verifyHeaders(req2); + assertEquals("aaa", req2.getHeader("If-None-Match")); } } } @Test public void httpClientDoesNotAllowSelfSignedCertByDefault() throws Exception { - MockResponse resp = jsonResponse(flag1Json); + MockResponse resp = jsonResponse(allDataJson); try (TestHttpUtil.ServerWithCert serverWithCert = httpsServerWithSelfSignedCert(resp)) { try (DefaultFeatureRequestor r = makeRequestor(serverWithCert.server)) { try { - r.getFlag(flag1Key); + r.getAllData(false); fail("expected exception"); } catch (SSLHandshakeException e) { } @@ -185,7 +154,7 @@ public void httpClientDoesNotAllowSelfSignedCertByDefault() throws Exception { @Test public void httpClientCanUseCustomTlsConfig() throws Exception { - MockResponse resp = jsonResponse(flag1Json); + MockResponse resp = jsonResponse(allDataJson); try (TestHttpUtil.ServerWithCert serverWithCert = httpsServerWithSelfSignedCert(resp)) { LDConfig config = new LDConfig.Builder() @@ -194,8 +163,8 @@ public void httpClientCanUseCustomTlsConfig() throws Exception { .build(); try (DefaultFeatureRequestor r = makeRequestor(serverWithCert.server, config)) { - DataModel.FeatureFlag flag = r.getFlag(flag1Key); - verifyFlag(flag, flag1Key); + FeatureRequestor.AllData data = r.getAllData(false); + verifyExpectedData(data); } } } @@ -203,15 +172,15 @@ public void httpClientCanUseCustomTlsConfig() throws Exception { @Test public void httpClientCanUseProxyConfig() throws Exception { URI fakeBaseUri = URI.create("http://not-a-real-host"); - try (MockWebServer server = makeStartedServer(jsonResponse(flag1Json))) { + try (MockWebServer server = makeStartedServer(jsonResponse(allDataJson))) { HttpUrl serverUrl = server.url("/"); LDConfig config = new LDConfig.Builder() .http(Components.httpConfiguration().proxyHostAndPort(serverUrl.host(), serverUrl.port())) .build(); - try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(makeHttpConfig(config), fakeBaseUri, true)) { - DataModel.FeatureFlag flag = r.getFlag(flag1Key); - verifyFlag(flag, flag1Key); + try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(makeHttpConfig(config), fakeBaseUri)) { + FeatureRequestor.AllData data = r.getAllData(false); + verifyExpectedData(data); assertEquals(1, server.getRequestCount()); } diff --git a/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java index b9f061814..1dd75b174 100644 --- a/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java @@ -3,8 +3,10 @@ import com.launchdarkly.sdk.server.DataModel.FeatureFlag; import com.launchdarkly.sdk.server.TestComponents.MockDataSourceUpdates; import com.launchdarkly.sdk.server.TestComponents.MockDataStoreStatusProvider; +import com.launchdarkly.sdk.server.TestUtil.ActionCanThrowAnyException; import com.launchdarkly.sdk.server.integrations.PollingDataSourceBuilder; import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorKind; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.State; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.Status; @@ -12,6 +14,7 @@ import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; import com.launchdarkly.sdk.server.interfaces.SerializationException; +import org.hamcrest.MatcherAssert; import org.junit.Before; import org.junit.Test; @@ -38,10 +41,10 @@ import static com.launchdarkly.sdk.server.TestUtil.shouldTimeOut; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNotSame; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; @@ -50,6 +53,7 @@ public class PollingProcessorTest { private static final String SDK_KEY = "sdk-key"; private static final Duration LENGTHY_INTERVAL = Duration.ofSeconds(60); + private static final Duration BRIEF_INTERVAL = Duration.ofMillis(20); private MockDataSourceUpdates dataSourceUpdates; private MockFeatureRequestor requestor; @@ -260,70 +264,137 @@ public void http500ErrorIsRecoverable() throws Exception { } private void testUnrecoverableHttpError(int statusCode) throws Exception { - requestor.httpException = new HttpErrorException(statusCode); - - BlockingQueue statuses = new LinkedBlockingQueue<>(); - dataSourceUpdates.statusBroadcaster.register(statuses::add); + HttpErrorException httpError = new HttpErrorException(statusCode); - try (PollingProcessor pollingProcessor = makeProcessor()) { - long startTime = System.currentTimeMillis(); - Future initFuture = pollingProcessor.start(); - - shouldNotTimeOut(initFuture, Duration.ofSeconds(2)); - assertTrue((System.currentTimeMillis() - startTime) < 9000); - assertTrue(initFuture.isDone()); - assertFalse(pollingProcessor.isInitialized()); + // Test a scenario where the very first request gets this error + withStatusQueue(statuses -> { + requestor.httpException = httpError; - Status status = requireDataSourceStatus(statuses, State.OFF); - assertNotNull(status.getLastError()); - assertEquals(ErrorKind.ERROR_RESPONSE, status.getLastError().getKind()); - assertEquals(statusCode, status.getLastError().getStatusCode()); - } + try (PollingProcessor pollingProcessor = makeProcessor()) { + long startTime = System.currentTimeMillis(); + Future initFuture = pollingProcessor.start(); + + shouldNotTimeOut(initFuture, Duration.ofSeconds(2)); + assertTrue((System.currentTimeMillis() - startTime) < 9000); + assertTrue(initFuture.isDone()); + assertFalse(pollingProcessor.isInitialized()); + + verifyHttpErrorCausedShutdown(statuses, statusCode); + } + }); + + // Now test a scenario where we have a successful startup, but the next poll gets the error + withStatusQueue(statuses -> { + requestor.httpException = null; + requestor.allData = new FeatureRequestor.AllData(new HashMap<>(), new HashMap<>()); + + try (PollingProcessor pollingProcessor = makeProcessor(BRIEF_INTERVAL)) { + Future initFuture = pollingProcessor.start(); + + shouldNotTimeOut(initFuture, Duration.ofSeconds(20000)); + assertTrue(initFuture.isDone()); + assertTrue(pollingProcessor.isInitialized()); + requireDataSourceStatusEventually(statuses, State.VALID, State.INITIALIZING); + + // cause the next poll to get an error + requestor.httpException = httpError; + + verifyHttpErrorCausedShutdown(statuses, statusCode); + } + }); + } + + private void verifyHttpErrorCausedShutdown(BlockingQueue statuses, int statusCode) { + Status status = requireDataSourceStatusEventually(statuses, State.OFF, State.VALID); + assertNotNull(status.getLastError()); + assertEquals(ErrorKind.ERROR_RESPONSE, status.getLastError().getKind()); + assertEquals(statusCode, status.getLastError().getStatusCode()); } private void testRecoverableHttpError(int statusCode) throws Exception { HttpErrorException httpError = new HttpErrorException(statusCode); - Duration shortInterval = Duration.ofMillis(20); - requestor.httpException = httpError; - - BlockingQueue statuses = new LinkedBlockingQueue<>(); - dataSourceUpdates.statusBroadcaster.register(statuses::add); - try (PollingProcessor pollingProcessor = makeProcessor(shortInterval)) { - Future initFuture = pollingProcessor.start(); - - // first poll gets an error - shouldTimeOut(initFuture, Duration.ofMillis(200)); - assertFalse(initFuture.isDone()); - assertFalse(pollingProcessor.isInitialized()); + // Test a scenario where the very first request gets this error + withStatusQueue(statuses -> { + requestor.httpException = httpError; - Status status1 = requireDataSourceStatus(statuses, State.INITIALIZING); - assertNotNull(status1.getLastError()); - assertEquals(ErrorKind.ERROR_RESPONSE, status1.getLastError().getKind()); - assertEquals(statusCode, status1.getLastError().getStatusCode()); - - // now make it so the requestor will succeed + try (PollingProcessor pollingProcessor = makeProcessor(BRIEF_INTERVAL)) { + Future initFuture = pollingProcessor.start(); + + // first poll gets an error + shouldTimeOut(initFuture, Duration.ofMillis(200)); + assertFalse(initFuture.isDone()); + assertFalse(pollingProcessor.isInitialized()); + + Status status0 = requireDataSourceStatus(statuses, State.INITIALIZING); + assertNotNull(status0.getLastError()); + assertEquals(ErrorKind.ERROR_RESPONSE, status0.getLastError().getKind()); + assertEquals(statusCode, status0.getLastError().getStatusCode()); + + verifyHttpErrorWasRecoverable(statuses, statusCode); + + shouldNotTimeOut(initFuture, Duration.ofSeconds(2)); + assertTrue(initFuture.isDone()); + assertTrue(pollingProcessor.isInitialized()); + } + }); + + // Now test a scenario where we have a successful startup, but the next poll gets the error + withStatusQueue(statuses -> { + requestor.httpException = null; requestor.allData = new FeatureRequestor.AllData(new HashMap<>(), new HashMap<>()); - requestor.httpException = null; - shouldNotTimeOut(initFuture, Duration.ofSeconds(2)); - assertTrue(initFuture.isDone()); - assertTrue(pollingProcessor.isInitialized()); + try (PollingProcessor pollingProcessor = makeProcessor(BRIEF_INTERVAL)) { + Future initFuture = pollingProcessor.start(); + + shouldNotTimeOut(initFuture, Duration.ofSeconds(20000)); + assertTrue(initFuture.isDone()); + assertTrue(pollingProcessor.isInitialized()); + requireDataSourceStatusEventually(statuses, State.VALID, State.INITIALIZING); + + // cause the next poll to get an error + requestor.httpException = httpError; + + Status status0 = requireDataSourceStatusEventually(statuses, State.INTERRUPTED, State.VALID); + assertEquals(ErrorKind.ERROR_RESPONSE, status0.getLastError().getKind()); + assertEquals(statusCode, status0.getLastError().getStatusCode()); + + verifyHttpErrorWasRecoverable(statuses, statusCode); + } + }); + } - // status should now be VALID (although there might have been more failed polls before that) - Status status2 = requireDataSourceStatusEventually(statuses, State.VALID, State.INITIALIZING); - assertNotNull(status2.getLastError()); - assertEquals(ErrorKind.ERROR_RESPONSE, status2.getLastError().getKind()); - assertEquals(statusCode, status2.getLastError().getStatusCode()); - - // simulate another error of the same kind - the difference is now the state will be INTERRUPTED - requestor.httpException = httpError; - - Status status3 = requireDataSourceStatusEventually(statuses, State.INTERRUPTED, State.VALID); - assertNotNull(status3.getLastError()); - assertEquals(ErrorKind.ERROR_RESPONSE, status3.getLastError().getKind()); - assertEquals(statusCode, status3.getLastError().getStatusCode()); - assertNotSame(status1.getLastError(), status3.getLastError()); // it's a new error object of the same kind + private void verifyHttpErrorWasRecoverable(BlockingQueue statuses, int statusCode) throws Exception { + long startTime = System.currentTimeMillis(); + + // first make it so the requestor will succeed after the previous error + requestor.allData = new FeatureRequestor.AllData(new HashMap<>(), new HashMap<>()); + requestor.httpException = null; + + // status should now be VALID (although there might have been more failed polls before that) + Status status1 = requireDataSourceStatusEventually(statuses, State.VALID, State.INITIALIZING); + assertNotNull(status1.getLastError()); + assertEquals(ErrorKind.ERROR_RESPONSE, status1.getLastError().getKind()); + assertEquals(statusCode, status1.getLastError().getStatusCode()); + + // simulate another error of the same kind - the state will be INTERRUPTED + requestor.httpException = new HttpErrorException(statusCode); + + Status status2 = requireDataSourceStatusEventually(statuses, State.INTERRUPTED, State.VALID); + assertNotNull(status2.getLastError()); + assertEquals(ErrorKind.ERROR_RESPONSE, status2.getLastError().getKind()); + assertEquals(statusCode, status2.getLastError().getStatusCode()); + MatcherAssert.assertThat(status2.getLastError().getTime().toEpochMilli(), greaterThanOrEqualTo(startTime)); + } + + private void withStatusQueue(ActionCanThrowAnyException> action) throws Exception { + BlockingQueue statuses = new LinkedBlockingQueue<>(); + DataSourceStatusProvider.StatusListener addStatus = statuses::add; + dataSourceUpdates.statusBroadcaster.register(addStatus); + try { + action.apply(statuses); + } finally { + dataSourceUpdates.statusBroadcaster.unregister(addStatus); } } @@ -337,15 +408,7 @@ private static class MockFeatureRequestor implements FeatureRequestor { public void close() throws IOException {} - public DataModel.FeatureFlag getFlag(String featureKey) throws IOException, HttpErrorException { - return null; - } - - public DataModel.Segment getSegment(String segmentKey) throws IOException, HttpErrorException { - return null; - } - - public AllData getAllData() throws IOException, HttpErrorException { + public AllData getAllData(boolean returnDataEvenIfCached) throws IOException, HttpErrorException { queries.add(true); if (gate != null) { try { diff --git a/src/test/java/com/launchdarkly/sdk/server/TestUtil.java b/src/test/java/com/launchdarkly/sdk/server/TestUtil.java index d34f55efa..4e1b3cf40 100644 --- a/src/test/java/com/launchdarkly/sdk/server/TestUtil.java +++ b/src/test/java/com/launchdarkly/sdk/server/TestUtil.java @@ -16,6 +16,7 @@ import org.hamcrest.Matcher; import org.hamcrest.TypeSafeDiagnosingMatcher; +import java.io.IOException; import java.time.Duration; import java.time.Instant; import java.util.HashSet; @@ -154,6 +155,10 @@ public static DataSourceStatusProvider.Status requireDataSourceStatusEventually( }); } + public static interface ActionCanThrowAnyException { + void apply(T param) throws Exception; + } + public static T awaitValue(BlockingQueue values, Duration timeout) { try { T value = values.poll(timeout.toMillis(), TimeUnit.MILLISECONDS); diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/StreamingDataSourceBuilderTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/StreamingDataSourceBuilderTest.java index 12452b392..8a2eebcaf 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/StreamingDataSourceBuilderTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/StreamingDataSourceBuilderTest.java @@ -32,7 +32,6 @@ public void initialReconnectDelay() { streamingDataSource().initialReconnectDelay(Duration.ofMillis(222)).initialReconnectDelay(null).initialReconnectDelay); } - @SuppressWarnings("deprecation") @Test public void pollingBaseURI() { // The pollingBaseURI option is now ignored, so this test just verifies that changing it does *not* From d41ae6c02bbcba79bb52f1867f23b5541ce7f8b1 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 4 Aug 2020 16:03:21 -0700 Subject: [PATCH 521/673] slightly change semantics of boolean setters, improve tests, misc cleanup --- .../sdk/server/integrations/TestData.java | 68 +++++++-------- .../sdk/server/integrations/TestDataTest.java | 84 +++++++++---------- 2 files changed, 73 insertions(+), 79 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/TestData.java b/src/main/java/com/launchdarkly/sdk/server/integrations/TestData.java index 5973e34e6..fd5a6afa3 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/TestData.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/TestData.java @@ -278,7 +278,7 @@ public FlagBuilder on(boolean on) { * @return the builder */ public FlagBuilder fallthroughVariation(boolean value) { - return this.booleanFlag().fallthroughVariation(value ? TRUE_VARIATION_FOR_BOOLEAN : FALSE_VARIATION_FOR_BOOLEAN); + return this.booleanFlag().fallthroughVariation(variationForBoolean(value)); } /** @@ -286,11 +286,11 @@ public FlagBuilder fallthroughVariation(boolean value) { * that is returned if targeting is on and the user was not matched by a more specific * target or rule. * - * @param index the desired fallthrough variation: 0 for the first, 1 for the second, etc. + * @param variationIndex the desired fallthrough variation: 0 for the first, 1 for the second, etc. * @return the builder */ - public FlagBuilder fallthroughVariation(int index) { - this.fallthroughVariation = index; + public FlagBuilder fallthroughVariation(int variationIndex) { + this.fallthroughVariation = variationIndex; return this; } @@ -302,56 +302,49 @@ public FlagBuilder fallthroughVariation(int index) { * @return the builder */ public FlagBuilder offVariation(boolean value) { - return this.booleanFlag().offVariation(value ? TRUE_VARIATION_FOR_BOOLEAN : FALSE_VARIATION_FOR_BOOLEAN); + return this.booleanFlag().offVariation(variationForBoolean(value)); } /** * Specifies the index of the off variation. This is the variation that is returned * whenever targeting is off. * - * @param index the desired off variation: 0 for the first, 1 for the second, etc. + * @param variationIndex the desired off variation: 0 for the first, 1 for the second, etc. * @return the builder */ - public FlagBuilder offVariation(int index) { - this.offVariation = index; + public FlagBuilder offVariation(int variationIndex) { + this.offVariation = variationIndex; return this; } /** * Sets the flag to always return the specified boolean variation for all users. *

    - * If the flag is a boolean flag, this causes targeting to be switched on or off, and also - * removes any existing targets or rules. If the flag was not already a boolean flag, it is - * converted to a boolean flag. - * + * VariationForAllUsers sets the flag to return the specified boolean variation by default for all users. + *

    + * Targeting is switched on, any existing targets or rules are removed, and the flag's variations are + * set to true and false. The fallthrough variation is set to the specified value. The off variation is + * left unchanged. + * * @param variation the desired true/false variation to be returned for all users * @return the builder */ public FlagBuilder variationForAllUsers(boolean variation) { - return clearRules() - .clearUserTargets() - .booleanFlag() - .offVariation(FALSE_VARIATION_FOR_BOOLEAN) - .fallthroughVariation(TRUE_VARIATION_FOR_BOOLEAN) - .on(variation); + return booleanFlag().variationForAllUsers(variationForBoolean(variation)); } /** * Sets the flag to always return the specified variation for all users. *

    * The variation is specified by number, out of whatever variation values have already been - * defined. The flag will always return this variation regardless of whether targeting is - * on or off (it is used as both the fallthrough variation and the off variation). Any - * existing targets or rules are removed. + * defined. Targeting is switched on, and any existing targets or rules are removed. The fallthrough + * variation is set to the specified value. The off variation is left unchanged. * - * @param index the desired variation: 0 for the first, 1 for the second, etc. + * @param variationIndex the desired variation: 0 for the first, 1 for the second, etc. * @return the builder */ - public FlagBuilder variationForAllUsers(int index) { - offVariation = index; - fallthroughVariation = index; - targets = null; - return this; + public FlagBuilder variationForAllUsers(int variationIndex) { + return on(true).clearRules().clearUserTargets().fallthroughVariation(variationIndex); } /** @@ -385,8 +378,7 @@ public FlagBuilder valueForAllUsers(LDValue value) { * @return the builder */ public FlagBuilder variationForUser(String userKey, boolean variation) { - return booleanFlag().variationForUser(userKey, - variation ? TRUE_VARIATION_FOR_BOOLEAN : FALSE_VARIATION_FOR_BOOLEAN); + return booleanFlag().variationForUser(userKey, variationForBoolean(variation)); } /** @@ -399,17 +391,17 @@ public FlagBuilder variationForUser(String userKey, boolean variation) { * defined. * * @param userKey a user key - * @param index the desired variation to be returned for this user when targeting is on: + * @param variationIndex the desired variation to be returned for this user when targeting is on: * 0 for the first, 1 for the second, etc. * @return the builder */ - public FlagBuilder variationForUser(String userKey, int index) { + public FlagBuilder variationForUser(String userKey, int variationIndex) { if (targets == null) { targets = new TreeMap<>(); // TreeMap keeps variations in order for test determinacy } for (int i = 0; i < variations.size(); i++) { ImmutableSet keys = targets.get(i); - if (i == index) { + if (i == variationIndex) { if (keys == null) { targets.put(i, ImmutableSortedSet.of(userKey)); } else if (!keys.contains(userKey)) { @@ -562,6 +554,10 @@ ItemDescriptor createFlag(int version) { return DataModel.FEATURES.deserialize(json); } + private static int variationForBoolean(boolean value) { + return value ? TRUE_VARIATION_FOR_BOOLEAN : FALSE_VARIATION_FOR_BOOLEAN; + } + /** * A builder for feature flag rules to be used with {@link FlagBuilder}. *

    @@ -631,18 +627,18 @@ public FlagRuleBuilder andNotMatch(UserAttribute attribute, LDValue... values) { */ public FlagBuilder thenReturn(boolean variation) { FlagBuilder.this.booleanFlag(); - return thenReturn(variation ? TRUE_VARIATION_FOR_BOOLEAN : FALSE_VARIATION_FOR_BOOLEAN); + return thenReturn(variationForBoolean(variation)); } /** * Finishes defining the rule, specifying the result as a variation index. * - * @param index the variation to return if the rule matches the user: 0 for the first, 1 + * @param variationIndex the variation to return if the rule matches the user: 0 for the first, 1 * for the second, etc. * @return the flag builder */ - public FlagBuilder thenReturn(int index) { - this.variation = index; + public FlagBuilder thenReturn(int variationIndex) { + this.variation = variationIndex; if (FlagBuilder.this.rules == null) { FlagBuilder.this.rules = new ArrayList<>(); } diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataTest.java index 9fd2c864b..407c535c0 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataTest.java @@ -127,97 +127,94 @@ public void updatesFlag() throws Exception { @Test public void flagConfigSimpleBoolean() throws Exception { - String basicProps = "\"key\":\"flag\",\"version\":1,\"variations\":[true,false]" + - ",\"offVariation\":1,\"fallthrough\":{\"variation\":0}"; + String basicProps = "\"variations\":[true,false],\"offVariation\":1"; String onProps = basicProps + ",\"on\":true"; String offProps = basicProps + ",\"on\":false"; + String fallthroughTrue = ",\"fallthrough\":{\"variation\":0}"; + String fallthroughFalse = ",\"fallthrough\":{\"variation\":1}"; - verifyFlag(td -> td.flag("flag"), onProps); - verifyFlag(td -> td.flag("flag").booleanFlag(), onProps); - verifyFlag(td -> td.flag("flag").on(true), onProps); - verifyFlag(td -> td.flag("flag").on(false), offProps); - verifyFlag(td -> td.flag("flag").variationForAllUsers(false), offProps); - verifyFlag(td -> td.flag("flag").variationForAllUsers(true), onProps); + verifyFlag(f -> f, onProps + fallthroughTrue); + verifyFlag(f -> f.booleanFlag(), onProps + fallthroughTrue); + verifyFlag(f -> f.on(true), onProps + fallthroughTrue); + verifyFlag(f -> f.on(false), offProps + fallthroughTrue); + verifyFlag(f -> f.variationForAllUsers(false), onProps + fallthroughFalse); + verifyFlag(f -> f.variationForAllUsers(true), onProps + fallthroughTrue); verifyFlag( - td -> td.flag("flag").fallthroughVariation(true).offVariation(false), - onProps + f -> f.fallthroughVariation(true).offVariation(false), + onProps + fallthroughTrue ); verifyFlag( - td -> td.flag("flag").fallthroughVariation(false).offVariation(true), - "\"key\":\"flag\",\"version\":1,\"variations\":[true,false],\"on\":true" + - ",\"offVariation\":0,\"fallthrough\":{\"variation\":1}" + f -> f.fallthroughVariation(false).offVariation(true), + "\"variations\":[true,false],\"on\":true,\"offVariation\":0,\"fallthrough\":{\"variation\":1}" ); } @Test public void usingBooleanConfigMethodsForcesFlagToBeBoolean() throws Exception { - String booleanProps = "\"key\":\"flag\",\"version\":1,\"on\":true" + String booleanProps = "\"on\":true" + ",\"variations\":[true,false],\"offVariation\":1,\"fallthrough\":{\"variation\":0}"; verifyFlag( - td -> td.flag("flag") - .variations(LDValue.of(1), LDValue.of(2)) + f -> f.variations(LDValue.of(1), LDValue.of(2)) .booleanFlag(), booleanProps ); verifyFlag( - td -> td.flag("flag") - .variations(LDValue.of(true), LDValue.of(2)) + f -> f.variations(LDValue.of(true), LDValue.of(2)) .booleanFlag(), booleanProps ); verifyFlag( - td -> td.flag("flag").valueForAllUsers(LDValue.of("x")) - .booleanFlag(), + f -> f.booleanFlag(), booleanProps ); } @Test public void flagConfigStringVariations() throws Exception { - String basicProps = "\"key\":\"flag\",\"version\":1,\"variations\":[\"red\",\"green\",\"blue\"],\"on\":true" + String basicProps = "\"variations\":[\"red\",\"green\",\"blue\"],\"on\":true" + ",\"offVariation\":0,\"fallthrough\":{\"variation\":2}"; verifyFlag( - td -> td.flag("flag").variations(THREE_STRING_VALUES).offVariation(0).fallthroughVariation(2), + f -> f.variations(THREE_STRING_VALUES).offVariation(0).fallthroughVariation(2), basicProps ); } @Test public void userTargets() throws Exception { - String booleanFlagBasicProps = "\"key\":\"flag\",\"version\":1,\"on\":true,\"variations\":[true,false]" + + String booleanFlagBasicProps = "\"on\":true,\"variations\":[true,false]" + ",\"offVariation\":1,\"fallthrough\":{\"variation\":0}"; verifyFlag( - td -> td.flag("flag").variationForUser("a", true).variationForUser("b", true), + f -> f.variationForUser("a", true).variationForUser("b", true), booleanFlagBasicProps + ",\"targets\":[{\"variation\":0,\"values\":[\"a\",\"b\"]}]" ); verifyFlag( - td -> td.flag("flag").variationForUser("a", true).variationForUser("a", true), + f -> f.variationForUser("a", true).variationForUser("a", true), booleanFlagBasicProps + ",\"targets\":[{\"variation\":0,\"values\":[\"a\"]}]" ); verifyFlag( - td -> td.flag("flag").variationForUser("a", false).variationForUser("b", true).variationForUser("c", false), + f -> f.variationForUser("a", false).variationForUser("b", true).variationForUser("c", false), booleanFlagBasicProps + ",\"targets\":[{\"variation\":0,\"values\":[\"b\"]}" + ",{\"variation\":1,\"values\":[\"a\",\"c\"]}]" ); verifyFlag( - td -> td.flag("flag").variationForUser("a", true).variationForUser("b", true).variationForUser("a", false), + f -> f.variationForUser("a", true).variationForUser("b", true).variationForUser("a", false), booleanFlagBasicProps + ",\"targets\":[{\"variation\":0,\"values\":[\"b\"]}" + ",{\"variation\":1,\"values\":[\"a\"]}]" ); - String stringFlagBasicProps = "\"key\":\"flag\",\"version\":1,\"variations\":[\"red\",\"green\",\"blue\"],\"on\":true" + String stringFlagBasicProps = "\"variations\":[\"red\",\"green\",\"blue\"],\"on\":true" + ",\"offVariation\":0,\"fallthrough\":{\"variation\":2}"; verifyFlag( - td -> td.flag("flag").variations(THREE_STRING_VALUES).offVariation(0).fallthroughVariation(2) + f -> f.variations(THREE_STRING_VALUES).offVariation(0).fallthroughVariation(2) .variationForUser("a", 2).variationForUser("b", 2), stringFlagBasicProps + ",\"targets\":[{\"variation\":2,\"values\":[\"a\",\"b\"]}]" ); verifyFlag( - td -> td.flag("flag").variations(THREE_STRING_VALUES).offVariation(0).fallthroughVariation(2) + f -> f.variations(THREE_STRING_VALUES).offVariation(0).fallthroughVariation(2) .variationForUser("a", 2).variationForUser("b", 1).variationForUser("c", 2), stringFlagBasicProps + ",\"targets\":[{\"variation\":1,\"values\":[\"b\"]}" + ",{\"variation\":2,\"values\":[\"a\",\"c\"]}]" @@ -226,7 +223,7 @@ public void userTargets() throws Exception { @Test public void flagRules() throws Exception { - String basicProps = "\"key\":\"flag\",\"version\":1,\"variations\":[true,false]" + + String basicProps = "\"variations\":[true,false]" + ",\"on\":true,\"offVariation\":1,\"fallthrough\":{\"variation\":0}"; // match that returns variation 0/true @@ -235,11 +232,11 @@ public void flagRules() throws Exception { "{\"attribute\":\"name\",\"op\":\"in\",\"values\":[\"Lucy\"],\"negate\":false}" + "]}]"; verifyFlag( - td -> td.flag("flag").ifMatch(UserAttribute.NAME, LDValue.of("Lucy")).thenReturn(true), + f -> f.ifMatch(UserAttribute.NAME, LDValue.of("Lucy")).thenReturn(true), matchReturnsVariation0 ); verifyFlag( - td -> td.flag("flag").ifMatch(UserAttribute.NAME, LDValue.of("Lucy")).thenReturn(0), + f -> f.ifMatch(UserAttribute.NAME, LDValue.of("Lucy")).thenReturn(0), matchReturnsVariation0 ); @@ -249,17 +246,17 @@ public void flagRules() throws Exception { "{\"attribute\":\"name\",\"op\":\"in\",\"values\":[\"Lucy\"],\"negate\":false}" + "]}]"; verifyFlag( - td -> td.flag("flag").ifMatch(UserAttribute.NAME, LDValue.of("Lucy")).thenReturn(false), + f -> f.ifMatch(UserAttribute.NAME, LDValue.of("Lucy")).thenReturn(false), matchReturnsVariation1 ); verifyFlag( - td -> td.flag("flag").ifMatch(UserAttribute.NAME, LDValue.of("Lucy")).thenReturn(1), + f -> f.ifMatch(UserAttribute.NAME, LDValue.of("Lucy")).thenReturn(1), matchReturnsVariation1 ); // negated match verifyFlag( - td -> td.flag("flag").ifNotMatch(UserAttribute.NAME, LDValue.of("Lucy")).thenReturn(true), + f -> f.ifNotMatch(UserAttribute.NAME, LDValue.of("Lucy")).thenReturn(true), basicProps + ",\"rules\":[{\"id\":\"rule0\",\"variation\":0,\"trackEvents\":false,\"clauses\":[" + "{\"attribute\":\"name\",\"op\":\"in\",\"values\":[\"Lucy\"],\"negate\":true}" + "]}]" @@ -267,8 +264,7 @@ public void flagRules() throws Exception { // multiple clauses verifyFlag( - td -> td.flag("flag") - .ifMatch(UserAttribute.NAME, LDValue.of("Lucy")) + f -> f.ifMatch(UserAttribute.NAME, LDValue.of("Lucy")) .andMatch(UserAttribute.COUNTRY, LDValue.of("gb")) .thenReturn(true), basicProps + ",\"rules\":[{\"id\":\"rule0\",\"variation\":0,\"trackEvents\":false,\"clauses\":[" + @@ -279,8 +275,7 @@ public void flagRules() throws Exception { // multiple rules verifyFlag( - td -> td.flag("flag") - .ifMatch(UserAttribute.NAME, LDValue.of("Lucy")).thenReturn(true) + f -> f.ifMatch(UserAttribute.NAME, LDValue.of("Lucy")).thenReturn(true) .ifMatch(UserAttribute.NAME, LDValue.of("Mina")).thenReturn(true), basicProps + ",\"rules\":[" + "{\"id\":\"rule0\",\"variation\":0,\"trackEvents\":false,\"clauses\":[" + @@ -294,8 +289,11 @@ public void flagRules() throws Exception { } - private void verifyFlag(Function makeFlag, String expectedProps) throws Exception { - String expectedJson = "{" + expectedProps + + private void verifyFlag( + Function configureFlag, + String expectedProps + ) throws Exception { + String expectedJson = "{\"key\":\"flagkey\",\"version\":1," + expectedProps + ",\"clientSide\":false,\"deleted\":false,\"trackEvents\":false,\"trackEventsFallthrough\":false}"; TestData td = TestData.dataSource(); @@ -303,7 +301,7 @@ private void verifyFlag(Function makeFlag, Strin DataSource ds = td.createDataSource(null, updates); ds.start(); - td.update(makeFlag.apply(td)); + td.update(configureFlag.apply(td.flag("flagkey"))); assertThat(updates.upserts.size(), equalTo(1)); UpsertParams up = updates.upserts.take(); From 378b8e8fba147db3119059136a400c25554cfdab Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 4 Aug 2020 16:56:50 -0700 Subject: [PATCH 522/673] 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 4836ea36be5a8398e3214efd9531df381418bd92 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 4 Aug 2020 17:26:33 -0700 Subject: [PATCH 523/673] fix release metadata --- .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 96e58e76e7128da5b42c89c0567ebf4febcda487 Mon Sep 17 00:00:00 2001 From: LaunchDarklyCI Date: Tue, 4 Aug 2020 17:44:59 -0700 Subject: [PATCH 524/673] prepare 4.14.1 release (#200) --- .circleci/config.yml | 19 +++ .gitignore | 1 + CONTRIBUTING.md | 4 + benchmarks/Makefile | 36 ++++ benchmarks/build.gradle | 63 +++++++ benchmarks/settings.gradle | 1 + .../client/EventProcessorInternals.java | 14 ++ .../com/launchdarkly/client/FlagData.java | 82 +++++++++ .../sdk/server/EventProcessorBenchmarks.java | 137 +++++++++++++++ .../server/LDClientEvaluationBenchmarks.java | 156 ++++++++++++++++++ .../launchdarkly/sdk/server/TestValues.java | 67 ++++++++ .../java/com/launchdarkly/client/LDUser.java | 45 ++--- .../client/LDClientEndToEndTest.java | 8 +- .../com/launchdarkly/client/LDUserTest.java | 8 + 14 files changed, 615 insertions(+), 26 deletions(-) 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/EventProcessorInternals.java create mode 100644 benchmarks/src/jmh/java/com/launchdarkly/client/FlagData.java create mode 100644 benchmarks/src/jmh/java/com/launchdarkly/sdk/server/EventProcessorBenchmarks.java create mode 100644 benchmarks/src/jmh/java/com/launchdarkly/sdk/server/LDClientEvaluationBenchmarks.java create mode 100644 benchmarks/src/jmh/java/com/launchdarkly/sdk/server/TestValues.java diff --git a/.circleci/config.yml b/.circleci/config.yml index 554291282..9d09ffa0d 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,19 @@ jobs: - run: name: run packaging tests command: cd packaging-test && make all + + benchmarks: + docker: + - image: circleci/openjdk:11 + 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 + - store_artifacts: + path: benchmarks/build/reports/jmh 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/CONTRIBUTING.md b/CONTRIBUTING.md index 3e5c76bf0..13daea7e0 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`. diff --git a/benchmarks/Makefile b/benchmarks/Makefile new file mode 100644 index 000000000..d39fff5d9 --- /dev/null +++ b/benchmarks/Makefile @@ -0,0 +1,36 @@ +.PHONY: benchmark clean sdk + +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 + cat build/reports/jmh/human.txt + ../gradlew jmhReport + +clean: + rm -rf build lib + +sdk: $(BENCHMARK_ALL_JAR) $(BENCHMARK_TEST_JAR) + +$(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 testJar diff --git a/benchmarks/build.gradle b/benchmarks/build.gradle new file mode 100644 index 000000000..b63e654e7 --- /dev/null +++ b/benchmarks/build.gradle @@ -0,0 +1,63 @@ + +buildscript { + repositories { + jcenter() + mavenCentral() + } +} + +plugins { + id "me.champeau.gradle.jmh" version "0.5.0" + id "io.morethan.jmhreport" version "0.9.0" +} + +repositories { + mavenCentral() +} + +ext.versions = [ + "jmh": "1.21", + "guava": "19.0" +] + +dependencies { + 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.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 = ['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? + 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. + 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 = '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. + timeUnit = 'ns' // Output time unit. Available time units are: [m, s, ms, us, ns]. + 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. + // 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 +} + +jmhReport { + jmhResultPath = project.file('build/reports/jmh/results.json') + jmhReportOutput = project.file('build/reports/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/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/FlagData.java b/benchmarks/src/jmh/java/com/launchdarkly/client/FlagData.java new file mode 100644 index 000000000..f908e80c3 --- /dev/null +++ b/benchmarks/src/jmh/java/com/launchdarkly/client/FlagData.java @@ -0,0 +1,82 @@ +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; +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; + +// 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 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); + + 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; + } +} diff --git a/benchmarks/src/jmh/java/com/launchdarkly/sdk/server/EventProcessorBenchmarks.java b/benchmarks/src/jmh/java/com/launchdarkly/sdk/server/EventProcessorBenchmarks.java new file mode 100644 index 000000000..a033b1eaf --- /dev/null +++ b/benchmarks/src/jmh/java/com/launchdarkly/sdk/server/EventProcessorBenchmarks.java @@ -0,0 +1,137 @@ +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.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.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; + 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 EventProcessor eventProcessor; + final EventSender eventSender; + final List featureRequestEventsWithoutTracking = new ArrayList<>(); + final List featureRequestEventsWithTracking = new ArrayList<>(); + 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 = Components.sendEvents() + .capacity(EVENT_BUFFER_SIZE) + .eventSender(new MockEventSenderFactory()) + .createEventProcessor(TestValues.SDK_KEY, new LDConfig.Builder().build()); + + 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() { + 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 (Event.FeatureRequest event: inputs.featureRequestEventsWithoutTracking) { + inputs.eventProcessor.sendEvent(event); + } + inputs.eventProcessor.flush(); + EventProcessorInternals.waitUntilInactive(inputs.eventProcessor); + } + + @Benchmark + public void featureRequestEventsWithFullTracking(BenchmarkInputs inputs) throws Exception { + for (Event.FeatureRequest event: inputs.featureRequestEventsWithTracking) { + inputs.eventProcessor.sendEvent(event); + } + inputs.eventProcessor.flush(); + EventProcessorInternals.waitUntilInactive(inputs.eventProcessor); + } + + @Benchmark + public void customEvents(BenchmarkInputs inputs) throws Exception { + for (int i = 0; i < TEST_EVENTS_COUNT; i++) { + inputs.eventProcessor.sendEvent(CUSTOM_EVENT); + } + inputs.eventProcessor.flush(); + EventProcessorInternals.waitUntilInactive(inputs.eventProcessor); + } + + 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/sdk/server/LDClientEvaluationBenchmarks.java b/benchmarks/src/jmh/java/com/launchdarkly/sdk/server/LDClientEvaluationBenchmarks.java new file mode 100644 index 000000000..2caa89a2e --- /dev/null +++ b/benchmarks/src/jmh/java/com/launchdarkly/sdk/server/LDClientEvaluationBenchmarks.java @@ -0,0 +1,156 @@ +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; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.State; + +import java.util.Random; + +import static com.launchdarkly.sdk.server.TestValues.BOOLEAN_FLAG_KEY; +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_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; +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; + +/** + * 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 does not count as part of a benchmark. + final LDClientInterface client; + final LDUser basicUser; + final Random random; + + public BenchmarkInputs() { + FeatureStore featureStore = TestUtil.initedFeatureStore(); + FlagData.loadTestFlags(featureStore); + + 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())); + boolean result = inputs.client.boolVariation(FLAG_WITH_TARGET_LIST_KEY, new LDUser(userKey), false); + assertTrue(result); + } + + @Benchmark + public void userNotFoundInTargetList(BenchmarkInputs inputs) throws Exception { + 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 { + boolean result = inputs.client.boolVariation(FLAG_WITH_PREREQ_KEY, inputs.basicUser, false); + assertTrue(result); + } + + @Benchmark + public void userValueFoundInClauseList(BenchmarkInputs inputs) throws Exception { + 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 { + 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 new file mode 100644 index 000000000..fa69c41e1 --- /dev/null +++ b/benchmarks/src/jmh/java/com/launchdarkly/sdk/server/TestValues.java @@ -0,0 +1,67 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.client.Event; +import com.launchdarkly.client.LDUser; +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 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"; + 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 int CLAUSE_MATCH_VALUE_COUNT = 1000; + public static final List CLAUSE_MATCH_VALUES; + public static final List CLAUSE_MATCH_VALUE_USERS; + static { + // 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++) { + 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\":{}}"; + + 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 + ); +} 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/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()); + } } } 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 22149a9dcab7095b01998247e7975d96f1e020b5 Mon Sep 17 00:00:00 2001 From: LaunchDarklyCI Date: Wed, 5 Aug 2020 00:45:39 +0000 Subject: [PATCH 525/673] Releasing version 4.14.1 --- CHANGELOG.md | 4 ++++ gradle.properties | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 700c3ce64..524b685b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes to the LaunchDarkly Java SDK will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org). +## [4.14.1] - 2020-08-04 +### Fixed: +- Deserializing `LDUser` from JSON using Gson resulted in an object that had nulls in some fields where nulls were not expected, which could cause null pointer exceptions later. While there was no defined behavior for deserializing users in the 4.x SDK (it is supported in 5.0 and above), it was simple to fix. Results of deserializing with any other JSON framework are undefined. ([#199](https://github.com/launchdarkly/java-server-sdk/issues/199)) + ## [4.14.0] - 2020-05-13 ### Added: - `EventSender` interface and `EventsConfigurationBuilder.eventSender()` allow you to specify a custom implementation of how event data is sent. This is mainly to facilitate testing, but could also be used to store and forward event data. diff --git a/gradle.properties b/gradle.properties index 52798799d..3415237ae 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -version=4.14.0 +version=4.14.1 # 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= From 8825a48fbeeff5f2a64e540b0ed85fcf41a9e200 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 18 Aug 2020 15:14:40 -0700 Subject: [PATCH 526/673] exclude Kotlin metadata from jar + fix misc Gradle problems --- build.gradle | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/build.gradle b/build.gradle index 9c6e53740..331669ff2 100644 --- a/build.gradle +++ b/build.gradle @@ -169,6 +169,11 @@ shadowJar { exclude(dependency('org.slf4j:.*:.*')) } + // Kotlin metadata for shaded classes should not be included - it confuses IDEs + exclude '**/*.kotlin_metadata' + exclude '**/*.kotlin_module' + exclude '**/*.kotlin_builtins' + // 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 { @@ -193,6 +198,10 @@ task shadowJarAll(type: com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJ configurations = [project.configurations.runtimeClasspath] exclude('META-INF/INDEX.LIST', 'META-INF/*.SF', 'META-INF/*.DSA', 'META-INF/*.RSA') + exclude '**/*.kotlin_metadata' + exclude '**/*.kotlin_module' + exclude '**/*.kotlin_builtins' + // 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 { @@ -315,7 +324,7 @@ def replaceUnshadedClasses(jarTask) { } getFiles() } def jarPath = jarTask.archiveFile.asFile.get().toPath() - FileSystems.newFileSystem(jarPath, null).withCloseable { fs -> + FileSystems.newFileSystem(jarPath, (ClassLoader)null).withCloseable { fs -> protectedClassFiles.forEach { classFile -> def classSubpath = classFile.path.substring(classFile.path.indexOf("com/launchdarkly")) Files.copy(classFile.toPath(), fs.getPath(classSubpath), StandardCopyOption.REPLACE_EXISTING) @@ -580,8 +589,13 @@ signing { sign publishing.publications.shadow } -tasks.withType(Sign) { - onlyIf { !"1".equals(project.findProperty("LD_SKIP_SIGNING")) } // so we can build jars for testing in CI +tasks.withType(Sign) { t -> + onlyIf { !shouldSkipSigning() } // so we can build jars for testing in CI +} + +def shouldSkipSigning() { + return "1".equals(project.findProperty("LD_SKIP_SIGNING")) || + "1".equals(System.getenv("LD_SKIP_SIGNING")) } // This task is used by the logic in ./packaging-test to get copies of all the direct and transitive From b32abc3786e827534e760db5d95be8294c3369ae Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 24 Aug 2020 14:16:49 -0700 Subject: [PATCH 527/673] update CI and Gradle to test with newer JDKs (#259) --- .circleci/config.yml | 10 +++++ gradle/wrapper/gradle-wrapper.jar | Bin 56177 -> 58695 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 51 ++++++++++++++--------- gradlew.bat | 21 +++++++++- 5 files changed, 62 insertions(+), 22 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 9b4e8ed09..218275483 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -25,6 +25,16 @@ workflows: - test-linux: name: Java 11 - Linux - OpenJDK docker-image: circleci/openjdk:11 + requires: + - build-linux + - test-linux: + name: Java 13 - Linux - OpenJDK + docker-image: circleci/openjdk:13-jdk-buster + requires: + - build-linux + - test-linux: + name: Java 14 - Linux - OpenJDK + docker-image: circleci/openjdk:14-jdk-buster with-coverage: true requires: - build-linux diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 29953ea141f55e3b8fc691d31b5ca8816d89fa87..f3d88b1c2faf2fc91d853cd5d4242b5547257070 100644 GIT binary patch delta 50409 zcmY(qV{m3sw=JBGI<}pTZQHhOc5FW#+qP}%iEXE2+cs{0@A>XI=l)o=*Zwi9X4M)s zF~*#?Enp+#U=WHjVBj$Ex9IUWsHiX?AW%>sARs~@w)Omcg8yy;@q%)w)IpeJ7-&Tp zw@$ZCKS2K5pcUcAe+<{r|GzIY=KokHlP#3A{{R8O1_J?MNW3M&NL2iR1|VgkH?c8t zc8*q_uv-vB<6CHRWhx1J5c5nDG?23T-NzmVp%yPlPpjq03=9*)E1U#;##EHEy4JSUS~K217a zbSj}|(R}K@c1_8$q;7a>K$(oiRi*kRfdbX>x(6nH1!l(E_Vv# zeY|BHB(Vv;si&;tw>o7^G)aU@;^SQdD3m#%{h?B56M-MzFQd2kYn z|2+|F3UKrL#vM#O)8*RfQY1rk$ad%aF-t$E%yOo32R+x^upwk}MPr?7aiSz8s2W(`_|B_!v|&ut;Q{r+QXr@d&FXdP(Uh|gbU$pH*67EGtL)rP%?oF(udBOw zpZ#0Epr6QAn41?;-AVw7`wu*wQc}ji-pSR*?Z;<5j&J_YPo_VGFH+(lsLc8U5YU(+ zX-SM^COFsQH$usbBu47uaH*!c!`{qCaRBrYU=$LDscxT|Bal#7Ua=esJ8B`Y#{2CY;6*x+X0<(p_2Ccb_;#@@Q+|z{1bZ=q_VY*jXFXrny_}Fh=+y?1~vq zAPL=XE#2aIFKkFAiM;74s!@zi?bOXrZrTv zHHF(n%k)qNVj#EarP?q)u&-!!AOInnfq$k_;d0#P31UgzIVkLGi*taIm8&WPrBdCI zPRTudh1G4wC@xOy^%=)BF?Q)@(5+?MG96lq%@OGaM=iJpfC4ht>M_MAI!73*_eTqe zhoSopxmh~zXkGK_i0tD4sc>tJi6q86Ly$li`F;HHesxY~^o2Q@55Ni>Dge9E7;UfQ za1eexbYOp)Io8012Cdd4QXw}x^4vUIcXq6_TN4>$PbBa#A5CN?B~d1_6{aB&H#&&a^&yC&Kb%w-K*At#dOwuHwi6e@nvP6Obl>8 zb(VGC&7tqq*6_@p69I0~`6>(?v@HmM9>Q8atr=74+ETQkLjC;6+>@^^DP=xzQCsq~I13jlc^@#l|ndN#A~6#q#&JtcrZ+ zxubULu3UeE`DfX)zC-oFO9JeEC6OpgOuLuCv`5GrQ>wYx<^jAXzoFI(zo@+mzHVZu z`%U~UiGYbkBwPj_8Z8lQ-LYtuo z5Ba%%oKYk(aALK}9~_QIWD#A$bK4~F$QwnNf^?jYJfd!C-ZJ})5IDWmF|$(=Ez@&| z1tte_WaGG;@&I7w!MF}hV4GL|A|wrV-$l69P5DfAN)D7A1*;s#NNqcEfe6|d7VBuoNf2?!!8)(>9r zC-4y{&N0)CZcc?$Fw?zjca`Ap|4O3&9Y-ydzI+S*W!w3GhvYx`(qR%RaTO93w3#~b z3X%|jeFTr#{j7gIg4&BC0y)e`L~7yfoW6mnjWVpWj%6#>O*^EAM9!0Fbq-q?)6l(^ zMNB+drJ~Cf|9cFVqC%ScYl2QaXs!?IlwI3{fsfd>vJFH0cA+>)OD^rpO7fV<wla?`RA7qh?n8PD;GEj$jZ-ufB)A@C{j@~b0b$9m&Be) z0zk3arV6ea+V`dnJM09cvLt0v%bylDaOn#5rlM+Fe@jcsWJRQE?$iTzo6d_pbFfd` zw;dIOw*m!u1E13lxOSktNd=F%!W$#U2Bt?P4h@H=%;Vt}qYhl}B=wP?CpO6NGJIF@h1`(rv-WSiS0 z?sbgQdZ^uvttFVb@X7apH`Nhh(lr^^2D3Nmtb`)KO3W!#y}<*Gj6fk^0ilkA1GIH1|Ct!~Q=0zIxrD)GHQhrE>i zUi6GUx>$w$wMrC6U`i6c_4ci8eyVkKC)|j2V9~54@FT#ZhkB!6^udNHORem2 zt1FjCdsY^)?N&*P>xD9l%bYFN&)eV-b1o*APqvBtd^XbKkY&cz=Y5c|zz0?tujpkD zYY5t<&M3tSdu(lK9r2f#yED>NnCyepgLjZek1%l`F-Zedf|Jq@z+AM*Sw}RDZiyQT zm#BBf6wcO2*eoX2MMcjm@lz3h@Dq}GMI{>$H2YvmCoF+^5$_dlxQSIjP?o;S6d2h7 zMM@)A&kYrLz~QnAkkQ;$a@$6oM?4vb<@C=*>qK)TZ-RFV^gxgrvP0rCg)i5u<^C(- zjjzqSENt!7W1U0@SfaErF|TO!hInLJNXMN;eWSx8Hs1~ULPn6BhW14#pNqlojtoI6 zQGw2`@W7P|e8UWzZaSy21Og=d5s`!{Q-)c}NM2dcA2F>bYdJ%S#Q7d+b+2q5WpIz@dRrQ*Gg#rc}+IERPh{``4|;?IbOoe>T!h7E0+ zrmV?WzaDLAeZp|X)>dO($U6p#gIranM{k_hVQ9D4RGb5BC)}8{DkhvpCBTnz%N^k< z_@nn3EP`{XY&cMl-q)7^GO^;cm8zAlaA3<^f=#UK14kp?v~D{jKQ8%C>v{lVu{9~K zzQ~;y5wPZazkEgi6{PnpC)pvrz5 zXSABF=}}nK$&Mfc7nurz4XDa4Bl&GS@wdxDUPbJ3Kpc{#$T6HEB)vqr+F0iKy`HWp zT!KB~<^<)NT)rJ{b}S(rKC|Rq6w}js4_oB+n?8})-TXq=*w1(YZZ!?-rf&E0VNDv3 zQTDujG%T)Sr0z&vf=K$!s8F{J%h-bn@+Efh;uR}hQK@+Bup}u<7C^e$uOCb-P%@}5 zrr=>GoT*u$VIl&6#`22G!xW^5RnPcQvi#22JvS_v@>R~cGQxK9ItaqqCiNSI>mc;N z9c`)og9#-!>j1eVE6Id__!D2_t*$#~j7t;@lGI!Fn2?kSD}Fjx5|uG1=GkK_P=kStUgcqjHNVtPEL6 zW`IJ5Bg1YY3(+NV8$(U?jjWCt>hiJp{mbrxNmOF*f;yML>OL z=JN&QOPKDv80Twz@Z1lG2&fLJ#eTtk{Lm5DitV}IzrIDZei@;*ey2eFz8Lv?%K-TW zSQ_kkt%v@;3Vu^TRNcpYK@`Xt0c5-|2z=pgtD$bsj#OxWm4fL1LekZ z*5#~N%^IoXN?0@JjioE8Y^_Z?iQTb;*Q*_PuSlN}; z+uP{jS!>RJ8fetKz`He#eRQ$wTX^E}>X?*+* z+f~_)HD^Ihry~_&*41t>T{&_?H}@}gOs71v-H4?wH?|H@bLP7B0u@EO_r{nSHL5@s z0j>(Vieo|I)8InlXG}xanp~S()p~erC#o1s)*P=C0(0MM|9YY%&D12Bh`6&x>WP{2 zU^cQD4PBXRk)Du9U+xy5i6_|&+jS)8z)37=T?0dX;PzEa&AM~dI5XOV{#{&>iue%2DBt80?OC6>PsPTB>X6t?h4t2w8s9$+vBnf+4M ze}8r%gTR?O#efd5>=vbltyc?IWpZ?P3R1g{C7mBbmS7{+5r}29m5vD>xoN`LG9r#{ z#3*61+QlP>;7JuMUGJXg6Ahm?+T|r^zVY?=$-=nXp<=576Q6V+a?y0452Uozk)GHa zkb~}?iPKBjU_jJt(BYnq;EC+RXmeGGH)+Am{7=UIF{G& z)PQHAT?pfsa7y(To9)0h1#Zl-?+!HL0Yh37X)+Q{ZoOP_ZkXNd!cjzC^DZx|>42Dv z><=-bj`0M*Q6}VHi?x@~YzqFD4qGj-a1vLg(UDEoA&;|QY>kX4l#kb-{&R5lq)4Ty z%_ny%FD7I|)N)+DBr9%Ss=SmA7@_5_kt{r(z#?4L<6+|tP-wFNSP~8OBjR&z9Df0Kb-??g2c6=#!eixZ<~VOj1ib*ZDqt( zck^9@jEbW5@)yYx<|Lf++O3Tg+)4_WqOrQGJX2()H~L3z3do?*JE=Se6N<_LC~)zTV)^>2(q|TcaQxP$ zKzuv!>>Km3t@x7??n}*z?@Ey}YRifqL9wAo&BvCUmobNAbquYQ9~c;#o+;SUx#qN4 zycRd2M^(=d+CWVgt8*7*tqGrLYvD{}eYgd1+R;q)&CV*t3Rl&^X#C7dja6j;7`zgmuIIhQa;`b^7#Nyu* zyBGqQ)l2v)L>(J+c|vJxxKN_xeE4_YL1+iZ`pvxbF{z>of|QkA1QT*1ly+%mejiex5xsx7OWlgEv1k6%nYTw`=?8 zOl_@{WxP7wr<%VJ9FcLA*RE_0qh@ScI&`OAcY}JbxlmuWp^1E}G?I zMG0|Mv3!(7_to9rr+JGThbMKUG1bJ!Lrd&N_PzRxj!#X$g?YB8SNGdGxsd`cDNWU) z>lv(K70xR67Ins-jiq0{TjXBu6l=tNe&y0an|ez7xA2 zRJ=Foa6YKIuyU7^+Uk3FZ>Lv3qP)P+n@{N7rK2amXSGFd50wEylgB=Oj)R#!Wob5* zYK|8ux72#%ttpT5-vYYJ9cPqxHOx4_ zAr5e5NGWXH{ngB}7);b+U|Wsx--xk7{fUgz4tNprK%uMp&gw$C- z7h5Czawhm*+xtp$fw8-nc!bC6Ai$d&t))d;x@wlVO6^J1jQRi*SXn^R9;k(!mN;*C z>BBWqWz(-Ymf{NGnWP5k>b=vf2*N^`N1X{dIhHa@Yd`E{xoU=i1+)hsoc#i=kH{1q z3!{FBR7i#AKYE6DR=(O@@d{(nm_~g^9?}AreUILDp{42|KrR!8-FE&;{baSNMQSR^ zl|cD*F1fKrLkG4B>IG1n{|0GoVFW)0XI+!8zN%bqTfX#uZBBdD=X9~yQGL;!brqSR zk&^iG=|Q1pTBuXbvD|E`m@4#HPkwmT;7Qo%s zWsTrvj$}Ie*9A*R!O(Vgbf+YbA~2H&IDvCZ)i|JP-)i%$aamTgriA(cIRz&yvR+A@ z6Y31#YF_uHRpkSX3)*T{=ZKLR0@^y0ck3ncI9?I?6mu4W^I2t8rsY4Cs5S${#7O~B327EI+>|K(lJbSg~}xRFT-CK1c{qaAvZ&VX|9(rCJB&338|l*BmWTm^^G+*viz9Ol8`_%s z#WM;1oR?UPJdFHMk4o~v6IO_2CQiX6!}5UXkd-gs?+(}{_>l1Fa@W zM!#>NNCl{fYv2KzzGwkcm6V1)v|f%W+lG6&!F1qwEp|M?u32vUzz7)`Eg9MO@^iRY z>Bq=2ev1s?!9UU+kuXoqr6-_Mc3{2i;JsJ+%}IYDVslQ;Wj8oQz74AOypOtm?86oG z2QO>cU|WwHRywpL3ar04V**<4Ek@w3y65>KV*vTR(?{4F-g+E{VLQ$W(4?}7P6+4F z?9{RFXuuphKP(2zFLj?F$C-dYL}T0yy?j55gn`i5!&!F?RM(Ba3vXN=HIQy>3ng$@ zJ#aFAU%;wvQ+ltoTo9%V6%6em_2fj)e0|L8a^-jsRkTKZ0y^j!ixlP)O6wyGF$*A` zkpZbkMm`!p^xe>R%rJkyXrar3ZZcSp0?UZI_SVbHsWs5{+QVf(8fJ!7j8)I}$H_u2 z1tE6_===k*@nBpu{BS~9PEM_$A1a>t5w}CB!6bf>x_M{^pMPSDas!1FW+#Te^2d*Q z=inemkzU~-YaNc)7pL>*vxQ=l_OEVETmbkQZNolljHkX;b0!zt6eq9sx=|Te*mZ%j zPZQQ|a=4Y>1aH1UpR+S>xrRS=d!3`E@4596nL#VZZXI>=gV+m3)TVihs)l7=z*#u^ zuq><;ZWUihJ=&D!N*HI zl*`f}egF7{L4PpX0{kzZkPCJsBB{+}`rha|GCT+JhjQnCc8dXDUGkMHi0$*l)_F#U z{yOkk9BY(`q92X*QfaT?duZzXrUCp08^Gh?^+$fZffQdFz;rm1%Qjr@pq#2suNfX2Ert_Jou zzSjI&rK^ush5D47yf!sYeKMqUgpDxXU-?6stl=1))HoNq>=oQvw;^lvkqwm$VI9iW zmRUtZk9f#kJ1a`_p-&Rg6OGQSAPOMoH z9y61vwgr}(+wgSJO=`fH#Rgw$C`VLEf*_{D(KYGz(A-1XpcvZ^R3X2@1TEE-c2^qi ztn_m57Chw^9o}H+hRy%!b}YXhmgMzz7gohWLi}z9#wEspnITgPustMbXkFBd0U;}Qu!q+CU^khmZY~n&C~rrpV{(j_$TeiT`cq<^Q+Hul%UHf zopPP+hSNd0wUxZr!ci7oGGD9V8cT^qxpOwwBPOl&SWM@ZkJ}bYWXmMIr+GVV*Tqe8 zfL^s`>S37KM4zw_K%E-3#AI3~HfDJ04-sP;^nTq&)!sRxg4%XNBg9`SRXzo7Key2) zTeJEeZ@tAt=Ty@^jq!uzhC@1*L8T7U&R{YBP2xh4u3(4-$09vwIK~LEWXkp}9&+&wU92bXWAiUBX?vrv8&B53_)To05J6!s);hXCD*OMYJ)@k3=L6A zAqarDv=G0x68s7GBq05war_5cIi2a1N)ERRraWQ#8gD8$nSs#ElP zXR`_kpmQA|3SM89!Q)D~EI~6H8rg??)HSW@N%r~3sv!O_No((LTK3s3kf&yL7cD$} za{0U(#kH>dcE6KYYNxk_uT2QKD>@Y;Jgjam2iCk)?bcIPGuhRoG8zGKT1~g}Y+|9L zhsfdEEk-F8Pl-i-T2RZN6$0KBOdtrkj?jKIXIyW(`kVM;zu8${e91kDufZc~P7Y2e_9< z1d+cyn_%0uDofA|<@HW8mKbCOm);pV0sw(-nn>b`~1o;Es! zJ^smeP2x1gTM@1+Q*juJrP@^V^tR3k=mmSH>kQw#uJS8WeW>B+3T00Y$9ZzQ+W*>tA@R#KrC_Z|j#ya>WEscW%1UZF? z@GNYn8>z`*4M;W87h%qyKxrOuem_9QI4%1k^oS$$2nqN_{`l8Z1qf;5507w$18xRx zb3eF0Fo)|R9FhBqW;v_j;TAFze}w~Af|cqqP4+;p1%jcy*r*T^j~x-LgVUWw6|q|_ zFQF|T^29T_=EEW(W7^J3U6C&U{IMp3bAD%EpjL9DVQyY*y^?Yt(cRfZ6%jA6AvdG~ z#-UVpRZ=mEccfG*vtNPxI0YY@jS<#6))&6{HpNWoBaL$kCi=AjbfpFOB-J8$f?|7{`2}K zddemmC#Mx}!obinnxX;DR>m%#-KCf|)LuRIsjb!IYE}2d0k$NYjZdcP+%U7pUJVO{ z>MzYH*$nNP?)OFdA@eifVac#vXLl>Zdr+muTazyFVOkk%8Y!B>8iP*5px#D4D<;Yw z1A$}GiBY_t2GX+Ya+!MzmCfAGO!q2&1Ed6=~71v^uoH)dC@kex*ks z7vy7!;M@z{oPBP2gnOFGEeMXt_GRo-x%H~p6_aSqTBQdfGtD_*Y|Rk;{zcNaw$<(u z|0ZA?WT}Hqm;ybQkf|xPt>?1cbXLF!D|Wk!Gu+(0yc9d$PB?AN@i+~}(NaF%=uDsP zTt1j(yDRCPam$oFXLcZL9@kn-77tgA$wh^ne~IqLma_>Ra{r)3@rpv2e7X{XT#mml zEG1LKF33e6NrW{Z`p*wI(M{aX9=Sa}RNo zBjYMPndPR+Dmef7s~@M+s5*7e((w5d@V~H#^iNz*H(}=}g$4nE#{dDL{wG)9CGJci z1F|)LdZ3M&aUUPhb1cXOrKx?@s?T@PwFc^Y|{Y&b8kI)GBJ-xg0O(&07ysh&QF#-DWvcdO*55Oli z{~yGPQA5okC<5hMMsai>$v!jmb4Rfuy|%NF(8G|}ms=$W!o*y{)W}@LTPdFknA~9t zG~Qoa0yM-@UwHfsp$ug&zq*U|CSD=}YAaS^YN033R@Ub+tOOcrOMS27r?0XvB|6-n z-CVCxphV?QN*_zwa_}tA>Z>)K3;}_?i|Q=t4Ua`#2C=2^{)%HyEl*o1HIm*s2&MEB z4HgC(-u5xvwSTl5%8gBEu^TyHlY()dU|+PS{G=^YR9%jMmGNSBH%HDI^a+8X&g!08AR$$Og2_S0sX;4bJ6#Wr;n=Py8|oPGa=ohAFi zzLj?5tDLWefUjMJ(8)=^j{{sim?FkHyh4Y(`Ba=()qAoDoo(YGN6V>OUD96`3^9!T zUY6PJ>=WmyKwyI$3ZlvRGaUi7g5* zFffDuAw0lvO9RiuD>i^6Z^h|fO8wFtvc1y^>dZTNmT<^PaJ=h=Ijwk@JfGh~^RR>U zZMR8aBUKy)-Z-4#(~~+t2!6(w*4m(6Y$IRPljcw(1Q^T(ZvuSq7D!!9qwgQe_kU^i z8L)n!0~T)Fu{Qua86ki@oQZ+#N!Z`tA2`c zbftV~v6plO4o;zmMGyH4If_fo{!uy<166C`WbKz26;C-;XgNdY${g7Yx3cZLc<2yD zwB%#EurC;V4nVcyG6nJS>#XC1Y*2xX)W|~;a)m)wg@ICB=;nw#mXZ#tidZw6Mq!v$ zMj84Ku|+Fm?)fTK4*s2tH2>cbY31Qs#_TN0;OI;esLo`LIg^OO;I-bA#gnhJ;rq;Y zx`l^4^2<{m8@!DSp3GwdD}2j@7{R#Y3rWRX$1&-JXmUSU4NmDfw-C(TYu+XCs9 z5m=jSQMG9iBZ$>mMOMtTD>Csdo_V*(DlC5oCUD<6hrKHqTn{1yGH zU`WizG{C%}$M%ACq*yp_D_$?ny+DKV|rxHm~yQi66|Og_fW8Dw%lS}VJiAO z7RNl5?z7A@quz!4_}JY!CwaTN>(MG!eDXi1MvvX!S3^PqO?T!LPT)2!3AO|d>PKjt zSOC0F(sCOy$)-nR!xwn$70T>}oUT2yMNF{w>CpI;#mZLelil4}vdKkz-73lkrm4<{Oh88#IwmiaNr?}D_2VC(g=7ZUe0MbLUfw-B0 zR$fKv4CbsCb6SSbBWJsKLaEf9ztW|a0Wf=BX~ zfD~CwK+i8p7czzg4>Ftoj}T>&>(APlQ0$TW#XWF&luOXZNRmDWQhY8@@}AeHQE@Km zDyn&AZYQ+e?qR_JMjVN{B>u^Sl%x1zd}Xa+yh(}nWU2$ zx~qDdSDPw~KT_Ji=`qOu#sCj0oIXH>SGdAbXt!x4cHvU)?Zxt_>X-v{LhCEAYIqRs z)DdE8=V&(yU9uNDIr7ZwxC7a+tEP5Z@DUBIP0`NZQxNu_D$(ZfR^s}G-x+mKz%qSO z>)UIq>Mg)^LL5ibxXTWSZ2;(PSYs7S>`_mV2u|>c7WnLQI{0va^ul!%$e$k-M&saR zM)(SIyg7M{1MT5!u8K4AkXmP3qrFQG+g4e-RWUp>dw9*)MQRqeq<&K-744M@M+S60 z-sn}{aLF)NFs@W+72^^X%RBrOh<_lW{VU*FHX~2%4!BN7^HeJM0tDz!hZ}&3&ni{^ zT$9a??(_~$v*_B8Orgdv1yd1Px#%i<5tpL1`DI2Ilrhs|ylrQ+S%%ayHKpH(|3#B>3xrJKMwasnT7NG^+iHof^JK4FO|jw?8{xU(cfsFX_H*jh5`5=`Pby-* z_U0U`o3vC+xieqW?w?!l-OulG)1Sxrp+Bnn1&)lsXzdal0GQ4=? z{;;4zX44{1rznZm(3@XZ29q=lyGO+vc*jQw*w8>(aSr|IsqA|PO#QyXPBS1%t#N|J zga`Z;^lr24TL+Tg~tR8%$!5I+COw3_rTnu zGl{E~+~L~*YV^MgdjOya0Z_Yv#6FVZsQi<-gho~rq&}*{+#3uF&;sPU`zZ3#FF}Gs z$V-|=no3Jpvxyw1A>?&=`*mKT(I@Ib$G8yV>EtD)S?rq_nn{_fZ-#4gbd7)6OVVVg z!XMmPQ5Vc)F`6euym)OZWhoTbjaF|ZH`Gl+FtF>FOqCl+eD$|801DC{yT)vMB4-uN z=U(&B{?ZER`5Cv8h{$P<#!%cMUe-g>{)fd)O_@{@Y;Yy(MVTFqVmmb%t5C4niqiT5Fgd*QNZpx5su~>7h}c7=dwV&&~XHW3H_yI&m6sndn$+|w*}JMkPK-n z)-LOm#45`Yk#{K+fLM7!NegFN#8rGVg4LW6bgqk;Lai>TCD-a}5vi;j$`}>it1%p= zP~nh@^t!v90M1gvD%J$_62Y=ftWS5jRlRB^n=wub4$1J3er;*KqGogxImar~P&ZK) zs92X57zOT2+<1fnvOcxo8lIHN_S%jxHmTO%R_`Y;sFs z#&MCZO-5RjVB=;Kj(SAiVmX(Hx@>?rZy(e45n)+?0`9KcVMF zLu`B=#SIU1kH*c<52e1um&5>peFjlo(6pd3bsF2j3b~1L`OMbyF>C2O>yZL^R_LG( zHa4?#I?~5lpPioFYnYt=qTYF{Yu8da2?a|P;Z+TnpzMo-!$vY1D~92y-sHvr@trpr z**V==Kx1*_xSJ8T;#|7Er8_INZc6rj!f&OV)D(SPs(g0q-xd2uHN7DT$t|YQSxUJ+ zY`Ub!BWxC_6)6+a#Xl3=3CF zz$uoy=4~77GN^+EL)vODes>vTM3B)?shrM4iET!w)g@P`4Nx*W(@m3-oekJAY7*SL zOB6);Ott;2`OrWsJ>e5p$p zn>@5Szh2SNnOHXokfu}ycb80cAypoBlpmai`h^Nyv4?DZA#n8aRPNyEf!4{}i(`(X zAl8rP#%Is2bPi;kNC}{)_lYH{H-I&MhBLBL3Hv^>Qw#fZ7^w{Q=Lk|2Zs-FDKq+O{ z+&XX&v1*WRQGE_aO7Ld~i2Gp1iBO*c=o$%Mw+^x)pV^&O(AznIs21e?wD1+7L_3EH zbDBZ>pA0yRn1#+%bzf{<_K3$vY}J^8bPR9?FWg9S2TzLuaE#brqIyf^3!1{8=-5Hw zepa~@O?Ui~U#K_l>5Pz`e_n%Z9h!*Dg^1#lHz zQ@=HRRsfJQx5O9EMSAP0jUJ#h+8dk4Fiqh7{w!_shG?0dE?s%YW@TiWMMWV=L4hu^ zGZ|F{4i2Erz7$_P>B)GbdGvu^%Mt=>Hlub)p4PYuJ1r@K@h4E>545JcfMlAkat0n- zdd0MnmI%6E!Ih;@{r*|_{C5(35J8B?A*SWO>7pyi>q7SGTV}GexFcOZc{_~pIAn#x z0)G)FJTP6mGr4lqIDUsJ1ug|$vjvqlpX4LH`?BJa0!N^?NrLY7gRXg?Hz}aEOQAPo zQMw69Ci$ew{BSOfq3tlv0gjO?Aqe&OdV54}k>F1WC3Z%I5*x96dw5<$Br6otg$BI- zKW@>UA_04U+WzS1$r1+0zNH#*JRt=rD9b-4xCxBajOgP)NX`2_#M}N5cg1*T+jD$a zsOb%BOLh7!a7%#syaS?jF|bg8!q6*};6wmla{yumhF0+WWJjV<256dxdj)-xf|5G% zgM>ZWmUQD1?%#o+O+iXV@`kJqC1d@B{rMFQr6fecE&$D~TAw^me^reC%MK^Ni8JuA zRNB0D57|_Kkt}45I8}edsFg5e_lnH0AW=NIspZF(0Y~+d;1}WlqM81&Oc1rOS3ds+ zs>x6ze`27gCXtQ$PN0lsKo z1hoK*BzzA1!Pp#n3xC`Qts|x>`;E|ZAebB_nq1#&m(a{-#%gsq&EIU*dwM$L?PEVuVdS#>9 zX*=H22cT4&o4y=aVyn_$r_Z@{8)UYC zP@sivdAD{tvz022+%`so&HEoZsj%?OHZx^;(b(f-_$NWiEs)XXe{NAv@zviT zKO2?xO4Cx@S184;?8GY_M3~@KO&Ns4ABm!}Nkr{X8v4@eM6?oyji?0_wK;>;wRy$v zWB{;=NTiDSU;a8iXp9){cmdYA7=es zCc2BzKtKfPKtPE8K^7nIUoibQ%I%}Nv?M^$2pqdv0VRez{{t?J(H{d|Bn*WD_X8RU zOwE&&VQiH4b_zG4thzNgIHP?Bj)AK0uCnCz6?AN5iu|o_iV4w(_Uap3fq+#wribYu; z%#V6U2`1JJx#y!S_k&mbXjqzMdS;)Catd5P;b8xxDUw1evG&GP0REUt*dXsll@Rmo z)Q?`dZAGE5ZBPO6h83GC*}S8l<%2ax;?4l-7@e`W!?5c2t4NLy%Voc#B$OzQ zm13Ehq4PA ztF%c8K>U5%MqmL$TdmpRp|`ZglA^v`rKi$vy16#4wq#MI4|L;FkzYz*Q?F*N63dkS zElYxCtKG|5$4mZEaO6doHS6mGoydXr+j<_pWKNB#u)Jw(7N_6KsxrU1=|tQrnJqlI zJ4Ew0E8YT5+AtTgI=$TV1U+5GDiG_JID2R?;9j&8qLRg`&N69Bolt&IMCZ{gzplHj zTR(P0!&+%7p?Wb~WXK5fPqw=eB@QztQ?~vMnU`-nxdevynHRacXO)lzjxKt{*;4qZ z`KD&2kuon*<=_5uHWJe7F`?E~yny3j&MTXDocZ^2cs-WK;{3KT)@`Lg?aojUToE~F zfC!67{YgN1%YsO~a(#GP0l#yO)tS=2$`XEM3(8i0^qrezl6BOaaA|7Z^*6OpLZ+1R zLe*3jODj3D5e^Q!!ST&MvcHmCya$&^_IK;@w=lVYe0~$EIn;LJSGT#P+UDKbKM1 z-1}785Lr+c$9jIhq{;@Z^tj}?EUJZ6_GYOW${gGL#!jA<9cHnSonENP)-Lp!O~qt< zxTFPKmgHTkc~F}yriQ8cjgF=n1J(itps+iE){$v7sJ;os)?$%iFGmZhnY|$aIQ+ul z2_S=>mr4(Zem^$*WmHa3%OOnH61WLZ3^tBf|7yaotBu}Jwb!b}5G!Ulg4|VD+(m}| zf4F+b;Mn4B+dH;x+qP}nww>%G9oyQmZ6`anZS2^_j``+3=hl7CIUl-KSFh?1U8}0+ z{LeYYZ!q@@z+ygO{F(;|WxP>l#qLbga=yFF4*H71S9T6*L22-VLbQ6c5P{X@8jl-A zk?3|19|Y>I5wGKXgs>gKYz2l^51uiaPy=j{?gF3f#&%1i`@m!^g=7|HW-!&iK9Js? zTf*m))n=jF2NY66=+)pRD)BMgN&-C{L5MejWs)u)a1=g-v3IN(SuV7%Bgv*)zv$>P zMq?vSv(bZiVr3xu>Tr-oTVlDkASY*cks-ruW;&uP*-fNh5NmotGFJB1N1 zl5F82-vg8)DXW{nRraSfci`ON@cRf_=b5E}u3@@CF)0fz!}IUFM!6;*0wm)zC4Ox{ zrv)}Nqfu9jSdf+VgPx@j_a~V&(f~~L77FWOpd0U5($&RKk@<60_0zPjA@mV2k1o}6 zr!IOs;iY?2_V8g0!JAh`bv9UCFc+IaMzfFUa$`BPX3Y1-(J^S>*e5%ZOBM5GmJYp^ zN@j+?XMIzbIrKBJ7p5)~O=O|m?r|z>qj{&;k(w$Vg zj-l(4O!ry}Ebs0bUs8&+e5=Pfs_Zpp8UC671|N+HnTo(kFk?4;W3Gx`{=%Uh-!x9F z>??!LkeOX@rTI+#DqSFbD3|Fi6D)t=;xAYLcPo(DDX^z;S1ObFeZRc+l3uBy?G`J8 zxkyf9Pr3BHqxlN?A)4xW&j1LmOZ`n);frrrcMtE&Z!^Xy{0?XAQ8dAMH?2ghDjMQD zeLqZ2<*W9^yc5(un>`I>;uKKpOS@Bg@6e-v#QUOPMsNZnw%4h)Jt(OB>d+&3^vk=B z?YY8N@J8ho*eBU~Q97->e@cXyi(F8Ed<|*wpx=UBf@+t>c)fLaHc76CsL%91ZU4ge6X}tSj zjRYgcmmONoKcN%NU+O6Qm1r@F)RxX*5zaVsh5xi3CY#W>@5VM6dIui^tsZeseP z

    ^YH-jI`)=dY;ZV9lv8V3D3|6zsibR~s4zY#pWp~2s-y;>x!8r>`fjbsO(F}-+S zv=SbO44pZh87YCN;7s>k2BsT>g^_tBd^*qYYyFf?y3kd}g#ZFx<`WfexS*AIC3q6o zOj?PRNQVU+M1RF&{HR}o6eG}2juaVFajj&HO_?rTdmBPIClvtx@8ES_jR9W9o?7+E zY2)|fI8DRUJrgxxM;}xLZ{{nTQ5=Xu3q_*iw^Bo4Djq#_Pw(U-l`Rzl~JJYGe6#-3Nzb%#c z$gr;FuR1p3=e=Bdt4s5p`<;JJNUnPPs7Nb~ik3EtvIjw{rL;{1#tcn2SNmU!~NN+Ryna3i7gGMKammmxMdib-FV$@5$B=zkNY0NywXg@s>Ij zgn_piF#tZVvtRHoTEvfGHUp|67~T!vYjJ^h<&xgpaVj`-X7|A42)t=;W+ zR*c)VGSUBvoIb4bbQAUlygh&`-|e-ykUVEJq~OU=86aCT-vyrAH_w1vz*Z18#eULX zPyzfqIP!>FUZLYm@IO7Xc?o!Q2XF~4EgPBvIRk#ubQU0QI+HSL`s}3^>HC5GDx2!o zkyJE5rlDbp&NG-y{PT$`mKOJ$4T!Jv7yHYzB6q~tb$$J5E|$iI>TBB8cgJ3I*-Y+{ z|F%;_cqd4U2`~}1rfgG}nH*;2`=bLnBPKy4T_*X|Zu;^KZg39xJA7G%+uNV#E zhSbq`TmYwebz_gExo9lfgG2+unhPHin_`}8Vsz201CKS7L$M>MmaH*XjG{GHtdj0R z`q0JL4N^Z8InhG!Ra&^WygV49PK>p<;}#evB)q`Wqn&T5m!^PO1_^OytxM_awA5RB?8mcwiRsbdAR>ZPHbr-blEx+T%;EBmpdniX!x9H4~t?g?*#;oD$1?fT>77Dz30cR8vrIGU#%V$w}Gr8|{{afHIH$3ak}pOvqec z<>nazmL(I{mb9mEF5c}%FuKDn)S}|4L%KV%M>nLR!9(<^!3Hen7Nh|A;Tc4RW`Qf( zgzBk(D1lMv6LRc(P}sh3!JTge1+Cf_HT>mKGQzo>Ni{C67eMS*qVQ|^ksdRbQSUU6$Mg+`qEH=yEYBU%fpmL%Sn+2>2A3w8??oRd75N?uzeb4eF>PX_&BPXf!1GX z)``F5D7LJg^K^Yq8ul}G!z%UVr8BXC-0beTNtry3ee^KDy|0V@1nqmzy}fi>m!s>v z^n&1C=L1Ll+l#0NGO=SJ%MJbVif|yt4Gb+zEPEK?(7 z5oaFTk9}Ob|Kw&_i}dAOlA?4I3FYO1o1YnpeUvV&+TukrxFl@trwGCc+3a#^6 zlPL*+LILs+?HK$2xkRM9+`$bMh=qYntUr+PgM$-k>&&A|x4qY^Wap%MH1OXuNr>%{ zxVV#2QdhKdmCr4{n5SE1_id%-0^PA{czl3=<(8aWssceg=oAWusqWhHj(4|3-=9ah zC|ni~)Bfp*PFwOOEWPK^oKblZCh`i^ zh2CjCAxu~Mn(7uD>FWj4M*2)OoQkN#2>>5C;7@vHBdZHWbwqh}xbJT&0wa!pL}bx? z^b62o7GUrTov~1f+F2u|B31&6J&cg-cJeus*@aoR$x0=Ttlq8v`Kj#)!>fyP`#`c= zY>5y&6RhUC`JQO)_khhChItzT|21rhVIDY6P!lZ?en>-aB590ID3*)@)4s1uI|S6e z8$*rz{EMn)$_lHsF0q|oGs*Tqg`pS1h|hr*^GMQFn9x6TzFI#Pf$9NY(B>zRZ{B}r z*ZOi#x>(;+q9feIG-)#Fnk^PABV{5~0~8AiM+#F(9sc|`Pn-E76;fdBsLdCXC74}u1g81&ScQL%!J$`S@hAFjLNxD z9!0tg-=hTk;4cL!c~wNuh~QzncOUamZ%b1|?jB$)Fjg?8e;^xp73I;?SFM;$ugK2xPcOfK2$C&_qHzYux| zstFXo!8{pX+7^DTiK6VpH8*$~NxpY&3V*i>0m!%QcmzT2L8bOv^$HU2x#5*hMTgdj zY1F9}Vw)??cJ(Zns^=s_bO3w3c`6jagl5PE_w!aESRt zRcw)v7TF)K*0P6#GoIRMV`bm{CNB5!Z4+deL|KrNJ88`!GTPzAY=8~V2$w&Sr^EtY z$N!4w(6z|ZG+?5g2Ht&O%6}uYPQ<6FA(AL$Uzfd^^gAzXHnjR)k ze-&qF4)P>n_%X2x$ZF~R!slKml) z{u@S7My8H3_6MVQ^E0vM`VYclMu7sLyeTs%gv6g!99wgTapMV2XoC(q%qgCLAxuR> z71U$DS>g~YyP=7#I{oQ)^d>sk6$&#c)1;9ePS{ROlzYNTbN!V6wEE2Mryc>Y_cN)a zC34y9sDahcpA6iJnvjOo$~({CPW zEeCh(w@kLZGzcxyC+gw|IIm-i?8hD+Ny?VE7)FcQS6w6sFdmTnbpwuK`dLNeFrU>h(E zhwqUH?+(`3|MAGhSGuj!9ZA z<4Gsg&-gE)$NwXFHJ`Y7asKeJ5`V}e6#w^;!6)y|LI6@V&H%W}==`R#INp2)g=^3p z9!l9lX40*hK=N7XDQhf|eU18#OVCCV3u3uqnaoXO9@etfiz3fywbo}MnjO&?#b|BC zlWkw}^u61>tZ8<{m@oOy*S|V`KqJ3BPd?wTICnstV7`-_1Xp;OVB`nX^^&D%?v##& zF^zAZAOMWH$i^}W^)tK}vL09nv#aD0?fsise0Gs9?IFQl8UvKK($IM(p~`%iBW2`# z@v~B6drfp(Xm{MXf{eycjJLOi5sVQpCRm0^PH2V*2ej26>I6R`B16kFToMDAgDg6K z+8D?1JACM!;pg%-K;#{K@oz{c>O%ZH8q1rdvEgF&4!H^D;ETArx%-O|#He@v*f~P;%Jd zDu9cOc{leJ>Jv3r>G4#x*4aZ%G}VKaUhMi+b`yRU)AZpKOB(k(&)jwX5zst>~5kpTAk-o3uLf%;+opyl=G{@x7$GcugM;Xu$NwIyh)Z zGt_B3RZc{M;6aZ=3X4o*@Qb>UACNV^XMm(uVk3s9nagfOL0vNgx2Xm3VI9gylT+#E zmimQzCr#}Zx{~tJmO`6#kT8C7*=gQV3&)bi&TooOBZtZ3bGuGHY9S)Rch3R^$Kz#q zK>^}Pc3}(*dvXAMmEB9|f@!7sK?hH_bydev7=M1}JPH+$NSMi~W)S8e@(!wg3qW-e zHp5S(Z%>~?_OWn#p5hjs1{7HkjR^UgjC@*tZ0rlzMlS;BBprbPCoV(NJ2?uVs2%$c z8XMJ1Y6LqQ{f-pd`a^3Z*-K`G=GdGsif-#_YmCJ|NYm_4IbU~s%4A_)27Ec?PIcuK z+h0Lgdi({vj^b7pyVvAQy^Cab3GmM7GxE|Bu`~8U?@zh^%yP`6h$)ceuQWtx_JNF! zUAL#m@)ffR{_y8=A|xYoDGGZ_BU+%hBwfQ#iSFr0EGa}aI88gp<2<~~`JLuxVEB~xSx46LoAo(y+2IM)XBrS)Ws6VgI z%l?A(oorlg#2oTo%#T^ueMizR;GCi2-wyDA+YRP|2PQ03$K7xKx~+9j@?T<=Mpz_h zQNo#>KljfB&kVl930c)!j-R8Nhl3*xAoWToTUQzml+Y^{1?0BI51}lYpjgYjr#mK-Cmv_riJ@PygEiin!S}DfXcdOdW%O|j zsejSxiH>p%R_*X?7H)Mv^AwjnosvB{@oZNI4ZNK@`j&2nD(g@~c%|9ux} z5xwW26^hf%ubYxFnhrl&bj|qV#dlr`O1MCYD3I~me?Z)786#Pb( zQ$x8DRsXB$d;{wm)t<{Q5t&6ytL>J!gV;%akz1pC0T?7-&6L9VefQHtUwuL9Kkywu zMmRou^Ngnlqm`kqla(EByAGQ1X-gwO=GKy|)cZ&9L3m@637X$G{Qg1ex{&GyyF&zP z4KA9IM)*@XFps-+r^|LumVgcbkdYHjc^=m8&bq{y@Y>x-Q0hpWVfS7xtg6UJq&=4> z+eleFjBpRwi)pg?d#gzZ^IcqQFAir~xZKMkUr@~+q)G13N^IaqBQ*~E%Pxi%l`bMl z7`8qizR}yHqP^zme@svdu|7cf-=P0f%@6{Zz8LsH!}bW37o&Q>jmbh`c8krg|)5FwUY2*=k=;+1LE^zf3-m^Shny#~#gi zOG02V9o76U-e~x`rhq3aZb9UJ{_E<0^4Aq`vYqw$z9aYrc1zL&n~$mtjtaa)gwuz$ zt;B>Kpon7%Oy#AjunkDsNC~nAXN9o8ePBBloVv3d-gdTYO;^Irp<7e;S?-57BgWM@{u$1W`3R}j` ziXF$>YD_^20)LS90~KD>URvPR&wmOs#+gYEpj>LA+Qo0vum>>fxnf}@nS1%Y}n`^I&SDt6m9OkXspb09@|Buj?OhDXG*tC zqr{Q9mo3mbv8Yk4)(@ac=)^yum2!_tg_pI!9ejk1+n;WP3Dv{lva)iQ?UKmV*QX^3 za^QAn%(2E9X8<0&S03pp7B0;kL^*R@;w&^*9zsNy3gjvnGIGQ27r@S=Bj`0K)+_1Q z6V}_BR{XBJ@g<9kVis$rObx;(Pz|`&#)8wB*Z;N`k*{Fy9V;g&)mH{gxCeH$C$h0q zTCxHj&h!NT`2@D7Hlg|i7ROP#`6`7~dI6V#XFjcDECCv+YfDHXQ~Ks@(4d?73#M6e z3QXWHXck!R5@|j$tahq=1T1+*!g^-cHU5;ya+7vfZi475Z?;~}Os6<_l+xR$_RMsL zc^FvI7twGS$rx9R?UHII)R4eR3M&l5+f}wlrbN;E(#^eJJ_lRU>fXcAyoe2#y~qup zw3qC|`T{EUX?<(uca&{mP<3?h)IVn9!8CUMKVTA+wS_;xVD~B3JZbGM+Bp`j672 zdlid728umsR2Wsl&^vs#_%iNykhb-H$IYQp9{@InUt&?bYs_y)i&-S3t=F+XDdr6p z@h7V2_=RIr+3y?g)?0HfllE?Vn$->x?JMOkEL1?&TCf=XINFlvN7_P6)S>Ul%nWA(+s+Yqc@HVDyn-X7`kC7&T3p? zhX5cKs=)$1FMmeomb-RhBeQ~_+Ojk!Jg2=fQl=_PIqyG*s@;HRYV%gsg%}%q@!* zAU{jr=#!NJ=;QtnS*pWwiOfFKoS<$-VjsgHIVHNLYuUV<;P&y2@eeiRXKX-7nlK$B zj`RPJyI{&f8hYl6*OdN}YM=)<4A}sG{x$xMBo8A&$lRjYkEISS5IPA1UdJEsofPyI z7hbOFbVn%1XH>enm1bxX=B(NI@xR1dmHZWm^*={JLCF8m)_3P1lVj#k0kO&Uim1X! zqm~5^8wWD7d0@UE=+=mP#V{}=&{FDh=%U~#I|=mJM^b;1I;$xBY2?W$dTv2q3SwA2 zVN=Yr(kWP*SpVgHUR^)V?ilt0=Yvf0{76`LjX{o+jD*C4C853K_V_ZuSro_k$eYUh zlOSfXupr|Iy%dU2x$J-U1C*{J0LN*zkSk9s5G$-(kL=f*aE;1}(Vg;DaqMl>&)p~e z#K9t-mHuP2|6m_ym_e_qTv6l;sm@~=d23OtnNAr~J+X!ini1=BYq@JbYhC}=cO!Pq z!#q3O{98eI#?y&5qvLY(g*ejLwAGQ1Cv3L6h#pl`u$$e8{fKUT2aH>L@F~zmWI1f~ zzm+6g%9y`iS{pjxFz8 zE;>J0cRlrgCaDw~6c?vEzlv*f>~%Z6mQ=^&lPzP;hg!vop(O}Cu72zBi0Zrp1XMwo8Kw89i*)S00Xw!}70<9dq`pF!8LtKKWq@Q5OW`IIQLBEx zsuPV4liG2_U48;l+}0%xd~4#pS&>NigI5my&~^E*iN67C{unRiqJS!#0zEb}@u694B%uuXq3&he@C&AIpbhsRN^~ z5QZ>M5MQEulmi-vPH03SBM#=%3`)022!U&i%4{JZ@&;z90h68}QYRk+WtUQcSQnbk zA|L_>WD4_Gza!DL0n}*1Dmgh&e(t( zsJ$GP?UG%%?V5ZHMXL00SC;J}BjTB5l#^fHUWAA@C((qvd(x%;Od*JOA82{L>6(h+ zw{Wv3-d-Fb@wXq!6o42_Lc~2(K0s2qV(2a2Y~Jx6D~kZ`z=>8M?9`VxxaR?E^**Ne zBe{VecSv3R+bruKEK8}kf9|;ASUaHHh_`3ru#Q^l043N!gDF@WT=Vu3$slE}zL%Gq zYi^5~ZEb6ZlZ9=LH3{of(G4CBK^E%y2ft!-g-y@9o!d-L%G;wm@S(t()z(t&CpC%Y z1|4e-g#VtTPY~i_VzRR9=U};t+~=<7%^z~_8%y|t#ZwrxJK=X;DxSJbXJ#-Kjkk_%i`UiR zDQe4WY{uKN6}+6v2`V$PZZ)yJV#{1f38|TPx!ol8$XQVZ?~pJG zRA80Qq`zct^f5GcQEP)}Q3kBA|1~Xbo+6%R@wy>s+^W^Pr#{;Qp`d}w$!l&|iZq}< z*{)hQHG}_E7xIX%^VaDwNDPglU-e8TzIv9D>&lN*PYLJZJDHQw&SrVWJ-zUZ>n&3~ z4-yQg9Tp-!4<8p$?a+n@@8&9WZ_zK4DxzLUB@TYiay%v`ySf0ffCp$~aU(XhISZkr z7m-Lq(O#v9_DGoDTG#9Mi1VC$35)q{)+KjMxN2YLQ;eIF0&XC}_x5cTSq+Pr@S~8I zbYx%G&-F$jap5gpW9;v#9`6m* zSp=in*a9J)QbAb-Rh$%5u9+0JCUqTEgZ$(*`@Odb{dyIdI0kP^dvPHNMr|sOoYZQ?uPcrd1d^1z}#OAF1}=IO)1FPES6+ zc<~S6VZu8~bilD3)~GTzK>1GdLw;mO{R8wnrM_-9CITQ9pUrX^IR@=F5$PpRRu;o7 zswf##T}RF75}afls#$QTwz8t|>qF&aF?rILi_18pEc>}?X!Ax`OHI2PW#y&E(R?e& z$HF~j6eqFSyP>3`w&27~rRnomXj>;wV87xJW3+cK3voP zq&*OQBAenpa>@(P=a?X^y%5v~8=M~LKwO~^W(zGa?wbQH%I1yr!ogxIU7;P3=320* zK;i(R7|knda3ZrNuXr}$gEG71Q@FKpo6zP(%Cg`Ga3MHOV7k`5>~A%JPzJ)TQJjT*VYwoUCNmi zLm|^Vr$LBOcxMmMO*#`z#-uM~zEDdfcl^0PaW)4M+m*TR;b|?A?+n~iM02oBs?yC! z7sO#d$m>xBITK8RVTX?1-$+lpeAmOUXx|}D#sN>V&QRf@vSnvKkq#P{Qqt92qLqLU z+24idf?z80{mIJYi@h3|MQ#&ye6c@`5L1Ehl ztHnDNsX-E!oRD_H59oQhd(=3w5tm1peFnY&&Lo3sd8madtF-ZJ&Upr*GSVTx>hf{W zeK-=Zz1vc3yB-8`jBU1P`tTE{=X!vaL(XvP#MC)C`kRs@ObW}7)+i`T+npc-25{QW zl1D+J+vtL@89omB{4k1wzB@~dY_E%kd zbd-+(>JLEz6Yya7&h<9p{pZBwLGEP#{?P%~-W!c51UUCoP29s)Cy4zW#6%1~X0()o zkU~N{tCrlKsR-R1-C;d=RC^UEXi`{JGdV17J}FOhl}(ax@F;``lpKK`J+7(gD8}WKB(RR#Gm1=V(N_v_8)QJ@)T8kLA zTxgTGWbKp)vwrAgT3FQ`~(g(=L?7DBzyEh-+TWXl)q#i4NLfgJ;(n)0U|)j#Pj6Ieb#tT zM0<2UWbQ)mWc(u!F?aiU=kRlN{c*QC{(S6aZSD&C z|Gt;}VvP+9(}ZFZ1@hmVtgFE-O2i_nq+XHa`UhBoylj z7H#g6OTVY>6W(`^`->e#0PrnGk1|utv$=1+5!655_97!-PjtF{0!qQds&|-$7peZy zu?Hgn^dx`!Fbtta=WQeip}>_$D`c=&U*zmdJ%wq`#gAP;bOaNwKgBv@8!uQ8Mc*_5 z7TAPYc9m*X%c4*=77Diw51c8u}(B52$;!L$)>4ISuwva=pLE{YhG^ zay?qP#aA23meQJuiOtoT`h8Ai;IT{@f(5D7JVloo!$V0LG7^gOXn&IAg^jFX7HXXV z?WO3GBm2VR%zBzaFfIQ|^}sJ%h5tqc2vf6@j3*zk1r;10a?vfL9!fM>Riew#TE@W{ zjQM2t$l?sd#e{c1LT|G60A$8dK1A$coq8W(qDdh6M z)sctUO8)-wLEd^L|0^f%?(3F`m1)_=>@4EGO`Y-#H)%`8Jopm*2E)EGHn`#i5a+dK zw#2G?)^^u*y{Ip*tP|s5`75gkfpKBrL}GX)z;~r z`TWXK(Ti)nd|jXF)@h!*WENipQ20S~H|{df>m^z`riT z#FA5Li0aYmh}zL8RciBXKVO!R$Z#Z~PqF+MjiVF}p+>4!DxrpKqWoh3hi~PdVp3Um z$HFQf4?Cw1MALt;1lxkHVNysRnCJ|7gC=hAF|3^f_qcuAijk?8bcJ{9i11zrK%Gh6 zy}!-Ub(+Q6cWJBCl6H;I7`P+Sr0=!#}NO8I0h&1 zljwawtU@pTD4AjxZK6(1ZN>g=9^>S(?F=;e>qS`2pGWQrdM( z#1N>iJ$aOj*z=0{fz9x{eb$U8<8Sd~c=o>>*Pf={%`b)HXMq0~c;Dm;MT|Wc<$*62 zaJH>DVVaIJup{hQA=Y-}E)s({;1-4}(28+u0>)Zg$_GkwAiFLq z6nUVMYdo?aP1N9T4+Vs*gm4(WFvq`EhhrENp3>U#)n&E$mDr_hi+CBUoW&;YWd@zh zkge1*(~^>c~!>`N6J&a*fvZ%A?CyU2lVp!$lmM-aNf-^LE~x z=~+8LHPOyM{Kpt9G0;h)$#RvtiF(^mG_!m87kqYPb$O^MgDx4|sBQT|Z=xN3a{bN413h|ikq($0=03sWKx+9iFoq~J| z?Mbz+f_s%3WaYRt6p(i>cl_cU1Whk4mFSKJIC1O?=0PUM7t}el8_Ns(ySP&9l3Y%< zyr{y(Ko>)2?z0QMP))^DT+jA2MqHaMtF9;?5`5CT@4%xupBXwG>#Jf0oytxLTcv!O zwRl`Izx4s@mU|JR4dr45; zZA%dEz{8AXN%_Ft`Vp;8=9jqbPRye`C5+`F2Yomb>;*6n;0?A^K?Ce$Hjkf}xcFDp z7P_1kq)~-Ai;qO7bX9*gtNN4PIQhH+dP|u(yQrxCosPcnUSNm)e7m-2tM-P;Dz**- z?bzfR1^L79==X&Q?3Pj8E%j}a>t8mw4iYM=uDxFkHU_J=eBbSDcNh8K%KbF;0(#)> zgh)g8E5BYy0jPKxN5^>R+5cuxKge@F6|S#FfmVa{J_9Mhxbvj>!cx3~`d&w^d?JkY zCD{bUWW6O7YKh5=eZFyY$o~#&z!*dKlCUJdK=%i-QvuU$|9U$CEd)>8Z-*t0+x!z% z0Lp(lqVd)ZTJ&&6Rw8ZFB^8_VV2p;`CH9>a!uWxK0|3FZ=0%v>F`j@qS(t$;2~O!m z@|sAlE=q7|q~UnxF-W=1(%&RcSZ(*j zlGID!pTJRJGV&_`p=wiG4{F%ur@C#!g5gshEIU z@)kz)8B35sSP6U=_s3!DSca*!?S{dGZRH>R0APG3Cw{Io9S_NY|2D?6iNuIcI7k8t zms|>wnhcIohR7h+M}JTxldK9P&TXXNP&j-c(28}|a5Uv&3|SDL$5w?;{(u$!l!!+i z$t4qqydKq|pC@A!<7wM7A@~`|uHyz=b^ko8r_jA40up~4Vj~`AOdHW z=M%`1cYRO*EifKO9TB#{1_e}5Q9+auS4z4IU`gkDdR{(u-T0 z7ib+;q6qE{M=`BiPjjd)aC?LlU!cmItKc!RpU@MterhHc6FuDK^cZ?!8tnoC3RcGq zFR{-9$lm@}un`%$#V^^PVBKEmpWYii*(nkwdFmG#fWMwzNeSsD0=_Rc(H`+AX&f~~{HqK- zSRvdNKzeIT+$Ah~nl@3+AeQW*hF*J;LS0@vEjGwxXxv9{S5D2FL}{BshRvSCfhI>Ui~s!q%D8a$@#pb0*kd$nMu=gL0k%A zr>m4&f`kzh21YxU%LPttpn$78!*mixQ!$`H9b9ZDEF-4egm=OI5R4@rY6L5gkttx0Dsfn_OyB2zhE% zspAfJp|j^{!MG8T_p8%rCY*WxMFLO+W#DN4c4yve$}f!eKc#=Tu=MY>(rm5_mUUyC zQ%v-5ZLTX?$At3>p~Zr^DZ1vr(zhyW&^}$GJ734^vLnDJyCA|}KR~GkT8VlCAdF+x zCUfFG;&&7MWm5zb?0ba1zO`KHyoJv#`MR;<1u{fTEF~U$ns4_6vi=0xepE_t?fDWF zU(6NPn6pAPvO})1gTA5*1=4LO2eYS?RHJZ$-4YY7c37Mc2M$N>!yh;!wgz2e1moiK z4_wzJ%Oa`i+tYZs!-@oX)UFe(3i3Bvk0%@HBqDV%>Zi&gz2_W(;Os7Lq_sGcRYRX1**eJRRQut+o*YSvYS7#3_SX$G9i6)fQP1MM{IJ z*1O4Fq#KW^b@}8(+l(V~3@VK$vsY1gFSpbHqAs))|Jbc9m-yv@hb`eta((HCB{q0H z-?50gj8~!XmBG<&KFaF~9Aq(QE2MP6cuwl^$RNf_117DR%x=0L>|YOKZ@fe*CPmg< z#*bpd57WQvAsv}w;3=Y+%O z#5y)Tv)ymG-Ht@i3 zfpPYSBjjO!16ZY75q{Zr*gK#7J6b*u#y7lY@oc*iH3(pVdC)68gD;U3dPI}fpZf{z zEeUUyEVgE|lxu~Yrt=pz`K<=u&Gtkh%$rvfT{CEeJCbh7z@9W^2qwzzXKVWFc5Yh>H8`Nwyk&Y)PgTrk{rZLx}ToUIvZh~WMF!QsXOU~+qJ{$>EiI_yB2T z0y6Ty^0{N8+@?xnYgADj$g|oDB9V4b|zZ zF5`1g_0HN&?gax5xxqgJPOb}vpYHIQU0a2}vo_2UR;ulD_EM1S*lf(VddkwO$RdyF z2-UJ0c-AMYgC-}EL3gss%@*4Hr=(!8-^zu zS0W0Qba0?)+HPu_)O;RrlZP)M#&f#AdQRtvb#;?pCC}SzN-Hx{<>njUo02Y4gBP5D)o_<+}Da6E&sfUCAHlmSm|1ATKKqkIeOKHR*Ec+Dz6`&K@C8)09suo{Vx6W z;z$N(aK~xCku0|U!t&)H4_0FxW9@hUHBE@t7+j&U6ca)4Je7mJM$aCO2r|yzYadMz zz$n|l_XE#*NjYEzC%0oB`gl>h1+upr2`&5U7p1(4~Jcc=djXo$Rf8)6eZB+`aGlI%Ek@~sgP*Y;Y%Vt5T} z_LmYG+(Hb|r@_Gd)^$DwPdIToinO`73P9jZ=l!xRlRVC=m86I(8>-({6+eGe9G4S**PQS z?QwYYUW`oOQt{V-cWNq-){z#tC{$>xMy8c8a1r87FZrJloV#egybtCzKlKIx{(SeA z%i@pHm@M(aD^CuSAS-uSql*bGxiEFfcj`7HE>x2x2@)qrm3kn51U>a-l%vm3Y(UTXr93;@%vF111zO7~ZRE*?9k zswIxKwvl0VNC@mrJV@XYDI+BUvCJyKkkTy7==|FNdi@q^%yGh_;6?Ku$KBFrsW*!E zkKva|O91Tk>Yvk%_K;_51M!F+qqG57B87kLWN(LbNlOT`P^yVYVtHK<^Wc`wkcD|O z`=-Y5P@<~;qbWh-_k3?qYEzjE zk0Oasw-UziVlE9SmzT?2iZ2d%F2FzE_4qyI5D6Im?u%wje=+j?{~CJ>s5+Jlen(mtF z+C5d(w?3pfVhw06RL1#50H5zZlN&%UsT0pky2ilR3%3xB{Y>imDF!U` zeFE|%d66W)QgT`mHYP4@CA=t{fa0)YbMRp$Kh*Y3$8=zYbZa*D*O`m!Uo7ouN z>(AU2P3Fxeqb0cpu0<#c_D?%~eN70rfZ;bGAAhqY_j!n`%eW8%&~u0fk*RNA2dJxB z-k?EUz+6c-Q=tm?wpyiKF8jo9&>uX{DZ%7&M-!q$tZL0&QcOI}ZlKamn;Qqm zh%$U{Q|r@G%WP1;VR`aP1ax%ek+4}amC0$3+2M0Imatb{jc?-->h-wpr`gC;^Y3j> zo16hCPG9`b*$JaQCo2wCUY$dI-{}mer9ORY1hcP_7)Dq*3*NFAkOt+zZH?T>I&p`` z*n7XC6}>~G7je+Ict-D@)+Z0i67vN0r(5N7qB1@STCt->0|OKKrF4N2gn%)vBhZ>1 z=1V*GSm6w~Yy!Q6o+oO;VC{ z+({U3cFZVP91YOh4x?LS!~pDp;BcYQ5{rxl5W4qCkw!HT)Qpn&iONyl-)B-bhfDh% zpeWeiEz+A1ob6=%Fk6j=!M*H)C4M(}3%?o|=IQySyTIoO*HFYDfTl*j8z}dX6b#Cw zpvct{uQE<+ud+>*ftx@xJ%xhxbxSX{b1&o^m?8>DVIEOvptNO5m(-jo;;1kY{ zzWHE$%wyG1uo6WxZAgcDO9jiwk5r~8zKC^6SOXS8L(UcuFFj1C>Ff$Of$_Ooj0F-Z zJ?bkzc#b;JyoyyrU7dA3cg5#IeTunhGhXJk5iEzfc2)vogSTY+BkSk}pIaGK1)RRv)dU-%4uFm#b}fOlqo$p_C;%nwg-MBX~XCob&b2v{3GRv+wGA^%$uV z;g?Kn%SuLTHnAb*twIkPAJovabVN+j>VYlZLHoOmbd~l^Jsf~o+AtM<@3K)@zG1x) zqRG?GqGhz=2D}_Oo<~5OQ&I-XHd5oTU>s<_A1?=bjwfaY1}QfB!z1hlI-c6&Bk)x{ zRt}_LcRE%ARfhHsMjx*o()oxm^&*nnPB2q5KAyR)%r`hjT+hkEGxwRn=UZw_1{T4m zE&-qR-ggGvvjiL&?}bl7lKNeQK~XjT&f@RRVsD2XUpJZ-PLBC)HPS~Kc<(N zCpM<(s4{yl*sWF!$kczhg#Xwm+x2mgsitmC?WT*&T{lIJXo}Evupu-iI9rKFy8&M$ z*$vn`ejeI2!_u(YuiX&_JU>N*4@(X5;%)Q=gCJjM&&`x1#h4!&P;{& zA@I&n-Bp@y2^qnvXZYJGUZ*w(R3Z`vyLt7-rJ3-7=;>FaK8zT$p5H8U6ys|BKD zwI6L6gg%XLa%ONucvp`@)l-9q_s+vqZrKzu@#!zJKOYCZ?c~Kj9FW5+_voD}i!8}( zoXNd*1EjmID>$R7DY`IsVZqM4-2swnqM6b>-P0AcbJNG>Q+gp5JxQVQ2i94hz;;jd zQ^e=XOQA-L(-?g>j*}v&L?8oy$1>bYtQNVzrq~vMUyFhh{-i~|-a8ju-+d5m?ZC8| zrKP%+rL};5-n*RQMKjOy=8mX|dGd@Yv0F=M3cxn#UAu`(|L`$sk|%Y}5dR5%YrUHF z2krO+Gk56l=uPIMHUEdcer5GQ*IvsrvTdznZDmlVs0U6W@9Xr=I2}h++ih`|$T&fS za^E9u1g{$S1)$x>BSNtuXoMplNPb9L9*<>6S?O%f(0VDh9I7~=Gi#*wfYRE8f$+hk zDS*nn{`9qYsahd$R;thm=583O62xOOtNI+NFszKbn4qZ7-028Dsx# z=3Wdvq!-5zOeE=YwP6%o_SHz@S>_5TOQH5Or?b-!GPN%;7Rre~agauap4L@I3Z7?f zDLOIIF=;2&Y%-H+!s!iRBW|eS{go`UzG{`%DhFP4iCAjgduPA`mwsq-It4*r=&tJu zqusJb?J;MD-mG;BD`e*oMLboc(*k;mdrn^BDNMYsNw|6z{~kvnqNdIml4{2`0e4nr z6<@}x!Opabmj~|kx=zyBp!0|;(-ds{NFAES=ShL6s%*8lEkh!9sf98uO;XBrpWS^j zaBP|J`_vT^S5yFYS!Baz;(PA9)IGTr$fWbFq%E(1eDxbfV~AZuN#LpY>V*@G6iBlBZ9OZbg zLgO_)th{o`WM_?IgT!LQZ3>rtb!H?OD-rtrIVIfqf%3@=kSgQ}iAU|#7-W2m+XIK6 zRkQj8J^f^5zf=sbI-KpH>(Yr;SxhGwD#v1BdU%>#c!w_}^B$klD!deKYKYZJQ95c8 zKtFMdI)vj#-Xi`!cvN&>`D(Qr%o`<3CNr;o>IWh_G!<)odjFQ+rf0zU$wejM*ae_{Pn zB&ynkEk>MZ#%Z}oyf~g`nofk}#%(ig-#Cx7rlpX-rYspK6%XS0JU<`Hq zL%kbong)|J>;7MBV`@;c!sQ?x?gi=af1J4IlvhCCd2B$X`ikAmuM}m+?Iy}3ZoOfu zLOS_1+2z-C?GAKU5?Fbp&=U0a1Orery;bwbA&OfF9$Vm)@ZoQLz(_(;<<}DFMVYZ! z*0a*;527v1K|0;>0;bg4&Wk=+HpxowzM$q6T{dW<7ZM8x4W!4EW1uY!H_;GP?s5$c z-r~#vtX3s6u9yy{5Gu3GLRWK*22Rz$D>gffEgzGNqKnUZTQxVQ5g#J!{m6|!G#hDX z6F@|{?z%AgehDvbI#;G#Cv&uD$@Q_=qyHn)6=D)$5D&ec*7R)_S{k$X!%%|--|a9m zPKVCY4sqXS*A?!*_d%^yGEE_07@gbG4)s;#0nZKZ6RFyK5qp$6zD0KKhrDY18d68Y z*xJGQoJlq+p-;VvRhYti8)`y=N*PP(-HD^`&@VM}QW8^plWPA1OJzhs7I#0LD#m5u z$f~TAEFvj%CUs^qP8HwoomN*{sIHb$O{p>52%>X9T1DH1T&+3W0z+fDU#fA<2i@{D zb3iIT@qu4^X@E`%9iQdxzT%>4=n$Z+GO!iVq{}%w!*;@P)Cr{}6As_!7{7Xsxa|_E z`a*K#al}-;rL1MPY-^QOZeeU}Y?6sssJ}f4qxHt{?A+11e!a-esmg|xIWcddNRnh4 z_0^saWLZ+C$5k*|9vMDZ4_@8dVAz@TX#kB|m&+{{nXePhmg2Maq;_OIqZ)T0<%n^( zk5%Lpp3Vg1Y1S+(+5t4;kI^VoSv9qs`@q34@ebO)t}7zJDe-Jt=56#oJZ6fW>yzB- z82aNJH0p9;oh6<(8)WS;W~w^uVrc{G6wP7x#rEYSEA|Xr>O)_OQB7uuTKdG-^50f_ zC*Q)06~tlE8^A8PO;hkneN?cbJoMy7&rgkIK>PkO#U>HXTggG`RG=TV_^&myRI1I< zR*+hkgVdVm*Kd>O6dZt>EvO_err+88lrleIHGGFZ;u?Gq`)kxlSmr54?JuUGy>Vuo z^@Y|lD`sdn{BiiBkX z8<;OFyv(LtXJM7wfEt_Bp%z-^{|7ae(u*I@Cm zUptlNP5z3^1d#=odZ)bKaR;&Jh4`rx8QU$^xJmb`W#rC4pw0s8BP*P56eM(!E#mvo zYJKmN_z>elp-!`@Y_S%~G2!HPrPGFW-%ZXUXZC6zp{bI&BV~G*TC>BJ0@+CB*Whr% zPNqnSMTw@A096hPX&*|e05Y#T2bv$D#?yPK{%EUYLmpB2n<#Hm$eY={BX{|N8&43+ z6c3yIn^aF6DiIE(om<`QuWXPXm$!;M%Z#f2A2-&5yo855k0<$VMg{=hH z!(dW#7X^uI5;@6Q+Ggen0K3MKkt$!awSHT3Righ zN{WBkQ&3Iqr_fQ{cJ!YpK$V7Wlu=L~W2aS_syNPUpVI)=1ai~Fc;q1zb#aPEDFgD zk!EhopzPtVOG{pgxQF*8tcGHF0P`)K=E(3558-UZ*+{G3_)avQ`Sf(Kh2u{#D~4Hv z);%EUKL(~QcBy4$CV~Nf(~jI_U+gQRWkbiL7#38;d61vsVBVa33wSe)CfR(=d$b~( zjFvK9Rr&S?#hBe{Swrht?V;*3=hz$5G}Dm*jrZHM-1#|!r5{R5(~w_@-Rb8j>!0Ek zifT!7h4p6->;CBL1#f`8R(v3wzoRb55$jRou;z_lMprnlWN@U&e{;zDUbnzyH8s5n zRrZIxX`EWeXjB`TrU8IvdKhdacaDgO4eJhI=myVcwA!t8%1JK9O1xuI4Yq z>qIcb=FS*2M>PAq%rS_tmk<}87Fxrucnr-|+h$c*A6NtqmT|?NBjyNtjy5ia2U!CT zruYpG%pxih`;~Tn|DD7~kkM*@1}Xw*fXIu8K;ldo zfZB@!=$+N`t=HbLW0DTk8)dYpPf{bwy*Z@#)^gFX)U>Z&bgMo%d9+)fAs57LW8AY) zLPSst`=1wrYKEZN0L6ggXQSgj*JGn+PY>5mH2!>oM-n1(S-qcPIU~iGidYMF@EMWn z`8hE|Xc5>xblYwTRBu&~B^^h>_V>jH;c@|E?XJWvV)l4*9wRZh(dub6aN@@Z z8}8ka0|kS5s@8n4)*4K^>Jzm#6QAit+sB{Er7zRN5-|1^`c=V?(lJDw3ql7(0M!@# zd(EjVF7%%OO#8QCnr&o9mQ54!kwN>lp)iOn6Kd}6er8JqdovZNW@P)gP8Ja+hdT|Y zZre0zwB6i<~db2OoP3*H+z5?vsuNt@FK6j;anMe%#RR z8xyuW?=XetAp@D(N*E>xkOr#J*A$R#i#5h(r!Vc^uqsTj$SZ2*xv8*NeZXQW=AyF= z%8h!v{K^VW2WC{9a`ecz)ZCH8Mllq5h!xA|n^$U}LCWj&GF<8(9vp}iO#sBfEa{$I z1xgi5#Tm<@h(ct>^o?9|Ak5+Sz*-Ve0(}DddzK|UCYZ_2ci#?k&u6=s`)DXjhy(gK zhk7ix8$9%iI$xPxRZ5ajQ=dsh`Ad9skV}VQb&LCGO^!zspD14#UHT}=;E{Y)4zZNU zOf0q}Koq;dzG~A#$n&P>&BwQ7_;$mg;rhrxX;YP^kTPy_wIe&JLke>YbRAKHVF<}R zA58okBE3Kbjyyx^8=OC_l!d5cT@pyAJ)r;gFOe4s0aGgoudU}cbe3{o{beZQgF)Nt{D`TK03ch(QWY(B<>jQO z$bQCM$DONrr;qmw+81&Smv1JLVazy4o*->j8y(t92=#21pP#MC&FhWVYt$D2rQ}?N z=)d|5*gn-o6z5v`oZS$6%lOi~=uFF@ca7h@gw?^c!&?>#~p!2|fIzd2g?zUN5N$Va8}G_Bsll zCie??hiOnSx?8}jBSoP#9n=)nC1rLR7!5ulq~~?o@#v#96e52Gyqkfy9eAW$_qsbF zPy;ziOr6HID^8!}G+RAA2mJ$*0;m|RJw|JmfF=@98godDJT>A!dXGEMFoaO+b_xTO z0j9Xqo!91CPZJ0(MOspfp$T7S-=!WuDNa&3GB)B7znKWNTQ;rc(8_f$2{QIKD1&DX zDYA?w2QpG*q_%?rq>u+tD`nDl;iq>Ah`{WOf*FBVyF z_?4wgs)q%~c<60eLclXR^!Gm?XK_v-01@PQKwJ9!H!%Z8_2AiGNEOLPxa6*dcbj9; zNxq;fN(83}$eV~ww}z~s4Kb~f#bGp4MQ<_Bjgp6X@0RTbDA2^n-(tP`TH)mt)sGi9 zpiR{##?7Vs2xS}VcW`L^A^}tj6VvGjIwo+{31TPdvse~GHtpto@s*qE2!ufs<_rr< z$m*A`Gf6s^0`J+Cm-c9hE2hg0uRZ@$d5z@#)S*Di+XQ;8!v1TD^;`r^uc{mN(@LO7 zc{)r4XaAL1J}fjIT!OiFyl854dAULJz@?sb=E)l7z4=u)$rll@*Es%=pKMK)>l4J8;s&^nJ>D4)hy}%aZ^^KD$qaI)4?xuu zMC*0Gp8%e1eK*}KM|rmia4w!k2<;ZZ9-g`6w2~|_kfv?Jp?6UiK}VyBIxlolU%)Xw zyc%(1Q8KZp=pA&=$~)&B1mo#XMPzf%taPzu!fKRLvNL0Bj{}~5|K6W z(p%Jre8Rrp;E(CZ)hE10PhV^e;X=iz+EI}6!O6&^S#4EYh&1V_W>|SkR}frRNO!J? zkWPCtD(iPo8Qox7`hBB&uXrl%aCwd8Q9T7Y^7DN(M1x>&O7wTe4Gl~FNOve-5*cy+U zi}ec&ZXdxlj1F1rCsa#!XrxWoKgrvRevu|Jvr(ATxjy0wxJXpZ-EZMFD^gizT^(@h zVZWDDFwn7{<^p(_P?h1+`3M%dXMVKUaC_lu10f_2=y4O$*ZUBK=}Y3fV`LY1{53j|hNNcYo7z{y zj+-RHyxQ`mYtnrZFOq>d?yu`#^XZXiFOhwALtO@lCuMqlDP@bgk8r#ESXJbaoJ>6( zwB>J|g-Pj)HWF<`kw!qK7{jz`SAn|_Z2wr9}GYgw`X3iL5RoLf$Er_BP2*F^ZgMrzA(v+xwE{Fo>;z|D6=p#$p z%1Ey-;rVTP8jqJ(gV|D@kde(6bWKCHbaq^uaUq#j!f$r63`HBr+SYfdRj#^^B>}L%fJbC3i)4 zbys!QxrI5!Y9jAjRDW&2l^QYsW$pWCO|H&`TI?Ll`ygZ%;i*fy=Thef4y2coEyeHm zNWu$wI|xLc_bNTcpt$e@AHuFFpOXQ$Il_ftOwFi!Dyae7p}F1ylQxg1ZPiY$xqB>$ z>{$K$A5?~8xaD|x=8_L)n63#Boze4!N&_xF-C1VQ)F!S;n;4w{%-Ro*<#^S+)TPw9 zP??3h*%j##Zxy4?lY9h0%f*|e)Iir+>00WN<5UJ!&b0C&PNHkGmRKD=dFAoR!Z!o& zyhPu+&Xy(P)6Ep>4LDI<*rx4%fcKK1pL?5ZSU$(hq3+zmT)&IRm0mfN=~Iz{-%_6D(~{zV9ziGElRox<8m=(Ms~%G6WK?xyTBudmD4o*w z_Y=;}xkBLSMtH z#aVzdapag!f~A&|pLZT`R;+0dWnDIVfDly{rzJj^zkRP?v8I#H+Yof~mju8#eE6x;xyXV;I*H+crsZ(<`621p2E9)*53CZ9U zOqUV_>K6`K!+(|vj5xz((o1M=`SOg1EV=yA7W`UM!sK-BwARRavBC~6tej{?BZR!T z^5jt>iH{!_S<0O|*e$IxwiRmlfo|(n~2A*ySEOf}ne5S682P4KpKLKF&8>#Tb5}%NkhWfIKM%frB zixeAt8+)q;sHjRVd1R{HM#RTLKIV7fG7N5?Q->y($WwI_CDkXcRU%RB1$tB#FDs00 z<0TacFaZZi+Z+^?(YC~LLP4bI(VTlqI5$+3lQ#J?LjAi zL2H16C!K0V6$I5BQf&vPGxlgrV29Wmfwz{JH3Trs5>{v^$q$6bK}JWLfXbxi5Dn#E zx4=e(QQw`thin(Yi=l*Q(xM$U*)IHv=FQ*LI!tU z$&{-&A+AR1BCh%qAgxwNZgic#DGgv6hwB$;ilJr-{S;}n>D{ELB#3!}oY?h=txz7Y z+#KXm8rrBg7QYZuP`o={4r@5F{i_2U9D0oXecq3`Q&Hp!y)#(EOgfU=@Ot*sEkHHTYj4|0E3 z-ezWHl3^fd;^iahNNYfdI527iuvTo*LJjX8yJ}&#Bvg<-&gzOI8Ckij*!V4DtYo)P zAJddHIc}uVR8_1|@P4ltQy&wNU@dm2R_0~dZCy(xM|lUk(P_`ZuA}%ZfI)&f#m2zr z`+n8Pad(n;U-4|I)7O+b74htNnxk1VlL)H2M6!rbAK?iCmEH)0>7EGpJNE{o`2|20 zB22L2g=7wgAS2N?QxZF23z1c5EW-m&cEXwopKl*>QrD?&;9>l`yz;dUgvkpOOxaE} zfadYqs66cT4xrE|FiSRPfNxh*JGW55TgS+%>8`#{6Z8c`e0hc6-neRQLaFDLe;sHQ z$!MRiUB7ew46Zfd4B-AZSucZ=DTV76%^B zInK9c6zgu96y+I}txWl>wO?ng(E3$*xjh7Ggrf84ndRid7T^NN)4d*3n#zUi*5qok z@stc3t@CQi9ENeYLD5$c$P98v{*RW0Z1^(7MgbCOoF*aDERFi2BUW~`2iS|vZE1Ld zr?r;u3!(ALC9FbU=RQDSS#X3Zyr&_re@)@}RohzFP6G!S0A0r_efTN6(=cI%Ip^Aup1&5`ABU!_{6hiZjb!L1we_Rz^JTM5 zjJ>*V7t~GN&N*7)7D4){`*&+JC%$c`C2)`7hb>!Sunmqu&+hHEpvrOLR7jNlZG~9G z3}W{!yIal6W?vM#q>7LeEL#KZ@1mCN7w3v_m)dd+i~#T*8*(Xs{L_Bos*RD_u&2B4 z13ZFQ1(T~hmhXi7JRTAjR#DoUPpo#okr_IG^@B%S9u5rd*TM7oE642l(rzMjV_bYQ z{$$kHRwWq{*x8+`V{i6lC91Y{uhze@rW9w#tss|VCj;wiaKEn;NeN^-uN991x!D)l zZJ;+>?K}YI;$|BYT~8rU23u79i*8K~4$#_%+YF5^5~~t>65?^Md2m}OSNHOKgNEU&G+j)V$RO-E}!%1J_RJqmtKu>x=d^VnXx8q@7BV#Tc9CI7yqd`cIn znN*^u@kF{mP5-RohP7~|EsJ(EpD<># z>TY9h#-dQYXfF{SSwf@Sbt@=mgf1t6aa@DU;3&Lu1N#oLVF%({E}rH)iWwse6I%}A zer$kX48rBYVx-{?d7JF;T@6xB0rbEWJ%!P*$EP7W%Mb5dr0Mwg#{ zhcGuo;Ec$J4;l3CDL?zY64>MWHaOCxw%*ILE3QWe_`-FCR|OQ=@!|_5aCS;fp}!^R zvpnAGL2(dSX=TL&C2J50MJvx|N{%6VC$|6=UGUvA0%0yuT_Q}_{NBV0zW+R?bK|@r zXrUIVNq0o%p<6ThU_hm7~{)P9wJ#d6SiD&kdZI(}Wzs3{~W&@!FSDcjrB#+9a}J(uO70wLf=6nkr-`1PPCh zcZFKFNB`JR|7IMUTRZ)VEV|l-wN#P}pen&KzUuxMR2QI?-K`4ov_-Qozg;$FYS`1n z8+XtaaL|!pQT#1SevBq>l5gz#enA%T8?BJ4Rtp{s6XOjB#=`|8lR|c$(98s$YpCiL z-?-8@tHarg%+P}+__a&uVj|`GA1Ef|X~Y}`nFUn1+y+w zOam32Q}?X_=|n=EK@6eI0@j0?H}RJw{|%(f2M6qs)1KXcz5K3`fxB0aAr>UPe5)b! zvkbK?6dQ@{Fgjar6+9q3!P`*|0Q>Y0Y08C2WeEjY8oTOWG<;E! zxD5Kk-dPcjPA9+9u56nObC-ZbOM*@ ztDy^fRBdF{+wz`S9_}T{ixhO;e5X{yjssHR(-Z74?kcT0J2=tnD6B{-VZ=Lg6j2tQs-Fthfnk^vVdJ=wCaBD9zu1YrQ zG}a05XoGmzght|;zqgG#gvCY|N7*qY-&dCVbm1a*=Ey&k(FadxHU==EV&rfS4jI_h z_r-O_Ts}U=s8h}M?Oz}8GZRV2V<#XGj2bo!sCL*ZuM@U;Tb;qWR+jO;z2rpH0J_KO zIuza&$6mQ$RpLi%XU~1J16WTvChw%5$oh7+z3P-ZEKBDe)01Lvy?ca3XAk;%gX|^C zr1S-voy80Xgkg_fQ_O?>>c^ z^FfR`L1Z=D${rhUu)nm54 z+!c5*K0V{2Y$rdpdhzRClcT*d>30*}8eB=%o}Hl*LKkHH+Z5~6B=t%!LPB?N*13vP zp(5K(Rr)a^Y$L)Ju>jHC?}?t?ysq#jQ^srb234^&SJG?rVJeNoWCt3}$rfc-OrBwu z1DlP^u@t4|{tCi?=rY4E-k1^^oE(%T*!P#`_b3PERp!;m3eCKCpk11}GCv6{jo2KF zn?5T(en43GS#${Rq7J4{24mkC3y0tkI!sEhS9U0rq#+|?? z=361+thTJC=+1%q&devQlTOhgiY3FL=Zur;tKExF0***W)n)*qBMl5XgUh|Af2}d$ zN9_p@f!0{TK3U%(%k1Km>Tuh8y|0P|!KQS=qCf#dn4%ngz6 zpj>0kn~5=+)Z&0mjLZ!5*_xMPQ1Yx4mc2AzFUgt9?$v}0x<`!L^0((;8-4i$v3(@_ ztkGP$5qIjhpPN|?aPD}5`wsfXS!Q>lnkZ8Y(ZLKgE|Xb1@I`StG~%jvK%F^0$S-wI_2mpyvr>}e9kImVSGVO4f#Jh%$ODM+K zKORg&B7eCkv?_v`PRSm|heTK!{Q_)JXI0tRpH9q{l{a_lr}NytpiLmSN>OHYYAB#r zBM~-PTK;sssHPUy@D^z>gkjD2mfQ+D5-D16Y=SNgKt4`qm*|&Qv2#k*3#A ztUgIx&B|*`kmAQ|jd(Xs4;!69C+;zEQ5+?EfcTZ}Qcl!mT5qzxz9kvWT2uSLVVojZ zbSw@2Q`_z+oTR8vYtppl%v!zsH8IOq|MpZmkHz`T84WJk*#?i=+}uk3*<>HO)gIzo ziaU2jB|xW1|2$5%NtO&P)`rF<5p<7%=OT9gp!y=WY|%RU`Ay-=yQxnxwR-QnM`0h@ zXU;xzs5vC4OVNf$W1jjiEd~38* zW=Sfq;4-kDlC@9Ac5Osg6cCaOY_er+R9Rj^s|HvWI&x%iVN-6rb2pinQ^X&UY*nalaHo z+898=>^(bRbN(zO%=w5aqH<}{&}}}i{`jUx@sa}?X6=#}^D8~2$s7xbl;%??dJFv* z)dS|5V6Mz=AiiI$dWT&@Dh|BQP->{*<_AImju3t^Yo#&b0p}yUrn=q?Yg|3PU(ANX zCrvq+TWOt%ls0}Z&n^eg#iM)7uSfLXdYy`iWbqF0eBXBtVwpTvhJI}--+Gljb%9(p zpQzhI*jnJmoSek&t74TA-B0qtgilrDllqO)kW#eFc$SqD!(B}oBwuqW@eXprr`Q?` zz=&!|GyKf=Pm9V$r8@P*p5d7*VLP8UKjSN+h#kmV%h2@aO7#dot&V_>~3YjAm9^y!lp1%mxdg+h$G+sffVe($4J}vZN$*(UepFmN)xtj9EQq=JY_% zBfITEm@}C2)K?|eFUct9JLyaY1|+)=lYn9_-L|%8)MHPPXwhMDe|BUMwfF1NtD%Wa zkQgvBN96t!~n8x>9i3Ijr(`Z&cb^z=P zX2&Nkvk8~>?-?G3QW%P-&DKum6m4S=vuQUON>5i30ZRzU2wPiy$b1%8g7~{x}RAx|$zB_l0p6B}!>3wTtXmyeDk={UA-K7YC1V=K5 zl%L*wcpY*1V}f8Wnvd#MkmdxzP{Lpav-s{{BDMPeA8i zfd2^oUmRZI$Sdfdfa9MVB{2S5lw_)z;$~1tARZ_rkmVnSU|@v-MBqShcr2h2I3_10 z8?%Xxv9t5PiLJmHKazl=bSOc+%Jc^@1>|4E`2W+QrSh|HJ_tMw`cV8q6axz)BKrSq z&C6)_J0GZ57D2Auum1guxG=y0?k6EE^!S9U7_v!P#A8&yc8%g zbOMd~x8eR{_q0EFFapsgv4P_XRR1IUAKQ2RL4%?L>{6orAA;cDw;=h0Akz4kP&AX| zzYo)YYxw`WbJER!;r~SO?7@*|*kFooImk0f?^`S-io5e5dlLq4Q(>(oUeL!aOZwmyBoccLbgU-O} zDc;|u{ckBzFff6CnEli-RW}e84>)E+`Oi~kGW3;hlLCC_yx z2u}yf8~Vds{*i_82mU_i7yfV_>v#TtgtY&Ghb;i%`TwUg{~Iy=?-775`qcn#CL*wN z;g3=KBO>*WM!-w|LzDBrGwUCbfPWBOlmoZ&IR8hoKf=NOAb3*=YJ~!*TS)vrME{PT z`J1jVz^?{GM9%(yx3hmNiTq6iIu`~kb^~*}$$_8@grCn=@zT#{Yi}5Ijrs3~F>(3l zHMnsg)w1YMOu)eJ&*s_$FlPDn??d#DqXpvsvsUrnM`1wC6wrEz>~FpxpGN+t>-G0h h7ytqC5&nNUVoI{mph*V|j2rZK44Si~emWOm{|AOYwcP*! delta 48113 zcmY(JV{qU>x8^h9#J25ZV%xTDPi*`rwr$%^Cbn(c$;3`3JNw?fdv`x{_4(3W-BqVf z_j!KL$T-+-3mAl=3>Y|Cd>(o{4k{`d2*~&EARr(@AU&9462ku?jd(#hbmP(f=8RKp zoKx&ur(36QApd6x>9_x^TSNcPoCy96AMJlOAmNmwWrqL(VT4J%=EF{u|Av-Gum}l6 ztLwNgh$H>Qm{2zs{XPuMDQGN7k7aT{V47!-=X&re~nPx)Qt7rDT z%Xhz-(bvuZPh0-%%;uf;jqwdUJGp8XH|zMYN}Z*V<=&Tt=gt5T@CMlh@%v5^#3FPl zu05O;^-ARB$R6hG!4(E!I_SjzosaDouuHZy9)r?y zOqh|3o~;C#FzhE@tHva+h(R{sKLpD?vnHh>ktmBsUL&(xHwEde1nGlia~^d@>Y{bVB7Xlxg+Oex zuhAFUHZc3C>Erf_vlQW>c7x1cBJC|(;F(YyheNyB6o@MPA zNFlSCNvB^#nT2vtT!T4XtEo%go9Tou!+Ezo0B@vMF4;YKrJ9*PkLfMlCZGM(T;9O3 z%ssCSvYh8R#JPSvsXUSXvM{V9$nrC7r92dI<=u%4(`*|U4RX*&XX%W;c;wpIqAO$}Un+qWBJf_n^$7okAsZQ$@vDWn8h-Y1 z)GnKGKGd7Tw{IvHQFfA7JM@%ZNrqQ^*4OwWe)w|o(REXfo({TwyR+5&?1G4#}31NKAo}MglqcR8VIe zGv9b+%7SNrK#!OzgrznI*O|mW5D5=~dnl!$)OFH&+9xcZka+)*neBhrNox0*Yzggn24fJu&^mzD{^h z-p}R)p6h9G&!5|Vhx)ULemPjvo}Lvif=elZ^2yifJ}>-5>f1gkLM8I#h(R?J_$`^) z4=rs6o6Q?;b%#{)hf|D4s;E_d13Q%NSnyB4HE}<)Io8H4qo+}tE6&LS@SoEAf2*`Q zLFrK8zbd`-Z;*l{<}VT?{#=Agoc<0As!f}?@tqLxob={Rd)2=lLG8tX1~UnRzFpha zrFafb3A$Zo7uShrlySri8k;stA&L>vWfAizo;qACp;F;N^!c-p?|-vt6+NJ|^OS zy(TM8rrn63ARvN?ga{%4SkjQ0m7`kSvb3FzUPZ%Vt>q62a1&!;*+gs9g;Hu+!zs5= zxWFU>)BVKmRdH0{m%z9OR2ho2_Eu=S`ya07>2_xe;P2ZLSg$W{OT2hrNMJy4GTP5| z%^_`=3u-!?=agvp%@*WP3!{a8{E)*&BA5r~kT#7?Gs#6$l^=A#VxVN_AN)($$B^q# zG{=jEz5%@&&_+Ti)Zh3n&>QM^4vJT2EnzORyV^96Cu(TWYOtQIv|%bWGD~+l5ga0W z&*;}KL3akDerVF$x&nH6{dKD-UYYan%50Q%g5q#C`t^TvfL%YbCV@=VdWY0+aFyHX z7WQhg2lWHYB71lMNnjVLcLlLNWXb0wv?{YQp6L$-w(0nxA3MvxRnF5=cHDv+9A<=| z`L_-XcF9(V%wTm8 zGCuz{5TcQrXNfLaEq&_}wl@a1FHmA$YC!By@~dw-EgmNo$HaN-Q8>qr7PN zdL7o|j5?C$oLJ);wWy)X8Sv>@u~{1K^s2-K%F@Qp@SJ38TNOODoPdiwtR2-FbTQC$ zMEnKGkmx!_DN`6GQ|FjW*HFIm-+A(1X>tL5Y}LcYyl4*t-Jf!}p8(LkmRc!xIGX?@^uDQJgYJf4Knp3%R2@ zwNr6?4f^8!Ap-Ih1}INmB%40YP;{gB_UszKe`JOOh=%lk3qj8n=na8=0k9Xw4|nwL z=r8D9;1-||Uz~Jbl(H{N=YZ=h4|=V!E?N7oN?Jj!BG+#1gCl2{0xx{8dfmi0I@*fj?kTHH}Xf0E8( zIW4@F*{HQje@PY7p*o~;elgs8PH01>Eg)mNd*J>3Nt^`H1@H8l zi!4rF$W?+Q=i#!=eKMvv%gIbaNX`4J+t>S@Aj~CaRR>|wc^n{=iHhRnH51e5$G5_F zyvk2o;NDw5IEdL;FUD+zhKupOWC|s5$*!MSt7hWipU1h~wDbv@_{Ko!ylKjcjJX)o z5sthfMbS|pH4|PrkST-TP055DOMaQZt#!yd)5C`o>%{|c^>`n~rjgLjf*S+vgC9;T z-JM5^*04CB`}_tdgXkY`wKVMcRh1R5d2 zQ_o?k3>Cp;6H}gv9cbyZ4S8dOc+VhmA6Um{+Boy_8~`~7!&g|rGVS$RrEZQoax`ac z9THi#Y4f+GDgy&F#^drWDmfn3RWnKr8Y=GE6BkbVq}x=!>5=s@qS8!8vxH>mXU^ts zZ4)DUNp4!mIuzoY*s2A}zwSiGQR&7iojH%9OL=j&>POmiF^-;wUYDj{CJV+@I;}>{ zMgB0GMgqh-@a;MlUwml1R){>GKUM3jQ|34(+3Dy}*@~itXJ4+|n7KJO>T#vt>}uG@ z1O59x(z-R4uc8ns!$?c7iW<%Cw_oz)gE!7)f;7tqYw#UACn;JtZpBeq&+kCFSFmH^ zb!^O|A5sd#otwnXPca6D_ZBSLq}mOpwp>@YY=DmXvI1>g!Q|L7yuD>m;T|_89vN6S z%e2dRSQo^vY79El0K^jGmXn8*Xh$2XTr``SXv~#3i?xPEntgy6)EEg?uBq6EqIz!+(cVNZrpokcDTikmWP z0-*6nOekHOr1p(!Vm}w`^`J@9ZrMq6YZUFSVghc%Rsz0<`Ybb|YIUafR$S~Q>!H4k z+t^R>9`dVsqcMUtn?1fZ%RN+&^#tFBT#sa#Ot>9ms60IrBtpc9GA6J4_@P88_K1lCoUh7 zn&4i^FrUR}+&3BdQ7Iuk{#L{jFnOeLEh1hc$^i&moh02&3i^;ui!ti{DNm z(fPp4XfxL|`AF)@B=iTBt9k2;vIEQWHICtH)>6{bb6qUIM3QooH&SFrUu_T3ej23i zC?$T&Dh!X3)lv_xGZ`%+N1PyKb^e<3or0@to#2T$b<(+zHjF2#GM{P9B8+U#iWv^o zep=1Bl8fO1H|7>J5Q81@{FRDao$X%2Bej^tRTayj?lMWud9TW_nbarqLIGgsgn1R- zzwl?kjiBmtrsEJQD2C+!IH7>X+6x@{?K;XYeL7C&DZ@UdhI2blopU(_ntiv$L+>d| zo+VzB;;e<&*icBvPd^GbVs0KK+CUDL`Rys9#4g&Dc9~LVg>vBWB5~uP2OdIChGDcr zk{OW#Swp(YZJMq&3vrhHI00aepK~~eJqP@sj=)N zIs}IaayEaVc73y+yt%AKX2-Mnx!~98T7FsP;?gN}Nm}ac)2PaHk{yO z#hsz1k?Al`Dy*y9``rcM;cPHrqbzXFLaNqtQ{`^-t;7oMy;9Oxv=gMRiUtCigjUYQ5@z%BEil^gt~qb|<(%3+@*B=tO;>D<2H zIlfPh=uXFs062lYn6+&>bZO0sAQ&y0sy?LO#16Y7PFa0gPbMJaKZYGU4YLFl`S4Z| zD_R5pdaW@UAbmhe^7!A|QgWS3K=^#9v$IO8+@l?G`A!aG)J550QLG8{>x+aS*^lr% z)ZWvukoOEdTOx)p%H&LpGQS`Lz&I2+1cxI_>e?s|6-=ZuYx9{7Gy9b@ ze6%u=?;u2oNu!U54RC1JX)u-#?#(mH#anHmReu826`R`l72ngXs_<5owVud^YfRK| zDg^?Da>Y2?D57=j4X*?pw$PS)I*Ys;~M8+36nq(?R_QV?vVESQE}(x=YhxeA6&|V=C7LYQDBf#oxjerHrw43=c9EO<#lROs+>k=pYnK88p*`&j!&}3%cX|^ZaTKY&__KO&Inz-@lSHkcR zEKcpjwjjE16IJb?Hp*>&uD?`xVtFoYs#Q!3>yrxy#B)hKV_VOM7@lNdV<|xGp%{($ z%1J;~E20t9XxD8{EaZ;EXNOK~P!>?R1$GqOH(>RUkavQP*FIj2-28Z2RZX0mL}Gux zCHN(HlNL~Wz^Edb2)#1WgKYc;;m4wGoo6RlEeH9^9O6lfZbPd5#8girTh1a|0xMe@ zDNAlv|85y*R`kNkKY*%T{uf|uJUN(T)*W(w*u&Q5VsVf6`=UeL97$|BaK5A-?9OU^ zGPBddz&rGYw;C_A-3)86BF~ADrx-|rIlox2^k)6+l_5M)<+Qg;+g?cY$H!mUn3eK9X*>wYFJW@#=eKK% z;Q4!7rZ9aJeQ5$(-d+MrZRD;D&3`d6?;ezrF!yqX!@Y=HViNGB_h>eOu`G!EeKdPk)f@g%f?8W2oH41*7IedtwA!si#JjNC ztw>Kur>ee4<^;asT`)O~5Iy5Lxk)`GTswnh37TJY*D3>8}PM`)sr9Ww)U&`1I66QH}q9&>}ds9(fn7b2tHEx@+77gCx2dX_F4OVX8;b*L0 zN(4tv!}AYLLY)gGW=<3k5l?c^PVu@d0exncma|FIyKW0EmQPSSks6g-xshDfs|qyB z+v)_D%DMGxTXsMQ2*)x|HIdT9Mk!g*69>;w^ep!|S`0Fb&JRv~HR@F|POA+^fMGrn zZ8FmWlSPq)#DTdW5|(VRqvlWX+sDw-3j4M6fD5#W97=ov7Sr{XhSgksPd8G2tMg#PAc zjH(Uo^}d^Tr|Fft=QpyYrz!m}fJn=Gs$I6WvxKYm)`r=_1ql`m;^CFHUP7=_A)WX2 zpU2)o(J$iUpiX;?5 z=I`4n!Ut3iGmyNORFFyTmHPSm&wB3SR=Ei)bD@>oPewg4`j?oq8sen~JTEo9T{b+o zl1mMd^9#3pJ_aL*H@vrt7MRB_ABxCtH>CIi!?4_b1u2-fphAv1Bjpu^>uy}vpim*3 z6?&|AD;zL6tiD!Ba>){fz$!?FW8WhjgunslC$mc@8qQmJjLInNraR>*v}mU%zQDjl zReOwvgWL$Xyv@tM=oZ$0Iou%#>kov;&>hakRvv1wmKN)=+=NA^|Km{%bN@EG4~GMV z4U!7C{s?YRQjEcVMTo(f2dXE${n-m!O@n4@p#ien^+*QIWpguNiMHyf@xu1!deJ1h z!Io1@-II^$qWwN!df$y%^nNT)(kO*T`K*~#oOGg1YyT)k)vM5oK!cb^jDn>3uUD5S zzNrY9P)kY(M8f8)YgJLdWYTMtX0shu%J_I_|;}KfR@}W?rv^9EIlAL7J8%Gj=U&QLUYv@hy5DB?LKd zyZKZDiVR9SEA>Mfk=Nh|Mj6>E6Yd)8?+7KX2f=>e_fT6tO9F$Ko~Q|u#PI$EIV6&K zDsm`)c$R+bv5a@{o3;_`T1D{6orC3_aEs2gAR?Wu{r#2!Y$FB{A`BMjcC`NyhkY_c zeY%r@{2|Q58=(+nfaH&je3_L0{W*8AbX1Hbeup%KU68jW>~ygk$2c-fK`(UJ&KaIQ z8=8TwNk5c-dt$?u6}vkB3Wq>_My@tbjc=ni-y0cU#dU3Z3XR0wLgXn&hXfaOjZ+f0q7JYXXO8!ql4Kj3fx zn1_RDGHNz^TijFrmZ?6}uy$p$Cx_#>x%@fw+o9oj+8Jcff< zk?0mP@PWGS_0zbvmnSTy zTfr$Q%8qpdt?WXW&T@@vI?gYK!(uH~p;mDxM#?8gAsHGzo(W!oSpZFmfbS9c{}+3+ zpT@ww{xN|N@c#hflx3X6Yf5N<*e$AkkyQ`|O)WSSY4F1tF0`vOn!(&Rqiv`%$t9xZER zXER_pI}deSU%pYIvfTLZ?KygeBjs=mzM5QaiC-JTT#n`ySxLB1^@BE>B4uOIqnbDVZF*a(=y(&4EgY_9FLRi z(AQ%0HXl2FqnxQyZ7T(8At$zIo|iXCbKJHDw1cbMQ#0S-x^6{%MJJXt=rU1DXfHB{ z2H+b}MkPJQh-NUaLX^lamODu>HA6IM)01{p=3>7U)Ls-@2T5L`iuTi@*D{Ud>8GOK zaDex;tI%v4%MbBc!hvxBLn9exscj=j?FAaQ2&@5BrRV)L$G3pL(OnFWD8k=J+bjH= zf}O>R3-(s|3Vs3OQ#xm0!#R9Sz$h(jho3WC-GX`@Mv{_P)@75cM7YgW8cxN%!1gkH z6>O)g$y;Ds7&rFx=fga{wS=tg4>hI-B?{gpyN6+ zUxQR3f#zt4R26_LNXQRat^j0STb~auBCwL?7U!mu6+oyp?@NYj_rv?OU$#GtVL&~MXpJCjh0j+Z;Tqsq)36faMEGs}t61j4=c;O0tuh(rOXPlA zWYokpC2w&;pUGza*(&kA?%Ig@yOT|3gSTq{XkP#Ez23O+9YLMpeW3c)Y#bR;-6k`n z-6_vdn>w)t^G8XErmxr|Fo47BpWe^7P3LZ}bgSMu71`>2ivV%0%A{jFHQaHy*P*r< z`ApTOAQlbgR~upHk$d>+*X|?8K&a{{Fiqnj*cYhpBN@P7Wem+v>xj!OdH+uauQ&A3Jtr;`G{vnR~OJNYnX}vl~=so?lM|ZgBJww;39|VBnjRZQ_OQPV_ zQ`c2?evQccTgMZ@`rOI_;96$?Ea+}f`-{; zOd>HvFfXjL(I&5pXK^Y0NF`f*t+BSlh6q+K3yPkEW&uJ20BW1!l!ah(j^!w~HPP9Ao@(KE=(2IdsWIf6D(#;$13rp*>i!bY2sJLw#6QMOG zBNG)2&n#KOCjPnBMxcP@qZMCi!HEOymS$q&WvhwPu`x_scvpwbBP5<+C_*gsbrxh6 zbu}F^ZZsQDOo})J;I)!OCOgkXp&z+FLtj>$Y_)5{Dz{?L5XCu`5s=9@8iStYp z9FK;ny2b0D2|p2^nU@mll}0(tGl%!g!vg?+ii`J3d0jkHsWu@2!-Ics!`p}D;kBuu znnYCL?L6p1NWbQ^oJ}IFiM%gz2FG|(QiRz8vAJfSuXhLFNkL^HMNs$UGC8kc79CH% zY=5?A!uQseJ>?ep!lG3ijEHj^G}S>t+MXwgi)y{GX~qE5j)JuVNnR? z0lSw{(n$xsNWD(0_y#JpNmMvasz-$`4fwfkT&?kejOOO(%a0K|lVA#(bkFuPA!q#Y zl(TK>HFav^7K-Dr_KX{*f`W{SaGcFyeP{Rqd;@C5co~x8@YjTz6h1QwOn(v#%YH@- z8sm6?0wa<=j3h7YC_|H&A%B5EvF+j6rTbEDxN0;lo-fI|$`sqiIC*i!Imq1F zxVa29u4<_Ha*tsvB>v(@ zV$sC2;xMI^C;j}YYlLQPIJ`0GgpzJem|Vtc)CSrd&BcK^_g?3esB){B(+~X3DDGE) zk6B^Nf3D4QKAodNOZj-rx88(FUD4oJSzBx8M`C2xq->^F`^AgnHw@o&+yhYjYR0Kf zV^xKw5s3s7@yXw5)>_?8$DAnGQo~jU6$LSRwo#qRGFRp1CChCw_*|xLJ%`2Y#+vP| ze*z}AG&;+&>xk^}DkiEatCd9e#bUx@F4t-?-fW3}j&a8J~F~ zY=aq{&9q#Gn%gCI?-Pi-<{caXNI$MWM?Y_dF+l5J&EjE|1r>`%Cg`Dr zFzeY-dHdceLf(D%mpE1dxKhX`fw05TrS4Nh=>%XJrMOC)7Y%5vPTu&Usb91wD3rD; zUeJXnN)$zsJgFxY@|R~4A*%IzT0K$x+O<}-yUkG|wbgpoIfI8p{@zj3?Vamzhnt~( zmo<8g+u`v?M{P5Op&heQgQ+aN+J+`vQFFPs*4CDAXUoHg)(oHkszz`w1!13Ntuzjp z-^}shgcXcPfA&#qMyPN4({l#WIv`xNTo2=aAzg>zcZR+)aY+LyCZNdOW50MuXU3J1 z6sLVTL#XPXdIzzLA6_3nED~g}x$p)Z2lExxc_ikQJgDvX1lAG`-9o&wd!>dClISJ! zLa#qcWJ0-Q^&4M+szSE|{=4j=kXGiVb5Ep&5IK9Ms_ZFg#-_8w{{ZFu?`z3UdPptaY)ebN=FE&rjF9FoL z-2M|VYmbL@l~WA|t=<`1XnK^7+gj1h)KxM@zA(7Tt8Ev6zG_HGomH6?Xy%NPcx5ep zF#WTkKloeC4R1iT2zm5pVw)4_3|Fdpi7%)|cC*=3zCA-}D#98>p~DrWZQSg)MMFXr z3xUK_YxV1IIs+h%CpYe}IU{=TfzLjjzp_&DxZu5sfn=&Pjhli}#hTq^5R)lHs}ZF7 zuv>caaUyu&*0pH1z4F#kb$#583+JbazC9wF(iFa2_a8L3r>Zf-%LFVNFHbaU{tcQ5 z+Qa9p4YSjCD=E(-L$%W*} zM+$EKQ6yV)=_aO9cjioYro>_}9P0dVDHk4mQd~`dM?WAHr8{aim1zKiiS{&!XTHWV z;DmaS#^ZHCvt}0^M*p=6j{MuE5S7PL;{)R1MkV$wcw}GrOPt>KDCxpl-rz0w!eJ@j zpHD?~8sZKppUkn+PEK0|(o%G$OmZg+${pYFU8RXIl%s-q7L5NnI#lJ1er?iHTZ6vC zEZm16xq;T!*XzFv{FicxxPqPN$~yc1*nN36D0u-W5D*TeM0-}8#92Ye#JrX7 zK(@xKJFW)$-yF7is=GGFO$sr&d7N{K24x+sj37=}sjH-nae3lWBWhOq`hC_}&|uJFL0Gd;gI7#WDD7QumIS(q2&o&cZu@QT=dH(% zXFk67Yswt(I%Ggk*hw})N{UXZgsh$%a5P>G3W(qsK?<%cz!HIj;JQ83ppH0j;w9FQ zmv)j)@czeb;XFIpLR$B(ls0KKwuv>iv4=ViD&_YQjLWczJ%%W;}1yO)?@KABp=;EOYxWQ=Tq^c54oTrO5C1d{l3L+zty4!uAG^; zZ#>Zr6mP}Q4NxovYIO8c66dN1%=g$An89nL2^V57s28NhUfulF#}e5hw|||%=;~#+ zAB&E$;CoLtTZh^X$V5lO-OVzOr8r&|n%5kjCn)6cT(z~UPA@g4#B=YR2M7E!0R-0f zKkODZ z$Q=xfnTB75EZHmHyw>FDn|{CN{hGfv#eOWdT3`>6Q9%vy7R-(FJe8bi!dui+nWl}5 zcu)obUC*;q2`t8pgw&`p7)-B$f6&SQK~cdsx0SY&#-UbbCXkdY^^>Us0Iz|@!uF+Q zJH>wJcN}9FZSJHnaoE{T#4c1E)3%j%r-bJ~S~ABKZ{gg{s-u^fx!W}=C@mvogT$FD z^pq#I!{eYMiRx}zp_Wh7(7xu5p{z6Zla)uWa6Gf+Y%u9g?4T;JTU=mC6!Gd4=zmkY zHA6R0x>ZYoldFAIQoUsZybR!za_f9iN1~!wO~=a88(nJ;mCtg5;ats;KGG8|a9UE& zEl!K)Db$C=vlB9z0>Z44gC3>Xjg4!w>$6DWnN2-BDJ%D*LTV3iaoqOUaM(>EL#h)x zaW+^Vj57tNEr*7O> zD-XP#B?tKg>~{5GPxM9{hFxfX2YVrRK6uQdr{^1A>*|usexBk%N;Tf%J*b!b5W+nx zbb;j?RNlh9e{EAp?tzxM-~i(d-a_79s`7O051}kLtdu<|;z3fdi?&j#KPw*k|Im_O z;107Vj{e{^>%XQ7AOcW%Q6xX*$Hv{(V^*{pu9;x7!Ieu-9FV#|=a5Ahx!njMzLb7H zQGRF*7!*MVGHX_1&{B>6ob2j77fUpdlPb2avzQL0qxCcDQscXST=aC?Dvl;CVXn#d z_DhGhwOK#TU@wEnCQgdOub{W^cy~RM`K6qEZ{#~}SrreotJ9fnRK4VgCg!vnmV*it zM0v^AkvW(YXwBH!=2V+I=0zax=(ePVX~A9UO7lF+qTyE_V!yjG&h(z9M=2fB*XKNo zzD2wPs2s<}%?;so?c1f}-5kdmwu|AfD-!MpQ`lqDr?}3`KjW6!6-<`aOE_%S$BsVg zre#EmmFR(#kQee`rs0%l;UMunwEr*q%FYq_w)hVf969XUZ!W$>7)4 z>cB$losEEYo|A5(E(chArza%+SPphjf}b%8m1dNSD795|Mu0#p5I%H;L(F@S#^sQ1PjMl&DVx-c1YCnV@NKJJ!;3Vk& zr2!*rQu^VQygNX$j%lUnM4CKE`aoV+Qb0wJF1GTL{>7^nkH>IO!rUnA`*Cu-J}xek z$Lp)jazWltaqg2%BO>_6)L|L<#pazGaPnL8|wNk@N}C^vS_` z#Z~AI`c{wQJ*2%!k;S|a^@0e36Jpv;*{-S=4 zsCJ$i!1ZmJs(EPI?+omxeUAX6t{LrzX0gMaW>D7Ji&$b~D#FecfE^%=B{`wKJ~G;G z{)mXTA^qt20{#C5*n6`_TZVt^(UcYhgy=u8aYhjSpV=15Q1uA^>&y8-F9u4OsFz8> z)D(ps1r459k`W=1`h?1VK=RXXohe&a8*YDdLf4zMTa^Bwj`9xWxdv26zW0TC2SPP4>NvBqAS^cbH zQf3_Wf$TSmeOXVVhZjCLHtlj3@xsMOdKPkovL)WdsGtMQ`ro!ALa|6z0j z{jyuB29x+y3diEf+{-g0Fg2I@1m{#Fq^*qG;JqV^`b{|syP_u zvV3BRdd3o{QMBMoOsGO?It#0QZ3k)JZ=$F*XF1k4u=h1+#WZh+MO38 z{eIpsF(W<3X(Z?6UYJjlB#Xg8W2fqXQnZP_HgVTcv6@d*=)t^woOyRDhf`(f2Cq*G zfaERDo;$IFp3;4?cV8r6K=s-^jOj5Jgf;h(M`*tfTl|_ooY~bBqaouQZ~GBHjA`>> z!YF2Hp9YXX`O)b9otzLjkvY6a!TuCRa|$0$Wkyca$41DUyURn4OlP9l35Wx zgff~qL6OSInI~D9s*$a$&ZDv61elKvm(9%^i_26jlE{1Ekl+sVh0XP{QY;LQLxj8b z%^Pb@nGrFmVxo;6Fym`xbVG%Tr&T}pdC=AM>0*7)FnfG7r)ydLjvLXEAd5r;Ab5f7 zZjM0of3qQ2_*V#fS2&!VEF11Mn7JlM;VBlB)cotT@DZ2N=Z+vT#K7XcNZaPaAP;$% z&YCj7W;e^C(!@CWh{~RqCFBN}F~N;77P6x7sv5khnwVzMVsX@oP;xTuY59<-k<-Iy z&KFkF?+|g(IITh6Eb|x`c4!L#p^|UxFLhSQRhsJI^zCOl))~=OEEY?HBC>%2{cizH zV$qBvE^60SCD@@!cQ5>QEcSVuo0|L9P3YeR;p% zmLT&o)KVsW$Zj84vg@RC{@RhYY*6x_f$1X`-`FrQ_vOW@TbITdz%U8}U_s@~CleB* z3m!JnV#Xb0HbHe4z8fVIL%QpA)+@>K*-ZI(=;+}#*=s_ou*a>V{eZs~S@Lm}M$sCH6uy%ne-~^?psu^nmVG+B^$=>4 z9OC9HOQsxmt2`+VKB9&8f{x2{9`>-Xx?!hyESP~@9<{CJZP^;2f!x*igbXK3YOal5 zT_+%8+P)YS0$QK8%G+7P(Ucs(mcmRqxnXX~PR9}r$>TFz6(YGQy;ddM^#cVSJEfSN zGw{3nuLb4M*=&lH;<+&J{nuN;*^ohc*}(Uoap;YqGIUa}XPZgXWvIzUc~QeUL|+f| z`aI@z5kVlOJ)cSd0d#QG4?N|Oa7os^kP7|Pf{Fm71im}oLHw|4n4tK@A8v<7{}@fY zW;1bJ1MXxVbstBDklSk6&_2!DFEAg^^UOZ$#h@`n{X{0~USw9U*fDg(T>M|G!{#oH zE>~@nb?A07)RNGI5;8~&G^Sz!cuk2B#QQ2zL^AWomER!;iEimB<68wA7xp$(i8 zil%35Nl_yT>CD09G$xR;B@?6w+FI2rMd>tCO+QstAk>acGewh`d`qm0$nvZ}PuobL zlJrH$jpPIX)+&*ZJhCcm(8?D{!|Ff$yebH}URa^AtRLph^4G)6GIf)D#;&D23Mh-0 zq{r$AD`jQ&Jil^QIkZ~N9_X%C&P3iMOZl2KN`d(eZa(SQvj^Ybf~EJTKH2N#BBfJ4 z^hktCeKesp3unS_`SJpJaySOH_wXM!+4hH-tle4w|IfKs_3es#yH51*k6qd5T$f1Ck--`rk#ivT;!Ecb6O|Qx+wv+=2|=E ziuj)&GUv-i+9!OS8sJZH(>+JovZaJcVc>tT6h%Ms?IrxD5${R4^(1Dm3qR7P--*zF zszYey=?|FzMRU2~A>v>#)|_6#9>Q%H>=}-L!l>mv2QIEGkZl$6nSyK`U-V}Ot1g35 z;d|v9KhB-bR7mjfgTlCGm%?4EC@H69^DFTgd!Lz(wqyT11?6&Ii-ENvKm@rb8Z1Fo zA{6ryO(iKK%CZ_JoXYvfK1y8JZ@^ycfGIvz&q5Ak_~VSNoUmA<91yk@=ETPMROST6 z6ZoC_@Xv4Hf7gEzX6x(o+@nv)?Jc85IvlojGPm;& zysq;5>z8NlFCp<65sQZq&4yM6Bzcd#v>8~*WhmMJuQa`eB=;qbP!cLtyhe-Z#n)J$ zM8Vg@Y2dggPkrvS);LOpWyPZH#R3?udCHh(yxRd^mYM(Pf-)$7lRHU$r7-o0FYxH4 z9kb)R_>uiZyfN(KOq~)%%qJ`ya2s$Z$-A1Ti}^^cOQMzcrt_Ot6)+*wK2LSHtr)I7 z``{J`;_YO$#9?A0>Df#h+RIaFQ^`}pUKJ;`4X+%kmx!X>L!hPEJJKd&=K`XXssb&-dy}rcNSiK|AL5F4s%PCD!hm+a&rS@6<-`*l;v^!k7?6{ zBK{u+Oi@Z*t}R6p`Ru%W+a-+Qzxk1`d@Bm;Oc&L;f#H?&4=#I&U0#n~iQCL9WI()_ z<7(7?Q3^~^T2|#>)V5Lr;L3U3a^zWj3n2L&Yw-~0sm(ObN~RLN8u6X>|9AlsMtw8T+ezRDW6$%(@Qr@vygqEmp_vci4WaZOS)15H`V$?#sKW;m z9X%7}OIOT3{Y#myc-yySbPa`ae0Jw_8KKtrHqB(SP7-PxA)F8pGX%Gq14<1Z&YBgG8(mIh3Ki-+|Q4^EV7jqD@9c)txj%wEA9r|LchCn z1MVD~W@*DrfVQFR9h#D(mbvh%&GEvjAtPB1mLWb}*Hq#-`FShX>yet9QzN~_yGk+v zfgY*yd$VSn>2F*$KwD8Gm%n%UA=y^^&Y1<7B(|`=cWu9HR*Z04CBrGxg1BYGOGv69 zv9jHlv=zU#iU9+9m|jIU-@@8Xgzww63z1lH4;>u@+ZN`2A9e|G%s-Gj-jTe9qX^TC z9>s{R(s(o{VXMSfcKGZ$b%q&6FPY*=)MxqYPYl7pk((N zbhdJV@*?_4K<$s-KR@J4h;=cp#9K}jCu+e#q>+n;e-sP^$b*Y zVMg@ozT{%n_Y7ii=``D(x;1NZe(#goBtmPX)KBnZgzDB@d*3a{*^{Xfvg{Rt85O8T zRt?`DB+5>9qMdAKme$2SBu??M)A`QfXB{u&1=mV3fNm+%bbLqixg)JQU(M(#l*zUE z@9$T!H?I6doO7{^r+JqF@^{O}54fTVE^*y~Z;vR>B>pe1-Z40`@Ll%~C!W~0ZBK05 z6Wi%H>DcaQVkZ-OV%xTziS5aockgpf?fUdhomN>$<`gu%0)Bv{a0p zVGf(;XTq&k1UpYAVB(-wI>m`8s?=fs8P>yTejXG;)UV9Vk%U*ohE30mGmtS;jc_60H>b!fM;SUxM>GF}#; zHiZ&$)UUCQ^IDMzp3njg`5LNARR)E$_+v8;Cjf@a4=Rb%ha=FS!awevDhrCHvb~2Z zYu|6vY~pls^y8;Hk0mqMBHDZ}^{4NzE{Y1}8vIr-IHtTHAMUInOB?4|D||ELV~v_d z`}Nu%-x}RBNPs5JG<6I`B34Q;tClk=jaVhlRO(hU+U12Z>c}FTGj$74&QK>5x>Dm- zg6>c^m<0_qqW05P*a}kxA(OoGb`IognzgIn_=RDxJeX4Yx!e5; z`6#n&%z<*1lKged2-MiKOx|F9rvsJMP}J>Fu7DS|}A}6^gqGNL(aogCguD{O=&XV0Nby^1n{1jo1(_4~h*u@!^ElO_)Eh(h0 zV%lOKKTSUz3$@*p{s7&iz11QW_QqaHfkRcS3fP~3{)+r2 zTN)s8St-~BL5xy>40WLdk^V8&;f+{X!sG0Io(E6X5uAA+dHw}8s|%bnt>HKt-XmDX z2`7EAmd}yM1ne~V=xivJ`BpopcNA&CY%b)^JYAV!@V2FOH(^Hu0SP(&-P_NYVMo&A zM@2kMy>#}PU`6eT#cVf|Dj)8YMLw4!u}L8Bp)#9WXI@D#-MLj8%IsHF!y19{yhx%c zTV02Ko7!WBmQbD2*^+fY&!9Z=J#`_~`*D^fH??8m5hAy=BwNuSl!kKGT<1X(&nDO) z&RE!|r5=weJzl#CFh|$J$5`5&tr!A;|HW|x+?6ZZ9p-j@@w7N5f9x4!6f zv*5?o2;~J_sOJLMTeH>))>XH%3P=2K+@E0w-e@X4bJ2#B@kN7>vcJvfjRDm{&5%hu zzGK7JA0P<_0;pH8qjL?)I_%im!Up5K3ZoNB!J2O#SJ zHrz7q0bcM)K)i_Dz-U1PgLA17-|xJqxEbG&U?bTB93b33qPM`&A$Q!8TnTvC6Ss<= zFd!S$yweq~=mlXRKSpmbk$^KNkVGHQ0cNAy6lkrlG9=iRZH*xL(Ye@WXl|J>t5ihP z9+I92n=`vVI^CgRaIO-!s8Bu^7(v_u+cL&2szuGix-d;{GdeVSzKXx1UN;Ca(Erpo zs{+@18FHIFY?lv%IG8|0oVQ+&4ltln@)u zO11!Y7qnl~{~;(Ki#xo92tiAZ&`}=9oi>PLwI>T{4KV?dJEsw(-h=+ZFo{Edvo@Oj z5!bKx6dS5zo{W>c_yI1M8nB`W$i%-(S^JZw3z*H?)#5*O^I}K zTKVP!S>q}#VKOk86~amu4V?!e6saN+5)I@lq>0J(J^e3>dpS_vIqjD5z*+q5?YlCQ z2fE-)Ka)FkM0F2sVxy*Vguvw~ll>6CU2`?OQnY&y2cKscBrw}b%4n1(JDynv(^y0^XqVr*MD@0bH01Z09 zaWi=cs6QCp#840ZRGR1h$3%-XPqSr+S(-x*1_d(7szeNoOLH_1GO`M)kilN707oHT z16$#X1;8-7M6oW#SU>>8!oz+Z3=0{z2l@~Qn$m0~Md)4r=Rt`*#~k^I{9oj;AylIR z;(w?eLac?s>cPRlG$Fyjc>W7O{?J7Nov+{kQ&jcj@I^5GO<8O%wk)eo`0NMs z6Py%UzxRn0ok-4?Gu;ilN~x_2vRDa6)IVFsNmw7>LzL$Zpyze zaQw;b{x?jN!>+ikAV$Fh(S~g`e|jTmae6St9|yGA^Ky zGkCR2bAtOu5N$Kh=^<^SLBbv`csNjq)c8`T_6@`L1#zXfLvXuR?|MCw|LTSE9_Byk zvT9y4H1=0P*!ZiXlIs7wTuh*~82&P*AY_tc1h^^gc>rk&t3n7=nLGX;&PcuH63@Km(gV>7E>2 z7r~GgS;TIIv9CgMH&Z|NzznydOmD7y6I~`7==$^A2;!O)!gLGKjhCIX{Y+ zmWR(kd;Sh&Fi$VxC{uj@@9ss1vN{#mSYNtbCpL!N%}epYc@wtB>pISCZ^l2 zuh498lyDLD)3qidYdiE{e3O=8kMU>qG(LaIu0y15;sK@!QNW|^O7I(1oEg~TG1sH1 zcNCF`w(n15W`h3po2qxCgUhfZPhpQeW{_r9e)^?wl2LQS?1>-|f0b;7mskRGtvn%= zY_z|yBS(o>=acTzwOU7|F_MnqeK5n&xP_4I3Z6d|pYSj|KT%j3t%+Ngl`^mRZ|4;soK>-3{0qw%!?~UEgf6n>#k!(Nm z>Sv095axAxVcWBZPZjdIztmP9cJBkHHhN}~JYrWS1mgbn?XxV^T3%g!#-cp>!!>s1 zs{oyDKRGCB)QUEdxA9uDcsK6E_;t0tgO(Q{ZbYnY^Icdg_&Km+j0mwM^DE=AqDnin zt3BI{Ezn}yeEckpH<>n)xK8-i#*_p{En_EFZB6RIJzlp}>1r+?=k{;hO!ZIS{V{Qr z(jI(I0|aaAq+n{!Yc{HqOzk{HI_$91>427JC}eUxgTUg3cV=5jZlWGunr9L%g$UP= ze8Dp);zyA&;#ngP+o zG@vZe`bX(Rk0JS$(+)LSqEZRPQ!A_vWh?R+*QWCMd-8+GHQch0FaHv4mf~-gp2>Gd_J>~rnhD(hR1DHMeHBsjNnj&hGk0Yi@u4y5k~2sc2aKc6O0 zX1(2=TzG#-c52)M5Y?To+$~`04o_$;%0H`ZUoh1E5v^4WJH+hs=$^X5fPkAy2lE41 z{=|8or1yJlLXz*ALzds~1bi}XA+QV_C2h{7Y7gpxF)pxUyCzDDxY&I9rI?&O;*{G=RJgaV_Ke#V`>--tY z_0M&bIW+zaQ7LMYXy~+S0+>)_u_H>{8{QoL3ZbOo1SI_65&Y(dI42xSVGCT1tAla%Zk&#<&&9=-zR8Dn%i{^`W*F`jN=9H*ov*4k;$HBNPrals znk1vj!Q9s1)A9z{Byb)3FK!JhPg7S@dfqH6cS{VPhWOzsk z#D6Skt4C?}G1g&MP)h4z+R#AB(nNHwi1AQK_4A&NM*eVo^siCMo>~d-0-Vd2vOiLO zH!l%#FA*N)YuXlyB!!#h`&IchgKZo7TsT&BCUt*)>??sO?dpPUrYm2&fFY&B{zOxQ z-vfUxa^VAmW_P)X0%lFpNz$reB*H#v!O?flOGzFpnwUn=CrwxHE33n51t~ z1}lgmQSS{)Lt+*-O7104BLvR2@CLI`XSosrs8?qs(4co{iT`-8U!+; z{?6(}rCayOGI`&=*`&1A{)fVy z`XEHP)W!0jgH_6Ed9=nw@{^Pn4_-*+hE_=JhPO-khO$fbhO^7~Qr$QD3hI^kV-xPYvdV2qTtgCi zK^E@{ba z2yf5)yUV^@Rj7dTdCt`zXX{E^0uzD0kx*J32+W41I-A)W+8((~nj!T17#kMVXpFMO z-sc`WxTp@&;=re@I^283n>Cy#EH7l>uFP&?y}zN zIJBHQ&U`5Q#xGXa7UHSO_DudM=Elp0%+BCc@cliJV8@x@z>%b@ilUqHZ3x-uGmjHX6nXZF_#G#1F95lK{gL`j|3>(zDmVd438nB{4LGf&LT3Yt}= z<$}+l9eHLo7|)?mvLfJ^{6XBO!|jx;5#Et`53;r)dL5@Q zo|D&y->-S5e#YlmAmUNA+~HIerLOH0d_3b)UlXXI4m=5Pj$y2M>Ml!#H`P1y5Z$ezODfqIp^Rs`hZcjCJf4;v%=LJbw>-YJ>BZx`&OYagrMQ^CL zv`$Y#I;19PEAEj7yIc8m*>dxBZMx$~+6*62&pR<}udHtQjwJvus%n1a3??A^=$6cL z{8e|&aM|Xc7dF8;Q&^0j7@KqHbnQ!cH^~hHd15?L<*deC)Ub%l9U6qKc)un5{grW{ z?Z{7!H16lfrQ5rC0ol-G+r}jvZ8jlXFBs-hb-@)|VoTJ_u-{;tlcQT*hyD7{d^hXh zt`BNJaVW8V!S@3)Pp6LEuCwQCB-1}UIV}Zo(D|;NthW5)#1zoD(>~ocTs(2ox{YDs zCw+Ki@a@CbmIeLoUp=W}r>vpfvd3@2>53*|G#6R&>6YQ$A-N3y>*jKC3?^)X!|t7X98T;Xg-_)HV@ zNg4n}=1V4l>hH7D(03d%jbH@(&VqAH&97K0IL3ZE|BB2aNPJ3Ger#$m5X^%*FzOii z&bI12Fd9cDsgl8_Bd^L43O8o6OEM;(XxS1}v4<8cP&4>&CG96joQe{J;QX#H5Nj*i zA-ajWRW%I+B)0vKfmtQ9cKC)q)X&5kD5ZO?8Fto^4;DE_!Y-RY7ONE?Zl1tqeGg9Z z-hUnKNGr0(HqwE1(2PgH6wHuR!Uje`|HC4gM2u}D3GE;b&rJH8mwW~_2?Qt{Ihq{} zYo{^7Ls~mzzG>h^k%;JU*TDB?Q$S#7p@CFj>JHuamdLP^JLKu+-JeJZr4Po-KWUVB zZ!ui#8FrfZa91Cec>iTzz*lsDTI?Bv{@U8&GbV)p8l!@zzv8ar)ebh-^nsbBUjns3 zV4C$MCITpj*1Ufw@2}e@(AASSE$xYwM(K-c1iw2Df%oK5`VFRyKkQrk+nZ$gH*ZPM z7lNz~>@^18%_$snE*7jfpjuNa$v>oWN6=pReGFfc#Ewk6T0ocg8!^w+sC+zFSyBEH zU*LqanufgO`z8Knz5Sb&O_^5DZQ|vDl}#-$uL6iREA~48bQ_kVg`}p20Fk;{j;+AB zp5rzx^U;R349+O`y1bf|u{GthS0;1T>TECC6GpU|Zk5ZDEU#%VY)s?Tw(P%>!_Syg zke{BBQz-Ya-zMS`S8navZ81}ZKM&C?Io|6SwgOO9z&^zbgywrdv~Dhw8vpG#_?vYR zM;$n%z3dd9qgfx0A-3P?I^xdnGFn;`4w+ub;$}RiJ~!;0(&1WT5uZ9g(Fw7nWp#Sh zh7=A3>sHkZ`cgv7+iczZ=*e0bxvBbNQeY6M?v=cYQPGiqgekUJgGn&{3ccZSL|5ZdSC#oBF ztE*9>-!KBR+3Gvn7!UVYLDogJ2QOhN%GrsR`$=srzUTt0<4WOu@Tz+! z_V;LQ+``YAu+1UKU$#rjxE-Tk9f{r!d@!BF_RP;L0WAZGHt)p(0-AyFuD4qxC!eW$eM9OK$8w~vP3_i~d?RgzFEYSlK@?wFA@_E|I( z@!=oYqFuunUvGkDZGu6XQ(j??y)^CO14!_}~dH5t`cM_dd zsp|dhjr}MJ=ebHVQ6DHYrX}ez(KJ7CxS4K2F^;?I4A=Y%aSn9>W*xB1sm@@`$<8R7 zY(qMmX~yb99yfwXpc{4Da8>26a=UPXGaB;PGpQWizBJuYQ4BHbaQv^89ZFyc6{dHl z>L4wya2+dA(v6x0hjooz>tzb3a$bY~TES!YMXPp|vg6(e%_EqLnuq$1Zi$h4NPKYr z!Gcglp}>;%i(YNB`X1-~j3R9baQ&J~mw)(!Upd%lo?IsyKE-h|3261=GpzrB$&6-6 zg6X!PrS8`o--;IG`rvK&<|YAMqlYd|w~;_GTZPK#7!88oidCrVC5-Bf-0VPaPi*c` zmSg#SUo;lqE9$R$B1dMqHaYVVK;yT`*>XvD5D9(o+FdUEA20VJ4g(9@;-X z6K!TAI$JW`nez-Vxn})yExWTG33EPV*F3x*|IGS#TW*WKF-`k3_H8s<6CO?3PcDpG z8%o#v%*bW=HJ*thRRs)r!S>&k;l+hk9HXR5IeC8PbVadb0Jiw!*$)Av=V;c`uF(+M zuppUspv0q7BPPl2c{C>q-FXYL0iojeSj7op+?g!HG%S)L5B{H$6@3~Jg# z{9Ui#hN$sLnqY%UP8Ju@f*ll*Hb>k2$l+pz)K^*1nv#WmMH)|4?XsmjrT^oDVeQ21 z%QT8%&2q0eA*^b|ZBF>$V@D|xD=D{;k}b7R3+2zg5KR%bEqc(kfUbB9sX{wgwnc;< zfU|X(4;YLYIuup-i(eN6IjvmpsyRbW#l2Fc2m5Q$X%PE}5k9s4y5xEKe|)OM%s9?)xh zvB`u!Q|;33%5jOg4cekXxXu-RWF3Xo4<#R<@GGY zE-bl41~cWT#{tU{E3WFkzs5j&yvq3DF8ZM zi*}q1?Lms{_}`sAjBGD3{k;bEcP+HiETpi(Ynq>p?*7--YCm+wi0I4eoB#ylDhUJq ze{(1>K@G0B;1~^)u2B&GLy{I9{NQZ!g-&n~K(_8;z)dZD4V2GKGf=2G0tFgMK4!u~ zNRha`oNYc$@xo9TvI4XOBzVD9~nn%Rp}H$tw=wQ(l`z93G|v2Vj@I1Cw)%Zn6Q7fRhAO4dBm<}b_Z;k`4& zzJg!NIInDN`BlkJt{DKMQOroRsq}JHeJzIUt26Q5)mi&6TikiNBKXuKW*ge3vQjLU zW>ebo28&;mlP+$Ph8#~%nB>!oOc_JqsGPQ`KvMOYxL@D3xZHVR{3mV_urfI8pmY@9kP~F5y4- zEZue;h6D6R7dNU)_!^vN(7OSdF{Cv~;1r4t`M{Q;uamHc) zK;GsZk*v7;IOrnsj^xy$DT(1!ra`%Og(N27xhG`q=I8k|CPrc4AQz^>kQy`X9q!K( zL>TjLBRoCJY;-&a{oHO^y6-8d!x#n~>;QU~H%u}G^W!SUnkcGS=nCbnWyDfvU_9=S z;gVO`!w`|T8rD@2Tz0{G3;LHqtsi3ao&fHdv@^3zQ{(opl~1TyH3~%6?32e4P6qw+ z7|(iMxvVXW@dx z=7?>|qJ5Y&f6e?mB@O8LAziBt*zYiBpY~Er$y1p z{Ov-OL(QIyu}D8%U4fBmy~?TBzSw=I8E&4gZq ztcCBjlZ;!S?|Mi69-0tw1|+B4^LvB|`&U$m&%_a?Gtrvgx!V1M<;Z%l-!(MG*>z5x z?Bz=mBc79!uUu5KQeb9E08`Azuf<_K&bxAKpBsDO^kmBJgKJ96gX(q6Jj)R3Qp@B@-qD>C5^ZN zMKW;8%65yWG|h?TVRK$3cc}^6261kDSVpTXmPV2)FI| z_U(_$&TYS`t1tY45O92>0E_QzI24NF%d;JXjD9VQjM5>9Fk{lAn z;bR(aA?t_DZ#rnAsjRGZ5jha^HNr$-j|h4;iIh?isD`^b+jABh`igLoPH^>-n>I&4 zTN`4kSY9xjor^t4wm_1#`ZdR{vq+bb5pt1Y6fP?#HffQhmipV0J-Gi~S)E-?f1!Tq zeo`P?(?>BD$0omr+_vHg7`E5Pp0xx|tM+G9I%1(u4)%0a-Py3IC5dD*(P(G_^RA(- zi6p-}!)R)Pg1sbztI=$121MRED8ZRYG~cjRL^yDSFKg?iCigovH5CPCuAz;SffVm9 zgt&=AEt|WscMBKpU!uM)dNJ9AxU;h%d+8vz2v3@7EbijM;Myu8@F2_0*E>G5Wpd_Q zlM5B?a}{*<;`XCkUrLj!m&{2e;?x{J)ZfF|JlBd622Oyv{^WXfGoQn#r<$nAAW6ZG z{1^)bCVCq{2X?l8b2$c{n z8HMLGdYq){Kwrc~iSh4P6ozqII1;(Xre1J~HgmXZ_M3H9t0v@Xe7@H7l*fI|d!YB5Kz{Q#8+D#Ija34q-9z7``z|D{XA|>4tg! z1xN>ZKVfoISmKm0DNsyRrc=Rh#mGrf~K!quRlb4)1GB}m&xu^ zOEJrXo)6y5fs7LViEHDgUJZLxjppnPS$Tf`RHbV8|4ti9<78m}UiMDG3wlZZ}0qw$e4+WX5mnX#x)@M zZ=Cf9UKY+4oMCL-0b;Ov2nsa5AwhJjEnf32j6eBjG!T}AtP%P#oSyi}Pt@?TEgYyG zWZ$sqD;09PapnAv^8Ca7@_v&KCVK;l*$be!Do^ukmsIGT;i1 zFw=}6p%)(j`y4GI`@4HYte@wEBI`i5g&Sc>cm*cO-EZ*dd!`ax_iCg81n)p+KhIu3 z){)*dXdh060({=AKji$&I(XQ=Y-)~`8)?;A7s!iLH^1L(J@DN%SqasG*<;{W>N8$Q z8sv*Wp!>Yw>F&lJRFYFA(@42D$p=@rH_5PbA9(8|)~Y3OVT!jEzUjjnFgOY2DqLF- zA6t{6{s?tx%|faOWU)tDaJW}#n$nvCz1qMfHr(Yq=sb8R#sqY2OSBDv_w<-mSc+N z=WteBQ$rm1)UHRuhsUmC+cA}z>KU%mJaCP0;G@~auPPksK;2LJ@6Ow|1a_rh`Ia_3 zCz8XKPjE6$+UeQjEEolMjiv@6aVew$2~quW1l9|xBf{g6&T?nPWn4?RngzgRnY&@x zJ_Ee^6$_V#zQ5l+;0M6Yk@2B86jG8Epc|vA zYyf^@JVC|4;T$KukwFbW$-}3k?e>tPlI=m1KvUr8#w^RuqMHD5q;0%~(wB?%MZQ}g z$g zv?kM{l=6RVg8Ryg=->4%)>_*U1#h&%DyM0@XQk8F%%#5_ca)EL7m#2^vsuS3KFq2i zgzveTA#>UmmGA-4!rT=U@RJ;hnh_<`paqOi>K{{S(eOeZTxeRq_I*LnGLta#Vbx_X z+MJdxUB?yvTo5+PmX$B`;+3P;PDlPj8y^xJEAXRc^S#qmI&Z@Kx^TsxImb#*`%mnyyi;eP$#kR z8)Ew>LWccwTxxL-vfGu2_FZDp;<)>4a|4WX?Ab+c-O(NTIPMB|^HutHSFJW)5iG)m;o3-ncJk zYC?01yil>aU+`*ufwbU45SB+%jzb2(`kNN6)fSnnQZ5yHI^(y)-OZqW!j zE;6?N(`D~9{oE{;+MA@etN6>Oc|pu{Zo3X2an*;}Z`|j0=#K&Eq6R60YGVD8_@?vHmMFo@fr)?^U3ZUk zquX*No;HuqYa=_<@_P@ci51;4$>Lf-WR@;8`p7BOHdj z>HQ(hA>WAk$+;QV;uh@H^!ju9$@3R~`w}W~ARGMspuAY~XWlt&l6T&S5V;(&b-c8i z)x^m;Sd@dWh)J$G^vC_BRFI~S6!Ln=P;&zLy3;(NR~7_>j0rAKu#^=;6x%A4oH2UUiFn!1H^6zV{IX&x~OYbB{MbNCndJ!cbj;l*5(Y>yq&r$Nztz! z4eCqzxn9H(c0=-`u%dSij*s?7sjI5-&w!4pIZq~9V8Az~S?xU37mg1|q=_GINdGyC zOMR-SV7|ys5ds*Pz<(_pv0M1SLCp)*bx90CdIWK^L>%n-nbd`RssLdY9*SO^B#22Z zXO=2ZBh^Qe`JhT|aSQ{uSE!KaHd(fHc#%Bl-^agL^*K(JE31CLi#_;o8mLX$w4JPk=}ZE2OFr^F z2B;9h7pqQoCS9soMv@UXrk&UaF6AqXr;YUHQv({OqLg|b*gtL(Q`iCe?v zGN@4HNE8B$->P6IfXuGdu%Xlq@9e|ExMAANXE+B1i;bLU$XZ9z;nRr1P|&5U0wUoO#K{eN$Vhi!w(AztDVU><0{722!f?Vy=_xHwX@KYvPg>>u` z-<4`jv@!vkZq-7-+51$Isb*10JrkM55$33G7ZuIQ{$;jtPzoZ@eAAw} zyGd86OUSD;(ob+iX%xVl$#QY#fR1GPcnv*a*pu4GiJKb1O)g+75iP-Vq;Y5I+`?IL zl7_-Oe(XvggF*G3;Ks2vv(`MTjdeMEhbRGyvRB35@BOVn_w*c9!zz333-S>cDD+N} zyy(Y(s84Wr;K|UK9*y8dO1qY#SPgl8o8nw$g}7WZ!jz8`dy`f_xxvz{FR)t#_wmK} z`4jgavkPcAGKxm9f$W~oG#`ceY60QL&SK6#!>ieoZ?TXIbT2z@Jm>xzDJ(1|!8#NWspxS~-^{wHQLQ|H9OYf0REX<`W`zZb-BY zXcb8gYUNxB0JsNta{|Sk8lV8ErVQcE%^?(el5qyWUMfAn?zB(OfAq%XmxVe$Kd| z2^{u`K>B+73EPNITJ27b@=|PF1oCeKO0MwJRo^fMRvT*Zf0c%kL59<~HmG=DKuse^ zAO|y*Dd?SSVG9e|SE`C`>=XPyrwo3-jvm#oh3^)`oS+0$aGd{v;&^7jn*@As}n66%)l>T2n}&Vvxd4VEE>$! z#8j6TyJN@O%UgIn7}pI60=h>-`m7xM(|x@|K~cXsx&}HY`V;P)-dsvAmso-NmZ@}e zN8r!&3u!H&05RcX$l9S`I9X`i^6?armg?31Pc~0Vzpz8BCyDik(6jL$M9~g;Mf`SN z;ZpiHJ!*)W>wr)j-i;?{N`VVc8!Kc?VxGQB{yD01QV*E6zjD>S`Aa3rT(}edErnc5 za@mmn6ZYZlV*H|9)Z~X;hn`Y__ZwkL?BG27TNLI%$ky)Z9vbRpB+t)>@4`_MxfPEE z9we_Ku579fnJYhkMC6t?&o1#wsyOFm63`V-(^NosPr7^6O=~)G=O?)bv8-Kr}vT~3Ad^*lCjfY~LFe-{L2D~=n z9PL#A>(83Pt4l+wr)!;hn`YA}T*EB1e@Vvk36|l$PU3pI%iwq`Ha1vUf{O72)DHz? zJPvS1W;2#V9--4l|B!)Ms04J;bM*1(pz_HyD_3Qc)OFrt{VuM11er!riIxOO*4BJA z>6xu^Ykx_gbR*eJH7^+Ra5U+5=)Ar?{M^N}twx_VS(v4zJkC}Az<2%#cS|M7tWXyw z)hN7zI=wLxKPYhVnt^f{q=+BEyMbC6M5mDqXfRaM9)i!NcH$qX*;BMycA`Ar&U4l( z8zXZ_lJGyG{?G9i56#bv^Cf?H1??n>12?r#JPwxcqq*IA_$~0?UWRvuz9((e8`zzD}7=~z;N&* zZi;jfMA;VV5QLwhUp>Dm=;GxVVmKBhRiq2X_`ogt-7XZYy+9Ho#C$m9&{|03yD4%~ zfvDm1n;VjxpJSk6Pk5!f>zEKruQ%Q;|1J#X9T_<0`V%=c!_@1j0K4yeZPxdEsSXqr z9kR(yAe#GCdz_5Ll&_;g1GFwVuq4GwLEA>)chgXkw{03POCH`$go71#S!$IIC+LtF zo92-LHVgPoMJcRB&J=xsfUG+5nL-4Oww`ctmg1budOFmQ3byg!34kWt&R0aG<~vqD zVgZo0SK#%o=PX$Nevxwj7fuYIibY%ftGRRTO|e|EU{Tluro#0V&|Y!@oS=RZ_<5Pq zmd{TJA-r^KnQM-5OP))7vKqKLLydC6wjZ*jGl8anWH(otxu_shkSsNko^h_mnBDDX z5-^Karf-_wA9lQ$Y*jsS^}|7>kIM`%Ta%144>zu7@nLn>Cf&XqYB8qk)(!yS8o}CJ zEJb*)#GPiEudArO{x^Xh2x0%w(9Fx7&s(A4vqus7fb^#|oP4ymXov>0m6k#I%PEl| zJ-Ku<&f<66=r2thzK@KiC1yFV86?0fJN-*NTkkXuT#d{Mqj5YK)W3IO3VZ*{Vh0m! zJNtcOTI34>hJ9mRWbB&@7D!HvWwopft(onHBX(4JA+gh!9Nmrx%kc&Q9A&g4k(EMBSRH=rZX+|#8`1{2X#RdHq zboIjG8bEeq!ETU+a8TusRg?|QDW6roo@>GPC5N@j!I6d!o}SB2rhOy=4DD5Gnzf$1 z21fD{#END%t6yT?K>cWXkIYmw{@7D2IL9$h1jljh=!h7xh_75<6}T&S{VeQTqRnR7 zijIkV^0z;-5{i6pzUhsUlb?HKF;7n7eQ#_+J>hIZeIn(UxWxPDxKg}0go3||3e^TJ z8rR2aEv(s>fQY%O7o*QHtj`4GYPC;eQbRQTLSg%8L(s{-z(Imsoup99sGsM9OFPe4(lFs0S-leew zDe=@?Vv|m4`1i=-lD3yBJDOs*ZAE6r$U-SX;-aT-hT9qD3q9%;qG$q7F1Pm~gV&fK6J5 zk7+^-EFbh|K!s7JH!aKYsH} zsAK_h2t>_wYP&()zTWZ%GP8R489%#9cMdx*;f!@T5Da$QyL&xyRYl_uvvz}>NzVoq z7S+uNN^p{sY=u#LPkg%C6LrQR+=Fc!e7gj~(FvaYLKm;Y+dNo*X#Ae{bJRq)Mr7d0 zoqYC$H$t}_n?PcaNWJdRSsnhoHw#_6CC6Ck?L?jWhz(^AeUA@b!uKy;s-Q&I?wNsM zK1yN^dsmZt#xL3vy*C#0Op_8&KAePzQR2>UFg3Gk2;=@geA9Lq07(BKpmGe(3=Rk+ zmTIyDs0^UY@+=$JAri=n)$$M~Q?>@PDZ)r9&GSr|HefdL813NequARKNlfkn z|6MOE#HpE4Jx?j#ElRACnp6qDPhT}6Dg>-4QitaV{=xstm7M0FTY2sxAKt6iR4B~N}MimknK z{{QOw3aB`e?`sHd!QI{6J-E9Qg1fuJ1a}?W9fAgjKp?n7aQ9%r32yl&`Tuq|?4GYV zb7nYwZ@uc8SJiL2s_r}f@}mW}`8K^ayzUrdPI{j|1P$)+S@eo_@MLR+&Aej_IbWim z;T#r_XzKR&- zReD394QVJAwn&LFUHh@6BrNu%uhaXVUW>6d;oF)SDprHkJR^c#b5ZxwRhGpHqD%4x3&hKA?~zklQtvz`5*CKNR6Xjj^5 zHLT@3T>{)JukSl{V`3~03u8U4k~oMJXx5uYivja8;OZ{NYO|P}1j-I^+zluIIS!ys zX=JPKV^1#<$0Q2Xe^9hC0`|CZ3iP6&wp)uP=D+T_ob2E3)u-yOYjJE3JVcT?e4=edFX+@E?|^p*#`ob>Qi%& z(RYjovU_S5M;LWsln<%XXm;E}q-dnE}pvwm3pQXgdy4} z>O@@Yz?cKwIMZfFPzC6lbOgutdu=B(=p}hh3sHn1jIuiL#>ty_>Bgi*Nr(v_r^)dE zrIaGO2zE$|g(&be6pjir%{iJl%-?>5bup0YEw8x~qd4QOcX$DT!B2Xk)oiUloV z3&>D#CWwq~Q&=zQl*v3J7(<_dM5`u^g|&}|{@cl}5NiGsyz3n7N}Ler;ggPZEXLod zHM?MEm@5M-ij=;hr}qlKi~>Jb)t-L@`S36_a5Iq__-2CuP^Dp~1bXD=f1){8MQ=|w zitXFQYHIt%P&OXh;C(o$B1gJT;QC1uAN^Z7d;ZDXE#|W*Vh&W!#9N3*m3|kwu7oj+ z2m|jE-o4fd7w?JI;|YUx02qxiXDClU$9@Z`);69XNN^#3I)0!ik`{>$p3a=3S6SUj zt&cI(bIiUG(4xVdp3)W1v{;~G39Ijb7ot1OAh6TT`rQV=B8P~2Q+!}*?w!Z)xF$(D zIZ|Tw$pe?jJZQ7)0v!|DY^++X77lm8wz)jiv%f+i5;5rr&q1?=rT7#EQ-?|H`%tXS z(%di;c8Bib4%5yR(g<6xE|5P~q$T7F9sB3>{dYh{z;lyzJoO3$VvlmuWn|}W2vsv) zNFBX=MJ>m97U`&j7}iR1QOd$xi5($YZyaXZQWR6m*0$O8bV1Wo+Nme?x0nFUrsV?j z%FK&i)pwB7s_Ig&WC$%-YYK@iTvz%(MnVxrX{**&Zoe)Ya|!6HYq<~TzEae3Ua?3MHhYUp^>fElqmt#lk$~qCd;J=Rl)~P}d2*3$ zrO(?}_d(nsk?l2Gd_a1QEVo7~-&NMUb?CO~uK7MwT=s_haO>(bbMtMHMg3&4yLYuK ziz04*T!~`tGz#=iIK)s9fp=tZ{#ODdWs8XfEdZH#nk`%EI>bnA4eSop4a)PkQ>f!& z0XZ>~Lg|l)p)@H41jnR4sArn0tgesR4*#&0_+{4Uott*sLS6iA0l z_~l)rq&`U)-0)Rt1osntkVZG1q2~)iFm+Lv%z75yOGY8IC7Q>$!yY!tKw+(`r(l!9 z8rpYw>DBC3TMu0zudV_j=LrM;^5?lp6)v2bVP6raVfbjaHz<*?T zTt-O|o12Z&#n`FI}$7Y*2yjyIy#>3z~??>MSxem$Ug~-Dtm6wLQb#6%|PwS2z_LY(#UVci% zwAGMA=#XU?!Y$xccB2t|P5y9zlc{Bj(R^e1X@>hdWLwDe;bvTq>(`!R^ZcCFI{kbQ zy0?RM7;cj(3lCnQD{fyAKWo(6n;1V$}6EzD? zbH{mF97Vv2!*A2Du0)k*d@;j~{3-YZAY|Al=#g%onH_+9-#09N! za;TL& zWURYb!?Y!!cqF9vlGd15Cfq13M^JRA!zlQhI@lK)dr>_xYnA=SfI+kdrim;Pf9L~7 zeu*t~?{QLx3HP$OB6f^Hm};d603sDJ^MhXC5^dQ3N3!`FrI2UzyaY&!jf ziNSjiD}9i6ij!R@2^3~cG;I$??)0Xl`bDJ{q_BdY31{$ghWCs<`j>;} zAYAO0frKeafW(Dw0IKhPX;BE)>(n1m^;67BciPTi6oyx9MHRtvDeJ=ON}uOD08JMZ z7eoU8~xc0xEtD+#eJoNXHSh>i77 ziCx#LPi3*1;r*x&?k|7Iso{DwD3C_47N4+G&ZP?n3ujn{tudbK~=fI7uEl|!x za55Qp+c%0L{aG|&Wa80rsiq>VS;UD4Z-&#su>|oP27{_)^p#b&{A24!V^)k^e5I0d z)7_c|R1JM8KsPKhD0Wz|8l4xiUQ*!U4nw?3{rEwJn&=?smhM=!oIMMI-w0f8(7TMC zfVl%cbZVw}-R}U5{jtWh8yH@19*aO zs)<)mST!_E(Qs$Ah%F6VNotiFZjlw*a<$e!C`WwRMNow)U8%N?yw1O;v0y0`HhZR~ zd{(+6E)%3l&HsVwH9r7v6ycbSQoKU`a+rD-8U|4V8m8KT1jYOSwPQte6%;{_E*8Mz}K2Yt-jDgal!Epu&1G4%I6ni+$PyJPj2t$ z*@vGP8wrDWFc?>(p$vwjuU~fcgEnQyGfAmlG$|y_WYB;jE=uHOZeKdLKq!meY(jqSAkcbqq~0Qr+N@JTf^|> z7|IO5hT>|=00!I;D?EplKVi6q9tl4-RuL{oj<>`X_Ky>jf4&V|c#kD15Z>*f*f92W z-MqE$=JL#|(8x!lV3{eeZ}up?gwLA@HF`=ZEuUCBKVphUW+Z}kIz6#N-`mB{)GjeO zuYQ0>v^$OO%~YBY*mCNJCpJczQ{XdcNgj|g-be>;TJS4B5Vn-M1sab1$E zm#48aoV*P+WpWFTWyX01cl!ZrW#pciJLv>_OdlG{{Apb8NX1{&`8$fXe6qkLPV>+< zv>#nkL^GZJRNo*RI8YHajqP>*X|^ji`9W#CDyyodtI(Xhe&HvX09$zlVv$AR%-D6d z)o?LIXt@I8c3u=rFJ6>vAHv9oohsJzN=lQN4wNRNUT;)bNnO zGIUqX3Upd=`PH6koA=ZzbQ8gO7-0EOJ$|_;t(j0a|B9C&Az|UtDCR^aFV&*JE}7CM zct_XgsnV2ax(?OSo+1Vdcr74ILEv-OL*h_Q6xm-vz11>(oZQXt6Q)Vd;Nf$jfh30* zekS<9n7lJ{cL)`F01#onoIft1?@l4;yU&Gf6kTQoj5~a8dqR>ep8uS({;|n!@m)#9 zN;@K(A&HqGZX9PNi|zF{ZPWk_H;56Wqpw1>pHhtrU_>>uI9MVP0b=(aYlsy;Tg9{z z`+AIVTJ3ca)jt1(TS6aj&fFBabhcbPxryw0B`BDEZTz079G(eAZjMBUN=6r#0v>zg zRC*JqfSop5Pb9;Y9C`yyIcO+x zzm_jAl`cC*RyO#E0HBBrR&1h;K*J;|Wrp#56(kVBFwfHS5WmeQ>kB3)fQhOb8|4lw zbBtv#MbYdS#-aEvg(b8ti9O0{gYf%e_LFnodtC4-p$|Cs;en>t47H)~) zX?$}2c|=~7HKd_|nqOv4`eOXe;uXYL?|ZptIQdQRRr+3yEly%%s>J#h8RzK7CmO@F z2Dn7&8x?M-QKKXNmm_g248=znkn6UA=4{!1&(|_n5CNc?Ke(u@C>z?U-FT218;-(;Qo?qw~n1N@&~IfBhg zV)1i1FANo0Z^Py2g61AjoSFR&25Ml_y|;r6-MjOpw2$wn_}g)A1g}IcA+D3!Hs3%} zi{35e@BqecN8?~aO!CQ;u8dLZ>u>dCmE2yTN)Qn@eagpcC8rDx*n9nI{Wy5LHeT!< z=~#tgaWmYww(o(D?T6calcTW2kNT%MQA*uN!33?JweOr~?6c4={9SPqQLT$~#(PmU zMR`NajZ&3muWZ_t_At`b+@1K@7e@0@hI^Ft6a4_}HMzxm7{edpo1HE5Wx(<^4G^!| ztD5b6{Uytul{mUiLa?njKL5Dd;x;$d{gh9}@C{D$$!%uAfs^qFvo-$>6ebh5F``VqlZ!sio z4-owTZcW66$1y8vUYd{k@(|k9uc+8XXu;{~P}cC3aIR z5mpA^I1=8VZ9FUlcMu>~FL0t~u4j%mm87Y_A6O2C0ei6-Vr1U34~=8w9%1G(5alNp z2`X+iJ7w-rvH7a@L|VB>B6;Z8*9HTBA}Gt@LO}kL_&gfSDrFPoA@-mt6XD+;5>!P3 zP*6h`LGh~}qB5#~1B-HwfU1&6i2|EP7EV@Ntbw5$;=I8{@%He$dFqmFQs)NXoJ7M{ z-Rlg-^I~hhD+-(W9-G03>n*8h83j?h>%`@y&-9N^^{i`l{p(z|Xcm0}%00RVbe>rSd8z=H0-G`V>!Ztc|Ro2O|z>5*Hs zv|Apu2Ab`rWUEFj_hC-YRkS4V-3o|>w?T_J@8ANuT(FvBRxC++7tPT^(x!LC zeN!{Vw;XwkrDNdMLRfiKzceJs$0+R9GoQ7S9O zX%Bk`hJ)OFTwqrm;bzOI>HaNaA;vfg%}dew=3-yI4n_5~E$58Pw`%130ScY$-nbpf zB2I>V3=&EqrH~M_OvYfm5Oj<1g-o<0RzkQ_0jT8mqX%w!Tn+ofso#brX~9K&F-tB%4712UD#V6Lhb4R=6E)ZGXk#ziZf*5ta7Ru zeTwaZpJY`1{CPS>1Wpp7%anjdchn`Rq%7aTGFYr@bf@cz>LT&vWWK4W5%76vTIqYT zk{GiAQ`L@6Y4J38hwtGUwK0(L5e$59plEy=ltYGqZT6tz(*E|t+Wt4te$qAd~1@G0Q&` z<-A_M6!?&cTaIf!D33nNHWH5@KbpK4I?7tK)a%>&RXk=?L;MHg2i96`FQbkK-9}YL z1VkN6<5sj5Y4cmt+KXYdM%61XAohqei_gAZBQ#dXL;}N`koA>M45^I*2Q8p0b%-UlC_yT3)H)GOZ0_!xv)Q~i`dhOgymy;Gs-5hZ65GVF z659lNzd8-8?jJ3~N$+ic$kglFvi4%VF|2|OJKnETCc&yV39PMH-tLlaVI@KeeVZ!b z-Uw3=;7MUU=-&0sEFkSO89{wdt)YNt%xbPQ%haago(*Pnr$9S^onVpB9cv!4fR%p< z2f5cyY^i0t*g^*ZUeYXmjg+Pgx!-tYNvA5N={x0;YMY?FRQ6&TDL<6S<)}oLCBxc6 zZH)IZeDGTu!w;VY(HbAd@X=&>^V~g1tSye(~^< zs{Zhcpw5S2uOi zQB{|Fib#ZQO^cImk;?I%bW;SdRmkE&-#v_{2pteqFOrm0=AF2buNo>6Ho6lPAdVT2 znMLXwi-z)p+jl#1IjEy>0(Dn&K-#Q)s9h_G9Oak-%*D4~o9Y|7;U?hCk*!T9-}97W z)DYSm+5RxpWC6gE4N0rL9xLdO#qJ@^kXe&9cF=kwGYE&pialj4wcPWVm76in0Ysfz zTmQ&Vnmp#I#!kiNe7{j*fhWktuFba$1P2~-)CG6KpBQ1xwF3|Gb*_#ZM&jM|1@Y81 zk#uI_U;8*4(4oqr1z=Sm-B4<(41tL(cCX9?YO*UeBLg_+>LZ+B3RH5M_)WtWW2{ed zf9B@6!bMB>q0LSZlaQsrsl~WwA!3`3GvL6ZoU5{(#gl4Pe9VtHA2A8gBx(|V3b$Tf zPKQ&xMzyGvhTe^h5Py4Q$+noKQ*7WfvsOJp!OPC3(5catrn=ZTeL~T$hYsuJs1`>U zD>Zw{R|;Sr>|G)gtZE@6biypt;WRC|FkdVN7gFn$7_yo|oKVgf_7ZhP@R_gvLew?$ zTs?}nEAfQLhp_l)Yi`=cQ|CN^``A6yZ2{}>jDSd*IL<*a?i>c*N{(iF#z-RrY+*oy zUmWAZsgv-*O8arPraX`n8&J`FoQD#U=FOVImj!tCIde32FV{Y|KV0Z|mpye0j?Xwt zZ{2oWEGAl z&oToP zepyQm_cv%c`=m_IdTro-r>@u!tzLM^og^@8*C}12E6D<|24vAO5K2>ZE+ksDQ2mAyAHMk;m*{Ja zSYsOW3eAZ}v<@>)kEB6ZS*BE8eJ_}lo=JpBn%aggk!G1GsFSjs54tdu>mY++;4LFK zk3VYuR&nu>(g66tc*j>ZKz@O=)lM0f)bI?$LsG_gSK=F$tc!K4E^?vS4_*_s#Dxsd zW^oC3(_DWL_qtJ;6)=}c_2sv+c%|0Ef1Dx?$tD;^)Bdw!FmYff0#VJnaPmzlD5C^9 zL}9_#?#``shPznj4d6u*<$5wT;$2RN!Qga?z;v7-)K-c>1=;vJ0{Yh$kU0?HDFW2Z zx)<#PyHtJmNuO|~Yr94J42MvYZA|I`V!5PJm`|)&2v&_~B2}Cy#tKWdQ66UH`sPUA z()4+%c(|%qaOe?(3*7UH>pzBgI4;mi`u2%3x{j`P5=f{9JzK-{7-$Bpj7p-Rz*^r!i z*b#;)N-hJiMIu3zSr*w9(8`S8$v&`>m~K+A)l2TR~4mQS3wSDV!oyjvE1U8cG9 z*`yVXcE+)#(BYa_#4?Th2Y`31Hw2$p1ip@0Zt?3v90P$wZ;Vt^4L{EcLJPs^(vE`} zQPQ`YN!+GkZ@2CE;XeB=AHTH z2#}~cOByRmf37f4Ay2}sWxJP3e%thmx&q%pjEm&{v|8FJYKceOY;fwEdFH$?9`?g6 z|CBJ>zdH!b`5rqQZV|1SIYnSujIg%pTi@h_S37tZPDX^*W&1>tjrpiKlyJ_+4}Mlpesghb8=o-fho4c2os`xoq_7MGrb{f-+-&qdm$|DeVkyV7ltpy?t4`k;|Z$qwR!MH4wL3DxuH^`pvJ}m7YDg#ebP?2^9SQ2Yso7epi z3n5{?5poV=`w|xdYy^`1mALx};!7WM9s}*81>YwK;>;Im4{*B<^<-UpY-d66geCbS zM@$^|2jkVOFULeswB$enIl5dsExvWz%U56XB|#O@s5gYJpZ!REpd0fcN@t>)#u#(W zDRJ{bE5bOu@lIen2Lxf$0;7{b4|b5MkX87`E-a!717Kx^9xp;9x9O zMd+Uq|IWlxmRzK@xekUBLp{S_F2-3xZ!HFXR;ld#&Tq9$;Jt4rvlWv@*Q&L*cTg!` zGl^Df<#&|bDY|D>cg(r8bh5EvX?99y?;g9>sac-_gs2ZuMHQLar=sVE-n3+D#Ea3P z)$HtaZLIZM04qXLx||#dsO3hS3GuW@$r)op%`NAqL)0(ON4=Q);^_9>k|jM<{j25z zk&K>1T3av+k;L0{kC92lhWO9X=9dK$pM1=)Pev}0F zh7qI|-4jW5A~E@#Z>K#AjLZdU`U8y@O!i3z+f;Xg0Q+R&E%!TwlX&(Pdg`!)55}GN zcQYeVHQT67$cAhk0b&G#k*MC|dFCngvhN7^g4z_ixX&5W(0Yugh(wthmrAt!R=BxY z>H0D~qg|oh96kLSj*$1avalO3UUiHm!0cS03&c|I%k65gA@lNupR(Nak2suB`+j)5?2^5seZ6Q7IUr5LU`^Sped)OT8p9KQB`j?QDo2NN zEz;CoKVA&oe9y4Qj>!DW1LgpW)K}$$FR1+*RA0+Ac+s z%7}l#J*x1Ew85ps_xz5J3njZgCU7njO|hq^u-h#su8h6;335PYub&3vM=6!-S#LNu3GtRDJZ6_@fCmD%a&St=HSm?!#?S9qRdUk;(y4C!5$ z2Wa?;&5?6^A=y4hG!x9Xu5I^2+TR%=?{Xy*cL|WWA))&Eql0^Np)%N($&1{vG89aB ze@qk7SKY3jv9BQ2qawJ8#iJD1bL*XUb;566j3(gWCw4tsOT zu0WS`SJ2&%r)}Ms1=X9hgm-^pX?18d+;BcM@i%zxlX8Od$cLv-T9{8LH6spA=uAYn z2UM>Mgvz~(^oJ&wT9v6t8k6O(iNvc~rQnpcw7~Ghz|bU=@M{cnD$>S-u4^dO)}#k8 z6ZGJXRdWkBlL4w8DD~#PsUpM#_S;t$iu|BUyw+1xgAcDc{K3s5>BTmfxdIpQ+T>I) zdY(QTxvza%Z6oJ(*LH&8FOIDNmkK`aN;q6x!)eQAmN( z_arIXb|FzM=?m4y{HZ>|v7JT)_$yCB?LowBvBILL{^FnXPWVtytz$W7ilf#p3ZP%( zdABqdJI%$*@Bwm2;F~Y{Z&{yXU2JGoCO+gT?jlPGcPZgsv^u{6!~qPOZg;!@kI{H7 zEOJEM6A_R0y4Ps_`9D<5=54QIb2{Y@?&5l-Eouy$#uJ>#D7OW@-~8h`*TG_wtu6EFQ3pXQ6UhZdk@64<|Iuf?3-GaZM?GiEJI&BgAU z*6(rgJR&oyvTs*jGab==is<7FfTH*L+WU1$`&3{38M^(BUMrcfr{ppcu`eRBvY-8& z#F-w)J(F9#?5%$kUMrT{)pN9yyBRT+q85oL@x#6u@R?AtGrC}%Zod}yQiF4C0Bvns z?t>Gd&`fP@_>%g^@kiL7ib@Z>iYxAwsLv2TKR7v1K1WekDcjTf$E;gM0a93mu8Y^* zGoAsipTXzwPNs3pHkzH`L)~FkZ`zc~5?laJIowj%4sZ8%TT)BQe4X3dJGO4gv>MyK z2%e+3Yhkol%z9qBZOSBBJ`>s64R_KdROa*-!V+|GVfn+yX8;cwpXPHzsves@<5$6; zbJ#su*}MF}`cwfkt58lR3rHZ0sbf_~&B`~z5|^)I+;3FAcktzWs)?mOsqho?EoYy=0}nW-cgkJ zBTiUh?B80TC=nhMW&q@8pf!$W!7B)X5VdB z@{gdnmpBN?2rgP*!8?vy9_{xluCbmlwY-}!vi9GnaTkPXJ3Zf3=XFVAoL!Q-eEdk+ zJp|17#%wvyA{40nSKNp{QY?YdCV%Y>1_R{}pqSkc&Sn5I@tb;JiPI@)W4qQd3dq=VzYTm~PPw!ah&*Y_Hs6 zrxU`LAjB$Ih|sb=o*rKQFz>~noBD>A-1PBH?gx_$7 z^fw3ZG~1jJ>YM0(y)}%AybnJ0I+qM z8c$#3@aShcMDRA~;$rvPzJJUM{4T?uJ$o!VuI%{f@leGgeX{;Q2*%KHbh!9oidQh1 z_1>J#d>!4J$)QNHvXxgb6OX;VAJ=PZ10}&`&qZ==aA{n#m?-PrZul-E&1|T}!3w*W1$_C-6ywH5EAq=CTR}MpQQBtupjTO3W zf)+MLn^w$o;*1L31v_R%Z7VY{l+{LZB5T^EM7u3>_Vu2CD2;m72NJ_3dP|0LMI!UJ zy#CX>l(~v|@LS8pnNHf(W6xt;{!64jbE6OYnj30bjgbx9_pC*Ety!UccK|X>Dc{Su zwRORS6hdH=&<_S1WzpI+jYsi5rR)?{mi^R9JCUehN!yAA;FKivR;C5++t{mnCcwKtLSjOu}3)e)Y24ZeTp?^f!tTS3mf7p zj;_mC9r{`m-|vhkIs522KLHNZRx3}ic6CY#Wzfe$>!Tz_<~(II;m&i|#R0WZfcAaD2r$haGr_Zct=9CX^4=NW{$leor z(@~;M9Ha-x6rbbL6zRz?wKL!;u!+zXWjdSz$V}_rs~l z2kgS%LUdapNu9ByeG-|H5fmPYo&OpIfl2JiZ|N!g+L|Xi2%c!+mM?4MAS%!hiTDc) zFEvMdK(33n!|2P3#nRg|{e_0ffE?a4EnN69{iMU>1|Ehn*_(=#8au$6RPl*2)4 zlbz<|dM*Frh|g?a*s%f3_3@knA*Gy;;M`uqcC7rK`4lF2_L~Z^u}nx7tRIXZOG1UW znj-n}guoL&M`MmZB;s}{${eN_Xbsei<1it&5#$=bBfP8Do~tvV-9{@^B1FL*Dfg7? zp|iK~#HU5FX?Z7O59M-{FnplC!q5hR3Aybop1CIJqC=gmmqG_9oq%jj=CVCj)?^x% z`XoltSa_u8a6OZ1#Mdd|o@AF)rIRcmK7K}iE_*giXn)V*7!|aVr+Fn7m>GfR-{{ zluz04G|%4$1~}R*%tpb<&~d$1r?e-Z(VfN9l~6-blLH){GE{YX$wBkd{8YE}ZE~9J z6^0y3(!G7~!xr;LDO`w=ISPpR?1_uKiP?M04j_)5+Mt` z?x`FEQurkCZ&ylg)=EndJUGRwrg4I@;3C5*2`)_-%T5vH??|zXNf+ql4zSD5NYVw* zNv*1Lka{8`(Fv8r-?oK+XgMQp__PAZlFiy>x+w+aXVlBx*O9c9MNu-ohH~sN;i1C} zE{y}oK#BKvKwA@9^Z77_PSc+4sA6j*?xfF3mP_IzcnwMxD-s04JwVtWFOVH)Dh1)vpOX< zgkTAP-AReO40p&oPwEoHT$jO5tb!-zI_M)56_!-*{j&RX)*ZDb@(&33{GO<2i8e?!A1CUqn# z$H#}KhB&zoYiEH5>uu+rbEj)VQ9}U(N>wloV@Oxz2EzZs1fat-G3F zNHhz``VF`KVWJfr=bzbQA(vD>D~*IYzqi(TN}QV+yj2b)1vz9ADXAfhOu; zU~L{+ld(X|U&fT>z+Yj2VI<^XN`TZ${&grwsEaVlC`vN_5B0YWSSR+7lqd+S1$zA| z<(~ssHbwaAS9!R=jVWmKUnjCbF9&CfzX}1F0=}Mx$NP1v>wl*#oQ+Iv&Hg|j|7QMz z{63C;rCwtAcP+?x6zI92z`&T{!N3^*g8qId1_#z!VFDFF4NWwIK>e$Z|7$%>qSC%X z5M~ziB7ecjg9hf$5CQFH5dXx_JDYYOgQoP~fEImlyub&;{KhlQ5>ow9{vq71Z6hdI zR2(4~80icA4loZ?eB3MosCj@{v!tK`7jb}Hvpj#AqxCN8X$Nf;i9iGcV}C&(!u@?o z<0K^iX@~6VAxLjf9YaAyzHEm%5@5g_$DanMHU~htWI@XbL9PRuo&Dp;BnM&ffw^>` zEl&UE6hP$pU$tCe`fXWe9^=n~_?M{#0HBDM8wCuE?2lr=!1|eg<0sz00Q2UN{^a~H zyRu~heHW(mU|=LKI3e8s;~@QQTzlb{@pHLfCwSx?EG*2NnM`brTwMO`1}_q@y(l$X z@pq~JmyeC`&zii*>-BHcHjZ#hvA_@ze!tbm9vlz%nouYO0r4F5}c6l~4v z|GMP=`kjfb)n9!NIOc*2v|WY;H9e9w(7_!S_~e53ADjP(l7bYEUQ{y87DN*K%O((i z<=2<^)(J>jOaRndA^7F{xxTm{@BZsJcm91YYx#wD{VgK+EAQH0(BJPWuD|h3+o*rG z+6xJ(7x;W{5S|>QC`g_ef zNKT9A1!*?@H%Xxo_1}j6ULg&-6}%iXr!zqu&i@$sXASDVuVy+2L|_KWm;JY9FINA( zs99e=h(r!#uJ}*QUaSXu!67L6%?YX^`uEkmSUdBAqgx8%kb#!byttwL{p0}@HoWX9 zVxNHEBVt%ld#P+akYuD(kmjVBZ1Y-30%CTJdGL z@@GwzhCsm!l@{o(_*cyGXT Date: Mon, 31 Aug 2020 16:42:13 -0700 Subject: [PATCH 528/673] 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 0403392b01b64d03d27762074e10f76f7cdca64d Mon Sep 17 00:00:00 2001 From: LaunchDarklyCI Date: Tue, 1 Sep 2020 12:11:21 -0700 Subject: [PATCH 529/673] prepare 4.14.2 release (#205) --- 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 2daeb9209bb73262bbb9a4085992905673fcb537 Mon Sep 17 00:00:00 2001 From: LaunchDarklyCI Date: Tue, 1 Sep 2020 19:12:02 +0000 Subject: [PATCH 530/673] Releasing version 4.14.2 --- CHANGELOG.md | 4 ++++ gradle.properties | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 524b685b7..760235b39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes to the LaunchDarkly Java SDK will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org). +## [4.14.2] - 2020-09-01 +### Fixed: +- Updated the version of OkHttp contained within the SDK from 3.12.10 to 3.14.9, to address multiple [known issues](https://square.github.io/okhttp/changelog_3x/) including an incompatibility with OpenJDK 8.0.252 under some conditions. ([#204](https://github.com/launchdarkly/java-server-sdk/issues/204)) + ## [4.14.1] - 2020-08-04 ### Fixed: - Deserializing `LDUser` from JSON using Gson resulted in an object that had nulls in some fields where nulls were not expected, which could cause null pointer exceptions later. While there was no defined behavior for deserializing users in the 4.x SDK (it is supported in 5.0 and above), it was simple to fix. Results of deserializing with any other JSON framework are undefined. ([#199](https://github.com/launchdarkly/java-server-sdk/issues/199)) diff --git a/gradle.properties b/gradle.properties index 3415237ae..7bbc413f6 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -version=4.14.1 +version=4.14.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= From f3f01ec61430cffe6cba51a9e57be64b113daea3 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 1 Sep 2020 12:20:45 -0700 Subject: [PATCH 531/673] update okhttp to 4.8.1 (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 331669ff2..0e9a726d2 100644 --- a/build.gradle +++ b/build.gradle @@ -73,6 +73,7 @@ ext.versions = [ "guava": "28.2-jre", "jackson": "2.10.0", "launchdarklyJavaSdkCommon": "1.0.0", + "okhttp": "4.8.1", // specify this for the SDK build instead of relying on the transitive dependency from okhttp-eventsource "okhttpEventsource": "2.3.1", "slf4j": "1.7.21", "snakeyaml": "1.19", @@ -87,6 +88,7 @@ libraries.internal = [ "commons-codec:commons-codec:${versions.commonsCodec}", "com.google.code.gson:gson:${versions.gson}", "com.google.guava:guava:${versions.guava}", + "com.squareup.okhttp3:okhttp:${versions.okhttp}", "com.launchdarkly:okhttp-eventsource:${versions.okhttpEventsource}", "org.yaml:snakeyaml:${versions.snakeyaml}", ] @@ -100,8 +102,8 @@ libraries.external = [ // Add dependencies to "libraries.test" that are used only in unit tests. libraries.test = [ // Note that the okhttp3 test deps must be kept in sync with the okhttp version used in okhttp-eventsource - "com.squareup.okhttp3:mockwebserver:4.5.0", - "com.squareup.okhttp3:okhttp-tls:4.5.0", + "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 f5a8555ad9cb28875cdfdc8b57a1e870879c2983 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 1 Sep 2020 14:12:06 -0700 Subject: [PATCH 532/673] gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index b127c5f09..cfcafe7a7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,8 @@ # Eclipse project files .classpath .project - +.settings + # Intellij project files *.iml *.ipr From ac9bdb19979e2f423089f9b656819b323c3c6970 Mon Sep 17 00:00:00 2001 From: ssrm Date: Wed, 2 Sep 2020 20:44:44 -0400 Subject: [PATCH 533/673] 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" ] From 4f8f980419411ddec40e6e8825cc6bbafeba09b8 Mon Sep 17 00:00:00 2001 From: LaunchDarklyCI Date: Thu, 3 Sep 2020 15:32:27 -0700 Subject: [PATCH 534/673] prepare 4.14.3 release (#209) --- 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" ] From a6777a8462fcf5eee1bb3842fd14300bda5e9de5 Mon Sep 17 00:00:00 2001 From: LaunchDarklyCI Date: Thu, 3 Sep 2020 22:33:05 +0000 Subject: [PATCH 535/673] Releasing version 4.14.3 --- CHANGELOG.md | 4 ++++ gradle.properties | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 760235b39..adfc1e2ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes to the LaunchDarkly Java SDK will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org). +## [4.14.3] - 2020-09-03 +### Fixed: +- Bump SnakeYAML from 1.19 to 1.26 to address CVE-2017-18640. The SDK only parses YAML if the application has configured the SDK with a flag data file, so it's unlikely this CVE would affect SDK usage as it would require configuration and access to a local file. + ## [4.14.2] - 2020-09-01 ### Fixed: - Updated the version of OkHttp contained within the SDK from 3.12.10 to 3.14.9, to address multiple [known issues](https://square.github.io/okhttp/changelog_3x/) including an incompatibility with OpenJDK 8.0.252 under some conditions. ([#204](https://github.com/launchdarkly/java-server-sdk/issues/204)) diff --git a/gradle.properties b/gradle.properties index 7bbc413f6..870f8a050 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -version=4.14.2 +version=4.14.3 # 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= From b800f23bdd4d678501e2bd7f3772de8b046eed25 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 4 Sep 2020 15:58:05 -0700 Subject: [PATCH 536/673] comments --- .../launchdarkly/sdk/server/integrations/TestData.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/TestData.java b/src/main/java/com/launchdarkly/sdk/server/integrations/TestData.java index fd5a6afa3..0ddc9b1dd 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/TestData.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/TestData.java @@ -48,12 +48,14 @@ * * // flags can be updated at any time: * td.update(testData.flag("flag-key-2") - * .variationForUser("some-user-key", true) - * .fallthroughVariation(false)); + * .variationForUser("some-user-key", true) + * .fallthroughVariation(false)); * * * The above example uses a simple boolean flag, but more complex configurations are possible using - * the methods of the {@link FlagBuilder} that is returned by {@link #flag(String)}. + * the methods of the {@link FlagBuilder} that is returned by {@link #flag(String)}. {@link FlagBuilder} + * supports many of the ways a flag can be configured on the LaunchDarkly dashboard, but does not + * currently support 1. rule operators other than "in" and "not in", or 2. percentage rollouts. *

    * If the same {@code TestData} instance is used to configure multiple {@code LDClient} instances, * any changes made to the data will propagate to all of the {@code LDClient}s. From 06fdd3e4ef1e529e8dcadf15b18dccedc4c59c10 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 4 Sep 2020 16:19:15 -0700 Subject: [PATCH 537/673] only log initialization message once in polling mode --- .../java/com/launchdarkly/sdk/server/PollingProcessor.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java b/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java index 772a332d7..45f49cb09 100644 --- a/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java @@ -96,9 +96,10 @@ private void poll() { } else { if (dataSourceUpdates.init(allData.toFullDataSet())) { dataSourceUpdates.updateStatus(State.VALID, null); - logger.info("Initialized LaunchDarkly client."); - initialized.getAndSet(true); - initFuture.complete(null); + if (!initialized.getAndSet(true)) { + logger.info("Initialized LaunchDarkly client."); + initFuture.complete(null); + } } } } catch (HttpErrorException e) { From 078fe1690ec91ceabd248638c1f98ba665014244 Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Thu, 17 Sep 2020 16:36:18 +0000 Subject: [PATCH 538/673] [ch89935] Correct some logging call format strings (#264) Also adds debug logs for full exception information in a couple locations. --- .../java/com/launchdarkly/sdk/server/EventBroadcasterImpl.java | 2 +- src/main/java/com/launchdarkly/sdk/server/LDClient.java | 2 +- .../sdk/server/PersistentDataStoreStatusManager.java | 3 ++- .../launchdarkly/sdk/server/PersistentDataStoreWrapper.java | 3 ++- .../sdk/server/integrations/FileDataSourceImpl.java | 3 ++- 5 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/EventBroadcasterImpl.java b/src/main/java/com/launchdarkly/sdk/server/EventBroadcasterImpl.java index d59aa1e1a..b417dc180 100644 --- a/src/main/java/com/launchdarkly/sdk/server/EventBroadcasterImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/EventBroadcasterImpl.java @@ -90,7 +90,7 @@ void broadcast(EventT event) { try { broadcastAction.accept(l, event); } catch (Exception e) { - Loggers.MAIN.warn("Unexpected error from listener ({0}): {1}", l.getClass(), e.toString()); + Loggers.MAIN.warn("Unexpected error from listener ({}): {}", l.getClass(), e.toString()); Loggers.MAIN.debug(e.toString(), e); } }); diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClient.java b/src/main/java/com/launchdarkly/sdk/server/LDClient.java index 4ce81ee1c..2f50d0a73 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClient.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClient.java @@ -397,7 +397,7 @@ public boolean isFlagKnown(String featureKey) { return true; } } catch (Exception e) { - Loggers.MAIN.error("Encountered exception while calling isFlagKnown for feature flag \"{}\": {}", e.toString()); + Loggers.MAIN.error("Encountered exception while calling isFlagKnown for feature flag \"{}\": {}", featureKey, e.toString()); Loggers.MAIN.debug(e.toString(), e); } diff --git a/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreStatusManager.java b/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreStatusManager.java index ed79adad3..971089fe1 100644 --- a/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreStatusManager.java +++ b/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreStatusManager.java @@ -78,7 +78,8 @@ public void run() { updateAvailability(true); } } catch (Exception e) { - logger.error("Unexpected error from data store status function: {0}", e); + logger.error("Unexpected error from data store status function: {}", e.toString()); + logger.debug(e.toString(), e); } } }; diff --git a/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapper.java b/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapper.java index d42f787c6..0da3495a6 100644 --- a/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapper.java +++ b/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapper.java @@ -420,7 +420,8 @@ private boolean pollAvailabilityAfterOutage() { } else { // We failed to write the cached data to the underlying store. In this case, we should not // return to a recovered state, but just try this all again next time the poll task runs. - logger.error("Tried to write cached data to persistent store after a store outage, but failed: {}", e); + logger.error("Tried to write cached data to persistent store after a store outage, but failed: {}", e.toString()); + logger.debug(e.toString(), e); return false; } } diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java index 3a6bb1f17..20b831cf4 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java @@ -70,7 +70,8 @@ final class FileDataSourceImpl implements DataSource { fw = FileWatcher.create(dataLoader.getSources()); } catch (IOException e) { // COVERAGE: there is no way to simulate this condition in a unit test - logger.error("Unable to watch files for auto-updating: " + e); + logger.error("Unable to watch files for auto-updating: {}", e.toString()); + logger.debug(e.toString(), e); fw = null; } } From 136eb1bd18dbcbf3652c2eeb6747c6c5f8cdf2f8 Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Fri, 18 Sep 2020 23:01:33 +0000 Subject: [PATCH 539/673] [ch90109] Remove outdated trackMetric comment from before service support. (#265) --- .../sdk/server/interfaces/LDClientInterface.java | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/LDClientInterface.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/LDClientInterface.java index 4906359d2..0c5a48cb3 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/LDClientInterface.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/LDClientInterface.java @@ -46,11 +46,6 @@ 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 #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 From 889a1c64c52bbb3afc5913a7dfb0708fa2f49bbd Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Fri, 25 Sep 2020 21:35:26 +0000 Subject: [PATCH 540/673] Fix compatibility with Java 7. --- build.gradle | 3 ++- .../com/launchdarkly/client/LDClientInterface.java | 10 ---------- .../java/com/launchdarkly/client/value/LDValue.java | 5 ++++- .../java/com/launchdarkly/client/LDUserTest.java | 12 ++++++++++-- .../java/com/launchdarkly/client/TestHttpUtil.java | 7 ++++++- .../com/launchdarkly/client/value/LDValueTest.java | 5 +++-- 6 files changed, 25 insertions(+), 17 deletions(-) diff --git a/build.gradle b/build.gradle index 5df30ac67..838430e12 100644 --- a/build.gradle +++ b/build.gradle @@ -58,7 +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 + "okhttp": "3.12.2", // 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.26", @@ -445,3 +445,4 @@ gitPublish { } commitMessage = 'publishing javadocs' } + diff --git a/src/main/java/com/launchdarkly/client/LDClientInterface.java b/src/main/java/com/launchdarkly/client/LDClientInterface.java index 80db0168b..775f25934 100644 --- a/src/main/java/com/launchdarkly/client/LDClientInterface.java +++ b/src/main/java/com/launchdarkly/client/LDClientInterface.java @@ -50,11 +50,6 @@ 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. * * @param eventName the name of the event * @param user the user that performed the event @@ -70,11 +65,6 @@ 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 #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 diff --git a/src/main/java/com/launchdarkly/client/value/LDValue.java b/src/main/java/com/launchdarkly/client/value/LDValue.java index 996e7b41d..20cd622e8 100644 --- a/src/main/java/com/launchdarkly/client/value/LDValue.java +++ b/src/main/java/com/launchdarkly/client/value/LDValue.java @@ -3,6 +3,7 @@ import com.google.common.base.Function; import com.google.common.collect.ImmutableList; import com.google.common.collect.Iterables; +import com.google.common.collect.Ordering; import com.google.gson.Gson; import com.google.gson.JsonElement; import com.google.gson.annotations.JsonAdapter; @@ -468,7 +469,9 @@ public int hashCode() { return ah; case OBJECT: int oh = 0; - for (String name: keys()) { + // We sort the keys here to guarantee ordering equivalence with LDValueJsonElement + // wrapping JsonObjects. + for (String name: Ordering.natural().immutableSortedCopy(keys())) { oh = (oh * 31 + name.hashCode()) * 31 + get(name).hashCode(); } return oh; diff --git a/src/test/java/com/launchdarkly/client/LDUserTest.java b/src/test/java/com/launchdarkly/client/LDUserTest.java index 49216608b..d8d771ae4 100644 --- a/src/test/java/com/launchdarkly/client/LDUserTest.java +++ b/src/test/java/com/launchdarkly/client/LDUserTest.java @@ -7,6 +7,7 @@ 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; @@ -29,6 +30,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; @SuppressWarnings("javadoc") public class LDUserTest { @@ -344,8 +346,14 @@ private Map getUserPropertiesJsonMap() { @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)); + JsonObject serialized = defaultGson.toJsonTree(user).getAsJsonObject(); + assertEquals(serialized.get("key").getAsJsonPrimitive().getAsString(), "userkey"); + assertEquals(serialized.get("name").getAsJsonPrimitive().getAsString(), "x"); + assertEquals(serialized.get("email").getAsJsonPrimitive().getAsString(), "y"); + JsonArray privateAttrs = serialized.get("privateAttributeNames").getAsJsonArray(); + assertEquals(privateAttrs.size(), 2); + assertTrue(privateAttrs.contains(new JsonPrimitive("name"))); + assertTrue(privateAttrs.contains(new JsonPrimitive("email"))); } @Test diff --git a/src/test/java/com/launchdarkly/client/TestHttpUtil.java b/src/test/java/com/launchdarkly/client/TestHttpUtil.java index 79fd8f30a..7c862c6b4 100644 --- a/src/test/java/com/launchdarkly/client/TestHttpUtil.java +++ b/src/test/java/com/launchdarkly/client/TestHttpUtil.java @@ -72,9 +72,14 @@ public ServerWithCert() throws IOException, GeneralSecurityException { .certificateAuthority(1) .commonName(hostname) .addSubjectAlternativeName(hostname) + .rsa2048() .build(); - HandshakeCertificates hc = TlsUtil.localhost(); + HandshakeCertificates hc = new HandshakeCertificates.Builder() + .addPlatformTrustedCertificates() + .heldCertificate(cert) + .addTrustedCertificate(cert.certificate()) + .build(); socketFactory = hc.sslSocketFactory(); trustManager = hc.trustManager(); diff --git a/src/test/java/com/launchdarkly/client/value/LDValueTest.java b/src/test/java/com/launchdarkly/client/value/LDValueTest.java index e4397a676..87b0d70f1 100644 --- a/src/test/java/com/launchdarkly/client/value/LDValueTest.java +++ b/src/test/java/com/launchdarkly/client/value/LDValueTest.java @@ -10,6 +10,7 @@ import org.junit.Test; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import static org.junit.Assert.assertEquals; @@ -277,7 +278,7 @@ public void objectKeysCanBeEnumerated() { for (String key: LDValue.buildObject().put("1", LDValue.of("x")).put("2", LDValue.of("y")).build().keys()) { keys.add(key); } - keys.sort(null); + Collections.sort(keys); assertEquals(ImmutableList.of("1", "2"), keys); } @@ -287,7 +288,7 @@ public void objectValuesCanBeEnumerated() { for (LDValue value: LDValue.buildObject().put("1", LDValue.of("x")).put("2", LDValue.of("y")).build().values()) { values.add(value.stringValue()); } - values.sort(null); + Collections.sort(values); assertEquals(ImmutableList.of("x", "y"), values); } From 67dea95201daedac67e65c9edf3b81bf9ba3e454 Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Fri, 25 Sep 2020 16:23:51 -0700 Subject: [PATCH 541/673] Remove import that is no longer used. --- src/test/java/com/launchdarkly/client/TestHttpUtil.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/test/java/com/launchdarkly/client/TestHttpUtil.java b/src/test/java/com/launchdarkly/client/TestHttpUtil.java index 7c862c6b4..b6a850557 100644 --- a/src/test/java/com/launchdarkly/client/TestHttpUtil.java +++ b/src/test/java/com/launchdarkly/client/TestHttpUtil.java @@ -17,7 +17,6 @@ import okhttp3.mockwebserver.MockWebServer; import okhttp3.tls.HandshakeCertificates; import okhttp3.tls.HeldCertificate; -import okhttp3.tls.internal.TlsUtil; class TestHttpUtil { static MockWebServer makeStartedServer(MockResponse... responses) throws IOException { From ee5e212c1c0cab954c5d790a5df9d906aeb8c8ca Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 28 Sep 2020 15:34:35 -0700 Subject: [PATCH 542/673] add Java 7 build (#267) --- .circleci/config.yml | 26 ++++++++++++++++++++++++++ build.gradle | 32 +++++++++++++++++++++++++++++++- gradle.properties | 3 +++ gradle.properties.example | 3 +++ 4 files changed, 63 insertions(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 9d09ffa0d..7e6e84da4 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -7,6 +7,8 @@ workflows: test: jobs: - build-linux + - test-linux-jdk7: + name: Java 7 - Linux - OpenJDK - test-linux: name: Java 8 - Linux - OpenJDK docker-image: circleci/openjdk:8 @@ -76,6 +78,30 @@ jobs: - store_artifacts: path: ~/junit + test-linux-jdk7: + # This build uses LaunchDarkly's ld-jdk7-jdk8 image which has both OpenJDK 7 and + # OpenJDK 8 installed, with 8 being the default that is used to run Gradle. + # See: https://github.com/launchdarkly/sdks-ci-docker + docker: + - image: ldcircleci/ld-jdk7-jdk8 + - image: redis + steps: + - checkout + - run: cp gradle.properties.example gradle.properties + - run: + name: Run tests + command: ./gradlew -i -Dorg.gradle.project.overrideJavaHome=$JDK7_HOME test sourcesJar javadocJar + - 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 diff --git a/build.gradle b/build.gradle index 838430e12..02ba7e1c6 100644 --- a/build.gradle +++ b/build.gradle @@ -59,7 +59,7 @@ ext.versions = [ "guava": "19.0", "jodaTime": "2.9.3", "okhttp": "3.12.2", // specify this for the SDK build instead of relying on the transitive dependency from okhttp-eventsource - "okhttpEventsource": "1.11.0", + "okhttpEventsource": "1.11.2", "slf4j": "1.7.21", "snakeyaml": "1.26", "jedis": "2.9.0" @@ -446,3 +446,33 @@ gitPublish { commitMessage = 'publishing javadocs' } +// Overriding JAVA_HOME/executable paths allows us to build/test under Java 7 even though +// Gradle itself has to run in Java 8+. + +tasks.withType(AbstractCompile) { + options.with { + if (overrideJavaHome != "") { + System.out.println("Building with JAVA_HOME=" + overrideJavaHome) + fork = true + forkOptions.javaHome = file(overrideJavaHome) + } + } +} + +tasks.withType(Javadoc) { + if (overrideJavaHome != "") { + executable = new File(overrideJavaHome, "bin/javadoc") + } +} + +tasks.withType(Test) { + if (overrideJavaHome != "") { + executable = new File(overrideJavaHome, "bin/java") + } +} + +tasks.withType(JavaExec) { + if (overrideJavaHome != "") { + executable = new File(overrideJavaHome, "bin/java") + } +} diff --git a/gradle.properties b/gradle.properties index 870f8a050..9a505c15b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,5 +4,8 @@ version=4.14.3 ossrhUsername= ossrhPassword= +# See build.gradle +overrideJavaHome= + # See https://github.com/gradle/gradle/issues/11308 regarding the following property systemProp.org.gradle.internal.publish.checksums.insecure=true diff --git a/gradle.properties.example b/gradle.properties.example index 058697d17..053018541 100644 --- a/gradle.properties.example +++ b/gradle.properties.example @@ -6,3 +6,6 @@ signing.password = SIGNING_PASSWORD signing.secretKeyRingFile = SECRET_RING_FILE ossrhUsername = launchdarkly ossrhPassword = OSSHR_PASSWORD + +# See build.gradle +overrideJavaHome= From cd60e6d0d0ce1d4122fe6aab15eb907dd528ac64 Mon Sep 17 00:00:00 2001 From: LaunchDarklyCI Date: Mon, 28 Sep 2020 23:46:41 +0100 Subject: [PATCH 543/673] prepare 4.14.4 release (#214) --- .circleci/config.yml | 26 ++++++++++++++ build.gradle | 35 +++++++++++++++++-- gradle.properties | 3 ++ gradle.properties.example | 3 ++ .../client/LDClientInterface.java | 10 ------ .../launchdarkly/client/value/LDValue.java | 5 ++- .../com/launchdarkly/client/LDUserTest.java | 12 +++++-- .../com/launchdarkly/client/TestHttpUtil.java | 8 +++-- .../client/value/LDValueTest.java | 5 +-- 9 files changed, 88 insertions(+), 19 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 9d09ffa0d..7e6e84da4 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -7,6 +7,8 @@ workflows: test: jobs: - build-linux + - test-linux-jdk7: + name: Java 7 - Linux - OpenJDK - test-linux: name: Java 8 - Linux - OpenJDK docker-image: circleci/openjdk:8 @@ -76,6 +78,30 @@ jobs: - store_artifacts: path: ~/junit + test-linux-jdk7: + # This build uses LaunchDarkly's ld-jdk7-jdk8 image which has both OpenJDK 7 and + # OpenJDK 8 installed, with 8 being the default that is used to run Gradle. + # See: https://github.com/launchdarkly/sdks-ci-docker + docker: + - image: ldcircleci/ld-jdk7-jdk8 + - image: redis + steps: + - checkout + - run: cp gradle.properties.example gradle.properties + - run: + name: Run tests + command: ./gradlew -i -Dorg.gradle.project.overrideJavaHome=$JDK7_HOME test sourcesJar javadocJar + - 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 diff --git a/build.gradle b/build.gradle index 5df30ac67..02ba7e1c6 100644 --- a/build.gradle +++ b/build.gradle @@ -58,8 +58,8 @@ 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", + "okhttp": "3.12.2", // specify this for the SDK build instead of relying on the transitive dependency from okhttp-eventsource + "okhttpEventsource": "1.11.2", "slf4j": "1.7.21", "snakeyaml": "1.26", "jedis": "2.9.0" @@ -445,3 +445,34 @@ gitPublish { } commitMessage = 'publishing javadocs' } + +// Overriding JAVA_HOME/executable paths allows us to build/test under Java 7 even though +// Gradle itself has to run in Java 8+. + +tasks.withType(AbstractCompile) { + options.with { + if (overrideJavaHome != "") { + System.out.println("Building with JAVA_HOME=" + overrideJavaHome) + fork = true + forkOptions.javaHome = file(overrideJavaHome) + } + } +} + +tasks.withType(Javadoc) { + if (overrideJavaHome != "") { + executable = new File(overrideJavaHome, "bin/javadoc") + } +} + +tasks.withType(Test) { + if (overrideJavaHome != "") { + executable = new File(overrideJavaHome, "bin/java") + } +} + +tasks.withType(JavaExec) { + if (overrideJavaHome != "") { + executable = new File(overrideJavaHome, "bin/java") + } +} diff --git a/gradle.properties b/gradle.properties index 870f8a050..9a505c15b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,5 +4,8 @@ version=4.14.3 ossrhUsername= ossrhPassword= +# See build.gradle +overrideJavaHome= + # See https://github.com/gradle/gradle/issues/11308 regarding the following property systemProp.org.gradle.internal.publish.checksums.insecure=true diff --git a/gradle.properties.example b/gradle.properties.example index 058697d17..053018541 100644 --- a/gradle.properties.example +++ b/gradle.properties.example @@ -6,3 +6,6 @@ signing.password = SIGNING_PASSWORD signing.secretKeyRingFile = SECRET_RING_FILE ossrhUsername = launchdarkly ossrhPassword = OSSHR_PASSWORD + +# See build.gradle +overrideJavaHome= diff --git a/src/main/java/com/launchdarkly/client/LDClientInterface.java b/src/main/java/com/launchdarkly/client/LDClientInterface.java index 80db0168b..775f25934 100644 --- a/src/main/java/com/launchdarkly/client/LDClientInterface.java +++ b/src/main/java/com/launchdarkly/client/LDClientInterface.java @@ -50,11 +50,6 @@ 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. * * @param eventName the name of the event * @param user the user that performed the event @@ -70,11 +65,6 @@ 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 #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 diff --git a/src/main/java/com/launchdarkly/client/value/LDValue.java b/src/main/java/com/launchdarkly/client/value/LDValue.java index 996e7b41d..20cd622e8 100644 --- a/src/main/java/com/launchdarkly/client/value/LDValue.java +++ b/src/main/java/com/launchdarkly/client/value/LDValue.java @@ -3,6 +3,7 @@ import com.google.common.base.Function; import com.google.common.collect.ImmutableList; import com.google.common.collect.Iterables; +import com.google.common.collect.Ordering; import com.google.gson.Gson; import com.google.gson.JsonElement; import com.google.gson.annotations.JsonAdapter; @@ -468,7 +469,9 @@ public int hashCode() { return ah; case OBJECT: int oh = 0; - for (String name: keys()) { + // We sort the keys here to guarantee ordering equivalence with LDValueJsonElement + // wrapping JsonObjects. + for (String name: Ordering.natural().immutableSortedCopy(keys())) { oh = (oh * 31 + name.hashCode()) * 31 + get(name).hashCode(); } return oh; diff --git a/src/test/java/com/launchdarkly/client/LDUserTest.java b/src/test/java/com/launchdarkly/client/LDUserTest.java index 49216608b..d8d771ae4 100644 --- a/src/test/java/com/launchdarkly/client/LDUserTest.java +++ b/src/test/java/com/launchdarkly/client/LDUserTest.java @@ -7,6 +7,7 @@ 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; @@ -29,6 +30,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; @SuppressWarnings("javadoc") public class LDUserTest { @@ -344,8 +346,14 @@ private Map getUserPropertiesJsonMap() { @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)); + JsonObject serialized = defaultGson.toJsonTree(user).getAsJsonObject(); + assertEquals(serialized.get("key").getAsJsonPrimitive().getAsString(), "userkey"); + assertEquals(serialized.get("name").getAsJsonPrimitive().getAsString(), "x"); + assertEquals(serialized.get("email").getAsJsonPrimitive().getAsString(), "y"); + JsonArray privateAttrs = serialized.get("privateAttributeNames").getAsJsonArray(); + assertEquals(privateAttrs.size(), 2); + assertTrue(privateAttrs.contains(new JsonPrimitive("name"))); + assertTrue(privateAttrs.contains(new JsonPrimitive("email"))); } @Test diff --git a/src/test/java/com/launchdarkly/client/TestHttpUtil.java b/src/test/java/com/launchdarkly/client/TestHttpUtil.java index 79fd8f30a..b6a850557 100644 --- a/src/test/java/com/launchdarkly/client/TestHttpUtil.java +++ b/src/test/java/com/launchdarkly/client/TestHttpUtil.java @@ -17,7 +17,6 @@ import okhttp3.mockwebserver.MockWebServer; import okhttp3.tls.HandshakeCertificates; import okhttp3.tls.HeldCertificate; -import okhttp3.tls.internal.TlsUtil; class TestHttpUtil { static MockWebServer makeStartedServer(MockResponse... responses) throws IOException { @@ -72,9 +71,14 @@ public ServerWithCert() throws IOException, GeneralSecurityException { .certificateAuthority(1) .commonName(hostname) .addSubjectAlternativeName(hostname) + .rsa2048() .build(); - HandshakeCertificates hc = TlsUtil.localhost(); + HandshakeCertificates hc = new HandshakeCertificates.Builder() + .addPlatformTrustedCertificates() + .heldCertificate(cert) + .addTrustedCertificate(cert.certificate()) + .build(); socketFactory = hc.sslSocketFactory(); trustManager = hc.trustManager(); diff --git a/src/test/java/com/launchdarkly/client/value/LDValueTest.java b/src/test/java/com/launchdarkly/client/value/LDValueTest.java index e4397a676..87b0d70f1 100644 --- a/src/test/java/com/launchdarkly/client/value/LDValueTest.java +++ b/src/test/java/com/launchdarkly/client/value/LDValueTest.java @@ -10,6 +10,7 @@ import org.junit.Test; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import static org.junit.Assert.assertEquals; @@ -277,7 +278,7 @@ public void objectKeysCanBeEnumerated() { for (String key: LDValue.buildObject().put("1", LDValue.of("x")).put("2", LDValue.of("y")).build().keys()) { keys.add(key); } - keys.sort(null); + Collections.sort(keys); assertEquals(ImmutableList.of("1", "2"), keys); } @@ -287,7 +288,7 @@ public void objectValuesCanBeEnumerated() { for (LDValue value: LDValue.buildObject().put("1", LDValue.of("x")).put("2", LDValue.of("y")).build().values()) { values.add(value.stringValue()); } - values.sort(null); + Collections.sort(values); assertEquals(ImmutableList.of("x", "y"), values); } From 424c7d5ecaccccf16079d05f6567164b6888bfb2 Mon Sep 17 00:00:00 2001 From: LaunchDarklyCI Date: Mon, 28 Sep 2020 22:47:12 +0000 Subject: [PATCH 544/673] Releasing version 4.14.4 --- CHANGELOG.md | 6 ++++++ gradle.properties | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index adfc1e2ce..00729842a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to the LaunchDarkly Java SDK will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org). +## [4.14.4] - 2020-09-28 +### Fixed: +- Restored compatibility with Java 7. A transitive dependency that required Java 8 had accidentally been included, and the CI build did not detect this because the tests were being run in Java 8 even though the compiler target was 7. CI builds now verify that the SDK really can run in Java 7. This fix is only for 4.x; the 5.x SDK still does not support Java 7. +- Bumped OkHttp version to 3.12.12 to avoid a crash on Java 8u252. +- Removed an obsolete comment that said the `trackMetric` method was not yet supported by the LaunchDarkly service; it is. + ## [4.14.3] - 2020-09-03 ### Fixed: - Bump SnakeYAML from 1.19 to 1.26 to address CVE-2017-18640. The SDK only parses YAML if the application has configured the SDK with a flag data file, so it's unlikely this CVE would affect SDK usage as it would require configuration and access to a local file. diff --git a/gradle.properties b/gradle.properties index 9a505c15b..a1a3404d7 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -version=4.14.3 +version=4.14.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= From 0113451227286c3f6627e2f32c110ed76297998c Mon Sep 17 00:00:00 2001 From: Harpo Roeder Date: Mon, 28 Sep 2020 16:02:06 -0700 Subject: [PATCH 545/673] add and use getSocketFactory --- src/main/java/com/launchdarkly/sdk/server/Util.java | 4 ++++ .../sdk/server/interfaces/HttpConfiguration.java | 10 ++++++++++ 2 files changed, 14 insertions(+) diff --git a/src/main/java/com/launchdarkly/sdk/server/Util.java b/src/main/java/com/launchdarkly/sdk/server/Util.java index 06864a4d6..9e4e1fcfe 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Util.java +++ b/src/main/java/com/launchdarkly/sdk/server/Util.java @@ -42,6 +42,10 @@ static void configureHttpClientBuilder(HttpConfiguration config, OkHttpClient.Bu .readTimeout(config.getSocketTimeout()) .writeTimeout(config.getSocketTimeout()) .retryOnConnectionFailure(false); // we will implement our own retry logic + + if (config.getSocketFactory() != null) { + builder.socketFactory(config.getSocketFactory()); + } if (config.getSslSocketFactory() != null) { builder.sslSocketFactory(config.getSslSocketFactory(), config.getTrustManager()); diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/HttpConfiguration.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/HttpConfiguration.java index 1d47867a8..994dd4d10 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/HttpConfiguration.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/HttpConfiguration.java @@ -6,6 +6,7 @@ import java.time.Duration; import java.util.Map; +import javax.net.SocketFactory; import javax.net.ssl.SSLSocketFactory; import javax.net.ssl.X509TrustManager; @@ -53,6 +54,15 @@ public interface HttpConfiguration { * @return the socket timeout; must not be null */ Duration getSocketTimeout(); + + /** + * The configured socket factory for insecure connections. + * + * @return a SocketFactory or null + */ + default SocketFactory getSocketFactory() { + return null; + } /** * The configured socket factory for secure connections. From 346f655f34d8f22407b147f0be3e990e4b45ad2d Mon Sep 17 00:00:00 2001 From: Harpo Roeder Date: Mon, 28 Sep 2020 16:02:44 -0700 Subject: [PATCH 546/673] alignment --- .../launchdarkly/sdk/server/interfaces/HttpConfiguration.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/HttpConfiguration.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/HttpConfiguration.java index 994dd4d10..1a2ab19b3 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/HttpConfiguration.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/HttpConfiguration.java @@ -59,7 +59,7 @@ public interface HttpConfiguration { * The configured socket factory for insecure connections. * * @return a SocketFactory or null - */ + */ default SocketFactory getSocketFactory() { return null; } From 305f555fbed5b878e5028baf751b52e1c2a6118d Mon Sep 17 00:00:00 2001 From: Harpo Roeder Date: Mon, 28 Sep 2020 16:13:25 -0700 Subject: [PATCH 547/673] add socketFactory to builder --- .../launchdarkly/sdk/server/ComponentsImpl.java | 1 + .../sdk/server/HttpConfigurationImpl.java | 11 ++++++++++- .../integrations/HttpConfigurationBuilder.java | 15 +++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java b/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java index 9f81c35da..370f1a1e3 100644 --- a/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java @@ -262,6 +262,7 @@ public HttpConfiguration createHttpConfiguration(BasicConfiguration basicConfigu proxy, proxyAuth, socketTimeout, + socketFactory, sslSocketFactory, trustManager, headers.build() diff --git a/src/main/java/com/launchdarkly/sdk/server/HttpConfigurationImpl.java b/src/main/java/com/launchdarkly/sdk/server/HttpConfigurationImpl.java index f110eb19c..9415fe8b1 100644 --- a/src/main/java/com/launchdarkly/sdk/server/HttpConfigurationImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/HttpConfigurationImpl.java @@ -8,6 +8,7 @@ import java.time.Duration; import java.util.Map; +import javax.net.SocketFactory; import javax.net.ssl.SSLSocketFactory; import javax.net.ssl.X509TrustManager; @@ -16,17 +17,20 @@ final class HttpConfigurationImpl implements HttpConfiguration { final Proxy proxy; final HttpAuthentication proxyAuth; final Duration socketTimeout; + final SocketFactory socketFactory; final SSLSocketFactory sslSocketFactory; final X509TrustManager trustManager; final ImmutableMap defaultHeaders; HttpConfigurationImpl(Duration connectTimeout, Proxy proxy, HttpAuthentication proxyAuth, - Duration socketTimeout, SSLSocketFactory sslSocketFactory, X509TrustManager trustManager, + Duration socketTimeout, SocketFactory socketFactory, + SSLSocketFactory sslSocketFactory, X509TrustManager trustManager, ImmutableMap defaultHeaders) { this.connectTimeout = connectTimeout; this.proxy = proxy; this.proxyAuth = proxyAuth; this.socketTimeout = socketTimeout; + this.socketFactory = socketFactory; this.sslSocketFactory = sslSocketFactory; this.trustManager = trustManager; this.defaultHeaders = defaultHeaders; @@ -51,6 +55,11 @@ public HttpAuthentication getProxyAuthentication() { public Duration getSocketTimeout() { return socketTimeout; } + + @Override + public SocketFactory getSocketFactory() { + return socketFactory; + } @Override public SSLSocketFactory getSslSocketFactory() { diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/HttpConfigurationBuilder.java b/src/main/java/com/launchdarkly/sdk/server/integrations/HttpConfigurationBuilder.java index 7fa33d889..9a3ca86c4 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/HttpConfigurationBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/HttpConfigurationBuilder.java @@ -6,6 +6,7 @@ import java.time.Duration; +import javax.net.SocketFactory; import javax.net.ssl.SSLSocketFactory; import javax.net.ssl.X509TrustManager; @@ -45,6 +46,7 @@ public abstract class HttpConfigurationBuilder implements HttpConfigurationFacto protected String proxyHost; protected int proxyPort; protected Duration socketTimeout = DEFAULT_SOCKET_TIMEOUT; + protected SocketFactory socketFactory; protected SSLSocketFactory sslSocketFactory; protected X509TrustManager trustManager; protected String wrapperName; @@ -105,6 +107,19 @@ public HttpConfigurationBuilder socketTimeout(Duration socketTimeout) { return this; } + /** + * Specifies a custom socket configuration for HTTP connections to LaunchDarkly. + *

    + * This uses the standard Java interfaces for configuring socket connections. + * + * @param socketFactory the socket factory + * @return the builder + */ + public HttpConfigurationBuilder socketFactory(SocketFactory socketFactory) { + this.socketFactory = socketFactory; + return this; + } + /** * Specifies a custom security configuration for HTTPS connections to LaunchDarkly. *

    From 7c2eea8f0152049171c92a6a108e50a858d849df Mon Sep 17 00:00:00 2001 From: Harpo Roeder Date: Mon, 28 Sep 2020 16:19:48 -0700 Subject: [PATCH 548/673] test socket factory builder --- .../HttpConfigurationBuilderTest.java | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/HttpConfigurationBuilderTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/HttpConfigurationBuilderTest.java index 73253d4eb..6b2b6bea6 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/HttpConfigurationBuilderTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/HttpConfigurationBuilderTest.java @@ -17,6 +17,7 @@ import java.security.cert.X509Certificate; import java.time.Duration; +import javax.net.SocketFactory; import javax.net.ssl.SSLSocketFactory; import javax.net.ssl.X509TrustManager; @@ -46,6 +47,7 @@ public void testDefaults() { assertNull(hc.getProxy()); assertNull(hc.getProxyAuthentication()); assertEquals(DEFAULT_SOCKET_TIMEOUT, hc.getSocketTimeout()); + assertNull(hc.getSocketFactory()); assertNull(hc.getSslSocketFactory()); assertNull(hc.getTrustManager()); assertEquals(buildBasicHeaders().build(), ImmutableMap.copyOf(hc.getDefaultHeaders())); @@ -99,6 +101,15 @@ public void testSocketTimeout() { assertEquals(DEFAULT_SOCKET_TIMEOUT, hc2.getSocketTimeout()); } + @Test + public void testSocketFactory() { + SocketFactory sf = new StubSocketFactory(); + HttpConfiguration hc = Components.httpConfiguration() + .socketFactory(sf) + .createHttpConfiguration(BASIC_CONFIG); + assertSame(sf, hc.getSocketFactory()); + } + @Test public void testSslOptions() { SSLSocketFactory sf = new StubSSLSocketFactory(); @@ -125,6 +136,30 @@ public void testWrapperWithVersion() { .createHttpConfiguration(BASIC_CONFIG); assertEquals("Scala/0.1.0", ImmutableMap.copyOf(hc.getDefaultHeaders()).get("X-LaunchDarkly-Wrapper")); } + + public static class StubSocketFactory extends SocketFactory { + 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 Socket createSocket(Socket s, String host, int port, boolean autoClose) throws IOException { + return null; + } + } public static class StubSSLSocketFactory extends SSLSocketFactory { public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort) From 8b51b95a2f9320e22060e26bc07fb22d7fc9f4b5 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 30 Sep 2020 13:55:19 -0700 Subject: [PATCH 549/673] preserve dummy CI config file when pushing to gh-pages (#271) --- build.gradle | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/build.gradle b/build.gradle index 95a1d43f4..0ac0e0004 100644 --- a/build.gradle +++ b/build.gradle @@ -613,5 +613,11 @@ gitPublish { contents { from javadoc } + preserve { + // There's a dummy .circleci/config.yml file on the gh-pages branch so CircleCI won't + // complain when it sees a commit there. The git-publish plugin would delete that file if + // we didn't protect it here. + include '.circleci/config.yml' + } commitMessage = 'publishing javadocs' } From f6ae98e48fe376e868695a204c602959689bc4f6 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 30 Sep 2020 17:01:50 -0700 Subject: [PATCH 550/673] fix concatenation when base URI has a context path (#270) --- .../sdk/server/DefaultEventSender.java | 3 +- .../sdk/server/DefaultFeatureRequestor.java | 5 ++- .../sdk/server/StreamProcessor.java | 4 +- .../com/launchdarkly/sdk/server/Util.java | 9 ++++- .../sdk/server/DefaultEventSenderTest.java | 20 +++++++++- .../server/DefaultFeatureRequestorTest.java | 38 ++++++++++++++++++- .../sdk/server/StreamProcessorTest.java | 19 +++++++++- 7 files changed, 89 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/DefaultEventSender.java b/src/main/java/com/launchdarkly/sdk/server/DefaultEventSender.java index 03d821998..e61fb795d 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DefaultEventSender.java +++ b/src/main/java/com/launchdarkly/sdk/server/DefaultEventSender.java @@ -16,6 +16,7 @@ import java.util.UUID; import static com.launchdarkly.sdk.server.Util.checkIfErrorIsRecoverableAndLog; +import static com.launchdarkly.sdk.server.Util.concatenateUriPath; import static com.launchdarkly.sdk.server.Util.configureHttpClientBuilder; import static com.launchdarkly.sdk.server.Util.describeDuration; import static com.launchdarkly.sdk.server.Util.getHeadersBuilderFor; @@ -91,7 +92,7 @@ public Result sendEventData(EventDataKind kind, String data, int eventCount, URI throw new IllegalArgumentException("kind"); // COVERAGE: unreachable code, those are the only enum values } - URI uri = eventsBaseUri.resolve(eventsBaseUri.getPath().endsWith("/") ? path : ("/" + path)); + URI uri = concatenateUriPath(eventsBaseUri, path); Headers headers = headersBuilder.build(); RequestBody body = RequestBody.create(data, JSON_CONTENT_TYPE); boolean mustShutDown = false; diff --git a/src/main/java/com/launchdarkly/sdk/server/DefaultFeatureRequestor.java b/src/main/java/com/launchdarkly/sdk/server/DefaultFeatureRequestor.java index 8aff2e66a..c3c7cc4bd 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DefaultFeatureRequestor.java +++ b/src/main/java/com/launchdarkly/sdk/server/DefaultFeatureRequestor.java @@ -11,6 +11,7 @@ import java.nio.file.Files; import java.nio.file.Path; +import static com.launchdarkly.sdk.server.Util.concatenateUriPath; import static com.launchdarkly.sdk.server.Util.configureHttpClientBuilder; import static com.launchdarkly.sdk.server.Util.getHeadersBuilderFor; import static com.launchdarkly.sdk.server.Util.shutdownHttpClient; @@ -26,7 +27,7 @@ */ final class DefaultFeatureRequestor implements FeatureRequestor { private static final Logger logger = Loggers.DATA_SOURCE; - private static final String GET_LATEST_ALL_PATH = "/sdk/latest-all"; + 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 @VisibleForTesting final URI baseUri; @@ -37,7 +38,7 @@ final class DefaultFeatureRequestor implements FeatureRequestor { DefaultFeatureRequestor(HttpConfiguration httpConfig, URI baseUri) { this.baseUri = baseUri; - this.pollingUri = baseUri.resolve(GET_LATEST_ALL_PATH); + this.pollingUri = concatenateUriPath(baseUri, GET_LATEST_ALL_PATH); OkHttpClient.Builder httpBuilder = new OkHttpClient.Builder(); configureHttpClientBuilder(httpConfig, httpBuilder); diff --git a/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java index 7f5ccc53f..36c4e6cab 100644 --- a/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java @@ -36,6 +36,7 @@ import static com.launchdarkly.sdk.server.DataModel.ALL_DATA_KINDS; import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; import static com.launchdarkly.sdk.server.Util.checkIfErrorIsRecoverableAndLog; +import static com.launchdarkly.sdk.server.Util.concatenateUriPath; import static com.launchdarkly.sdk.server.Util.configureHttpClientBuilder; import static com.launchdarkly.sdk.server.Util.getHeadersBuilderFor; import static com.launchdarkly.sdk.server.Util.httpErrorDescription; @@ -68,6 +69,7 @@ * if we succeed then the client can detect that we're initialized now by calling our Initialized method. */ final class StreamProcessor implements DataSource { + private static final String STREAM_URI_PATH = "all"; private static final String PUT = "put"; private static final String PATCH = "patch"; private static final String DELETE = "delete"; @@ -202,7 +204,7 @@ public Future start() { EventHandler handler = new StreamEventHandler(initFuture); es = eventSourceCreator.createEventSource(new EventSourceParams(handler, - URI.create(streamUri.toASCIIString() + "/all"), + concatenateUriPath(streamUri, STREAM_URI_PATH), initialReconnectDelay, wrappedConnectionErrorHandler, headers, diff --git a/src/main/java/com/launchdarkly/sdk/server/Util.java b/src/main/java/com/launchdarkly/sdk/server/Util.java index 06864a4d6..3ffd243b6 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Util.java +++ b/src/main/java/com/launchdarkly/sdk/server/Util.java @@ -6,6 +6,7 @@ import org.slf4j.Logger; import java.io.IOException; +import java.net.URI; import java.nio.file.FileVisitResult; import java.nio.file.Files; import java.nio.file.Path; @@ -176,5 +177,11 @@ public FileVisitResult postVisitDirectory(Path dir, IOException exc) { } }); } catch (IOException e) {} - } + } + + static URI concatenateUriPath(URI baseUri, String path) { + String uriStr = baseUri.toString(); + String addPath = path.startsWith("/") ? path.substring(1) : path; + return URI.create(uriStr + (uriStr.endsWith("/") ? "" : "/") + addPath); + } } diff --git a/src/test/java/com/launchdarkly/sdk/server/DefaultEventSenderTest.java b/src/test/java/com/launchdarkly/sdk/server/DefaultEventSenderTest.java index 1e8d3401c..b806997a4 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DefaultEventSenderTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultEventSenderTest.java @@ -301,7 +301,7 @@ public void httpClientCanUseCustomTlsConfig() throws Exception { } @Test - public void baseUriDoesNotNeedToEndInSlash() throws Exception { + public void baseUriDoesNotNeedTrailingSlash() throws Exception { try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { try (EventSender es = makeEventSender()) { URI uriWithoutSlash = URI.create(server.url("/").toString().replaceAll("/$", "")); @@ -318,6 +318,24 @@ public void baseUriDoesNotNeedToEndInSlash() throws Exception { } } + @Test + public void baseUriCanHaveContextPath() throws Exception { + try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { + try (EventSender es = makeEventSender()) { + URI baseUri = URI.create(server.url("/context/path").toString()); + EventSender.Result result = es.sendEventData(ANALYTICS, FAKE_DATA, 1, baseUri); + + assertTrue(result.isSuccess()); + assertFalse(result.isMustShutDown()); + } + + RecordedRequest req = server.takeRequest(); + assertEquals("/context/path/bulk", req.getPath()); + assertEquals("application/json; charset=utf-8", req.getHeader("content-type")); + assertEquals(FAKE_DATA, req.getBody().readUtf8()); + } + } + @Test public void nothingIsSentForNullData() throws Exception { try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { diff --git a/src/test/java/com/launchdarkly/sdk/server/DefaultFeatureRequestorTest.java b/src/test/java/com/launchdarkly/sdk/server/DefaultFeatureRequestorTest.java index ed98993c0..1158ca4eb 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DefaultFeatureRequestorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultFeatureRequestorTest.java @@ -44,7 +44,7 @@ private DefaultFeatureRequestor makeRequestor(MockWebServer server) { } private DefaultFeatureRequestor makeRequestor(MockWebServer server, LDConfig config) { - URI uri = server.url("").uri(); + URI uri = server.url("/").uri(); return new DefaultFeatureRequestor(makeHttpConfig(config), uri); } @@ -187,6 +187,42 @@ public void httpClientCanUseProxyConfig() throws Exception { } } + @Test + public void baseUriDoesNotNeedTrailingSlash() throws Exception { + MockResponse resp = jsonResponse(allDataJson); + + try (MockWebServer server = makeStartedServer(resp)) { + URI uri = server.url("").uri(); + try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(makeHttpConfig(LDConfig.DEFAULT), uri)) { + FeatureRequestor.AllData data = r.getAllData(true); + + RecordedRequest req = server.takeRequest(); + assertEquals("/sdk/latest-all", req.getPath()); + verifyHeaders(req); + + verifyExpectedData(data); + } + } + } + + @Test + public void baseUriCanHaveContextPath() throws Exception { + MockResponse resp = jsonResponse(allDataJson); + + try (MockWebServer server = makeStartedServer(resp)) { + URI uri = server.url("/context/path").uri(); + try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(makeHttpConfig(LDConfig.DEFAULT), uri)) { + FeatureRequestor.AllData data = r.getAllData(true); + + RecordedRequest req = server.takeRequest(); + assertEquals("/context/path/sdk/latest-all", req.getPath()); + verifyHeaders(req); + + verifyExpectedData(data); + } + } + } + private void verifyHeaders(RecordedRequest req) { HttpConfiguration httpConfig = clientContext(sdkKey, LDConfig.DEFAULT).getHttp(); for (Map.Entry kv: httpConfig.getDefaultHeaders()) { diff --git a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java index 338ca289a..c258501c2 100644 --- a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java @@ -72,7 +72,8 @@ public class StreamProcessorTest extends EasyMockSupport { private static final String SDK_KEY = "sdk_key"; - private static final URI STREAM_URI = URI.create("http://stream.test.com"); + private static final URI STREAM_URI = URI.create("http://stream.test.com/"); + private static final URI STREAM_URI_WITHOUT_SLASH = URI.create("http://stream.test.com"); private static final String FEATURE1_KEY = "feature1"; private static final int FEATURE1_VERSION = 11; private static final DataModel.FeatureFlag FEATURE = flagBuilder(FEATURE1_KEY).version(FEATURE1_VERSION).build(); @@ -124,7 +125,21 @@ public void builderCanSpecifyConfiguration() throws Exception { @Test public void streamUriHasCorrectEndpoint() { createStreamProcessor(STREAM_URI).start(); - assertEquals(URI.create(STREAM_URI.toString() + "/all"), + assertEquals(URI.create(STREAM_URI.toString() + "all"), + mockEventSourceCreator.getNextReceivedParams().streamUri); + } + + @Test + public void streamBaseUriDoesNotNeedTrailingSlash() { + createStreamProcessor(STREAM_URI_WITHOUT_SLASH).start(); + assertEquals(URI.create(STREAM_URI_WITHOUT_SLASH.toString() + "/all"), + mockEventSourceCreator.getNextReceivedParams().streamUri); + } + + @Test + public void streamBaseUriCanHaveContextPath() { + createStreamProcessor(URI.create(STREAM_URI.toString() + "/context/path")).start(); + assertEquals(URI.create(STREAM_URI.toString() + "/context/path/all"), mockEventSourceCreator.getNextReceivedParams().streamUri); } From b33e5bf1a74d22cd340d92e01b707f6843ec80b0 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 30 Sep 2020 17:04:46 -0700 Subject: [PATCH 551/673] fix shaded jar builds to exclude Jackson classes and not modify Jackson return types (#268) --- build.gradle | 17 +++++-- packaging-test/Makefile | 12 +++-- packaging-test/run-non-osgi-test.sh | 50 ++++++++++++------- packaging-test/run-osgi-test.sh | 43 ++++++++++++---- packaging-test/test-app/build.gradle | 16 ++++-- .../src/main/java/testapp/TestApp.java | 8 +++ .../java/testapp/TestAppJacksonTests.java | 43 ++++++++++++++++ 7 files changed, 151 insertions(+), 38 deletions(-) create mode 100644 packaging-test/test-app/src/main/java/testapp/TestAppJacksonTests.java diff --git a/build.gradle b/build.gradle index 0ac0e0004..648dce998 100644 --- a/build.gradle +++ b/build.gradle @@ -71,7 +71,7 @@ ext.versions = [ "commonsCodec": "1.10", "gson": "2.7", "guava": "28.2-jre", - "jackson": "2.10.0", + "jackson": "2.11.2", "launchdarklyJavaSdkCommon": "1.0.0", "okhttp": "4.8.1", // specify this for the SDK build instead of relying on the transitive dependency from okhttp-eventsource "okhttpEventsource": "2.3.1", @@ -96,7 +96,9 @@ libraries.internal = [ // 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 = [ - "org.slf4j:slf4j-api:${versions.slf4j}" + "org.slf4j:slf4j-api:${versions.slf4j}", + "com.fasterxml.jackson.core:jackson-core:${versions.jackson}", + "com.fasterxml.jackson.core:jackson-databind:${versions.jackson}" ] // Add dependencies to "libraries.test" that are used only in unit tests. @@ -169,6 +171,7 @@ shadowJar { dependencies { exclude(dependency('org.slf4j:.*:.*')) + exclude(dependency('com.fasterxml.jackson.core:.*:.*')) } // Kotlin metadata for shaded classes should not be included - it confuses IDEs @@ -198,12 +201,16 @@ task shadowJarAll(type: com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJ description = "Builds a Shaded fat jar including SLF4J" from(project.convention.getPlugin(JavaPluginConvention).sourceSets.main.output) configurations = [project.configurations.runtimeClasspath] - exclude('META-INF/INDEX.LIST', 'META-INF/*.SF', 'META-INF/*.DSA', 'META-INF/*.RSA') + exclude('META-INF/INDEX.LIST', 'META-INF/*.SF', 'META-INF/*.DSA', 'META-INF/*.RSA') exclude '**/*.kotlin_metadata' exclude '**/*.kotlin_module' exclude '**/*.kotlin_builtins' + dependencies { + exclude(dependency('com.fasterxml.jackson.core:.*:.*')) + } + // 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 { @@ -384,7 +391,9 @@ def addOsgiManifest(jarTask, List importConfigs, List/dev/null) + $(shell ls $(TEMP_DIR)/dependencies-external/gson*.jar 2>/dev/null) \ + $(shell ls $(TEMP_DIR)/dependencies-external/jackson*.jar 2>/dev/null) RUN_JARS_test-default-jar=$(TEST_APP_JAR) $(SDK_DEFAULT_JAR) \ $(shell ls $(TEMP_DIR)/dependencies-external/*.jar 2>/dev/null) RUN_JARS_test-thin-jar=$(TEST_APP_JAR) $(SDK_THIN_JAR) \ @@ -44,8 +45,8 @@ RUN_JARS_test-thin-jar=$(TEST_APP_JAR) $(SDK_THIN_JAR) \ $(shell ls $(TEMP_DIR)/dependencies-external/*.jar 2>/dev/null) 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) +classes_should_contain=echo " should contain $(2)" && grep "^$(1)/.*\.class$$" $(TEMP_OUTPUT) >/dev/null +classes_should_not_contain=echo " should not contain $(2)" && ! grep "^$(1)/.*\.class$$" $(TEMP_OUTPUT) verify_sdk_classes= \ $(call classes_should_contain,com/launchdarkly/sdk,com.launchdarkly.sdk) && \ @@ -79,6 +80,8 @@ test-all-jar-classes: $(SDK_ALL_JAR) $(TEMP_DIR) @$(call classes_should_not_contain,com/launchdarkly/shaded/com/launchdarkly/sdk,shaded SDK classes) @$(call classes_should_contain,com/launchdarkly/shaded/com/google/gson,shaded Gson) @$(call classes_should_not_contain,com/google/gson,unshaded Gson) + @$(call classes_should_not_contain,com/fasterxml/jackson,unshaded Jackson) + @$(call classes_should_not_contain,com/launchdarkly/shaded/com/fasterxml/jackson,shaded Jackson) @$(call classes_should_not_contain,com/launchdarkly/shaded/org/slf4j,shaded SLF4j) test-default-jar-classes: $(SDK_DEFAULT_JAR) $(TEMP_DIR) @@ -89,6 +92,8 @@ test-default-jar-classes: $(SDK_DEFAULT_JAR) $(TEMP_DIR) @$(call classes_should_contain,com/launchdarkly/shaded/com/google/gson,shaded Gson) @$(call classes_should_not_contain,com/launchdarkly/shaded/org/slf4j,shaded SLF4j) @$(call classes_should_not_contain,com/google/gson,unshaded Gson) + @$(call classes_should_not_contain,com/fasterxml/jackson,unshaded Jackson) + @$(call classes_should_not_contain,com/launchdarkly/shaded/com/fasterxml/jackson,shaded Jackson) @$(call classes_should_not_contain,org/slf4j,unshaded SLF4j) test-thin-jar-classes: $(SDK_THIN_JAR) $(TEMP_DIR) @@ -121,6 +126,7 @@ $(TEMP_DIR)/dependencies-external: $(TEMP_DIR)/dependencies-all [ -d $@ ] || mkdir -p $@ cp $(TEMP_DIR)/dependencies-all/slf4j*.jar $@ cp $(TEMP_DIR)/dependencies-all/gson*.jar $@ + cp $(TEMP_DIR)/dependencies-all/jackson*.jar $@ $(TEMP_DIR)/dependencies-internal: $(TEMP_DIR)/dependencies-all [ -d $@ ] || mkdir -p $@ diff --git a/packaging-test/run-non-osgi-test.sh b/packaging-test/run-non-osgi-test.sh index 49b8d953d..2c1f0cd4d 100755 --- a/packaging-test/run-non-osgi-test.sh +++ b/packaging-test/run-non-osgi-test.sh @@ -1,5 +1,7 @@ #!/bin/bash +set -e + function run_test() { rm -f ${TEMP_OUTPUT} touch ${TEMP_OUTPUT} @@ -8,29 +10,41 @@ function run_test() { grep "TestApp: PASS" ${TEMP_OUTPUT} >/dev/null } -echo "" -echo " non-OSGi runtime test - with Gson" -run_test $@ -grep "LDGson tests OK" ${TEMP_OUTPUT} >/dev/null || (echo "FAIL: should have run LDGson tests but did not" && exit 1) - # It does not make sense to test the "thin" jar without Gson. The SDK uses Gson internally # and can't work without it; in the default jar and the "all" jar, it has its own embedded # copy of Gson, but the "thin" jar does not include any third-party dependencies so you must # provide all of them including Gson. -thin_sdk_regex=".*launchdarkly-java-server-sdk-[^ ]*-thin\\.jar" +echo "" if [[ "$@" =~ $thin_sdk_regex ]]; then - exit 0 + echo " non-OSGi runtime test - without Jackson" + filtered_deps="" + json_jar_regex=".*jackson.*" + for dep in $@; do + if [[ ! "$dep" =~ $json_jar_regex ]]; then + filtered_deps="$filtered_deps $dep" + fi + done + run_test $filtered_deps + grep "skipping LDJackson tests" ${TEMP_OUTPUT} >/dev/null || \ + (echo "FAIL: should have skipped LDJackson tests but did not; test setup was incorrect" && exit 1) +else + echo " non-OSGi runtime test - without Gson or Jackson" + filtered_deps="" + json_jar_regex=".*gson.*|.*jackson.*" + for dep in $@; do + if [[ ! "$dep" =~ $json_jar_regex ]]; then + filtered_deps="$filtered_deps $dep" + fi + done + run_test $filtered_deps + grep "skipping LDGson tests" ${TEMP_OUTPUT} >/dev/null || \ + (echo "FAIL: should have skipped LDGson tests but did not; test setup was incorrect" && exit 1) + grep "skipping LDJackson tests" ${TEMP_OUTPUT} >/dev/null || \ + (echo "FAIL: should have skipped LDJackson tests but did not; test setup was incorrect" && exit 1) fi echo "" -echo " non-OSGi runtime test - without Gson" -deps_except_json="" -json_jar_regex=".*gson.*" -for dep in $@; do - if [[ ! "$dep" =~ $json_jar_regex ]]; then - deps_except_json="$deps_except_json $dep" - fi -done -run_test $deps_except_json -grep "skipping LDGson tests" ${TEMP_OUTPUT} >/dev/null || \ - (echo "FAIL: should have skipped LDGson tests but did not; test setup was incorrect" && exit 1) +echo " non-OSGi runtime test - with Gson and Jackson" +run_test $@ +grep "LDGson tests OK" ${TEMP_OUTPUT} >/dev/null || (echo "FAIL: should have run LDGson tests but did not" && exit 1) +grep "LDJackson tests OK" ${TEMP_OUTPUT} >/dev/null || (echo "FAIL: should have run LDJackson tests but did not" && exit 1) diff --git a/packaging-test/run-osgi-test.sh b/packaging-test/run-osgi-test.sh index df5f69739..22bc52459 100755 --- a/packaging-test/run-osgi-test.sh +++ b/packaging-test/run-osgi-test.sh @@ -1,32 +1,57 @@ #!/bin/bash +set -e + +# This script uses Felix to run the test application as an OSGi bundle, with or without +# additional bundles to support the optional Gson and Jackson integrations. We are +# verifying that the SDK itself works correctly as an OSGi bundle, and also that its +# imports of other bundles work correctly. +# +# This test is being run in CI using the lowest compatible JDK version. It may not work +# in higher JDK versions due to incompatibilities with the version of Felix we are using. + +JAR_DEPS="$@" + # We can't test the "thin" jar in OSGi, because some of our third-party dependencies # aren't available as OSGi bundles. That isn't a plausible use case anyway. thin_sdk_regex=".*launchdarkly-java-server-sdk-[^ ]*-thin\\.jar" -if [[ "$@" =~ $thin_sdk_regex ]]; then +if [[ "${JAR_DEPS}" =~ $thin_sdk_regex ]]; then exit 0 fi rm -rf ${TEMP_BUNDLE_DIR} mkdir -p ${TEMP_BUNDLE_DIR} +function copy_deps() { + if [ -n "${JAR_DEPS}" ]; then + cp ${JAR_DEPS} ${TEMP_BUNDLE_DIR} + fi + cp ${FELIX_BASE_BUNDLE_DIR}/* ${TEMP_BUNDLE_DIR} +} + function run_test() { 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} + cd ${FELIX_DIR} + java -jar ${FELIX_JAR} -b ${TEMP_BUNDLE_DIR} | tee ${TEMP_OUTPUT} grep "TestApp: PASS" ${TEMP_OUTPUT} >/dev/null } echo "" -echo " OSGi runtime test - with Gson" -cp $@ ${FELIX_BASE_BUNDLE_DIR}/* ${TEMP_BUNDLE_DIR} +echo " OSGi runtime test - without Gson or Jackson" +copy_deps +rm ${TEMP_BUNDLE_DIR}/*gson*.jar ${TEMP_BUNDLE_DIR}/*jackson*.jar +ls ${TEMP_BUNDLE_DIR} run_test -grep "LDGson tests OK" ${TEMP_OUTPUT} >/dev/null || (echo "FAIL: should have run LDGson tests but did not" && exit 1) +grep "skipping LDGson tests" ${TEMP_OUTPUT} >/dev/null || \ + (echo "FAIL: should have skipped LDGson tests but did not; test setup was incorrect" && exit 1) +grep "skipping LDJackson tests" ${TEMP_OUTPUT} >/dev/null || \ + (echo "FAIL: should have skipped LDJackson tests but did not; test setup was incorrect" && exit 1) echo "" -echo " OSGi runtime test - without Gson" -rm ${TEMP_BUNDLE_DIR}/*gson*.jar +echo " OSGi runtime test - with Gson and Jackson" +copy_deps run_test -grep "skipping LDGson tests" ${TEMP_OUTPUT} >/dev/null || \ - (echo "FAIL: should have skipped LDGson tests but did not; test setup was incorrect" && exit 1) +grep "LDGson tests OK" ${TEMP_OUTPUT} >/dev/null || (echo "FAIL: should have run LDGson tests but did not" && exit 1) +grep "LDJackson tests OK" ${TEMP_OUTPUT} >/dev/null || (echo "FAIL: should have run LDJackson tests but did not" && exit 1) diff --git a/packaging-test/test-app/build.gradle b/packaging-test/test-app/build.gradle index b00af000f..1d03d1d63 100644 --- a/packaging-test/test-app/build.gradle +++ b/packaging-test/test-app/build.gradle @@ -21,14 +21,21 @@ allprojects { group = "com.launchdarkly" version = "1.0.0" archivesBaseName = 'test-app-bundle' - sourceCompatibility = 1.7 - targetCompatibility = 1.7 + sourceCompatibility = 1.8 + targetCompatibility = 1.8 } +ext.versions = [ + "gson": "2.7", + "jackson": "2.10.0" +] + dependencies { // Note, the SDK build must have already been run before this, since we're using its product as a dependency implementation fileTree(dir: "../../build/libs", include: "launchdarkly-java-server-sdk-*-thin.jar") - implementation "com.google.code.gson:gson:2.7" + implementation "com.fasterxml.jackson.core:jackson-core:${versions.jackson}" + implementation "com.fasterxml.jackson.core:jackson-databind:${versions.jackson}" + implementation "com.google.code.gson:gson:${versions.gson}" implementation "org.slf4j:slf4j-api:1.7.22" implementation "org.osgi:osgi_R4_core:1.0" osgiRuntime "org.slf4j:slf4j-simple:1.7.22" @@ -47,7 +54,8 @@ jar { 'Import-Package': 'com.launchdarkly.sdk,com.launchdarkly.sdk.json' + ',com.launchdarkly.sdk.server,org.slf4j' + ',org.osgi.framework' + - ',com.google.gson;resolution:=optional' + ',com.google.gson;resolution:=optional' + + ',com.fasterxml.jackson.*;resolution:=optional' ) } 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 034852cbe..a6d87be37 100644 --- a/packaging-test/test-app/src/main/java/testapp/TestApp.java +++ b/packaging-test/test-app/src/main/java/testapp/TestApp.java @@ -48,6 +48,14 @@ public static void main(String[] args) throws Exception { addError("unexpected error in LDGson tests", e); } + try { + Class.forName("testapp.TestAppJacksonTests"); // see TestAppJacksonTests for why we're loading it in this way + } catch (NoClassDefFoundError e) { + log("skipping LDJackson tests because Jackson is not in the classpath"); + } catch (RuntimeException e) { + addError("unexpected error in LDJackson tests", e); + } + if (errors.isEmpty()) { log("PASS"); } else { diff --git a/packaging-test/test-app/src/main/java/testapp/TestAppJacksonTests.java b/packaging-test/test-app/src/main/java/testapp/TestAppJacksonTests.java new file mode 100644 index 000000000..531f3b15b --- /dev/null +++ b/packaging-test/test-app/src/main/java/testapp/TestAppJacksonTests.java @@ -0,0 +1,43 @@ +package testapp; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.launchdarkly.sdk.*; +import com.launchdarkly.sdk.json.*; + +// This code is in its own class that is loaded dynamically because some of our test scenarios +// involve running TestApp without having Jackson in the classpath, to make sure the SDK does not +// *require* the presence of an external Jackson even though it can interoperate with one. + +public class TestAppJacksonTests { + // Use static block so simply loading this class causes the tests to execute + static { + // First try referencing Jackson, so we fail right away if it's not on the classpath + Class c = ObjectMapper.class; + try { + runJacksonTests(); + } catch (Exception e) { + // If we've even gotten to this static block, then Jackson itself *is* on the application's + // classpath, so this must be some other kind of classloading error that we do want to + // report. For instance, a NoClassDefFound error for Jackson at this point, if we're in + // OSGi, would mean that the SDK bundle is unable to see the external Jackson classes. + TestApp.addError("unexpected error in LDJackson tests", e); + } + } + + public static void runJacksonTests() throws Exception { + ObjectMapper jacksonMapper = new ObjectMapper(); + jacksonMapper.registerModule(LDJackson.module()); + + boolean ok = true; + for (JsonSerializationTestData.TestItem item: JsonSerializationTestData.TEST_ITEMS) { + String actualJson = jacksonMapper.writeValueAsString(item.objectToSerialize); + if (!JsonSerializationTestData.assertJsonEquals(item.expectedJson, actualJson, item.objectToSerialize)) { + ok = false; + } + } + + if (ok) { + TestApp.log("LDJackson tests OK"); + } + } +} \ No newline at end of file From 18af928d7e2eaa67b10dc09bba42610750987fd4 Mon Sep 17 00:00:00 2001 From: Harpo Roeder Date: Wed, 7 Oct 2020 11:19:19 -0700 Subject: [PATCH 552/673] add test httpClientCanUseCustomSocketFactory for DefaultFeatureRequestor --- .../server/DefaultFeatureRequestorTest.java | 20 ++++++ .../com/launchdarkly/sdk/server/TestUtil.java | 67 +++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/src/test/java/com/launchdarkly/sdk/server/DefaultFeatureRequestorTest.java b/src/test/java/com/launchdarkly/sdk/server/DefaultFeatureRequestorTest.java index ed98993c0..09d97fd7d 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DefaultFeatureRequestorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultFeatureRequestorTest.java @@ -8,9 +8,11 @@ import java.net.URI; import java.util.Map; +import javax.net.SocketFactory; import javax.net.ssl.SSLHandshakeException; import static com.launchdarkly.sdk.server.TestComponents.clientContext; +import static com.launchdarkly.sdk.server.TestUtil.makeSocketFactorySingleHost; import static com.launchdarkly.sdk.server.TestHttpUtil.httpsServerWithSelfSignedCert; import static com.launchdarkly.sdk.server.TestHttpUtil.jsonResponse; import static com.launchdarkly.sdk.server.TestHttpUtil.makeStartedServer; @@ -169,6 +171,24 @@ public void httpClientCanUseCustomTlsConfig() throws Exception { } } + @Test + public void httpClientCanUseCustomSocketFactory() throws Exception { + URI localhostUri = URI.create("http://localhost"); + try (MockWebServer server = makeStartedServer(jsonResponse(allDataJson))) { + HttpUrl serverUrl = server.url("/"); + LDConfig config = new LDConfig.Builder() + .http(Components.httpConfiguration().socketFactory(makeSocketFactorySingleHost(serverUrl.host(), serverUrl.port()))) + .build(); + + try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(makeHttpConfig(config), localhostUri)) { + FeatureRequestor.AllData data = r.getAllData(false); + verifyExpectedData(data); + + assertEquals(1, server.getRequestCount()); + } + } + } + @Test public void httpClientCanUseProxyConfig() throws Exception { URI fakeBaseUri = URI.create("http://not-a-real-host"); diff --git a/src/test/java/com/launchdarkly/sdk/server/TestUtil.java b/src/test/java/com/launchdarkly/sdk/server/TestUtil.java index 4e1b3cf40..9c647926a 100644 --- a/src/test/java/com/launchdarkly/sdk/server/TestUtil.java +++ b/src/test/java/com/launchdarkly/sdk/server/TestUtil.java @@ -17,6 +17,10 @@ import org.hamcrest.TypeSafeDiagnosingMatcher; import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.SocketAddress; import java.time.Duration; import java.time.Instant; import java.util.HashSet; @@ -29,6 +33,8 @@ import java.util.concurrent.TimeoutException; import java.util.function.Supplier; +import javax.net.SocketFactory; + import static com.launchdarkly.sdk.server.DataModel.FEATURES; import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; import static org.hamcrest.MatcherAssert.assertThat; @@ -255,4 +261,65 @@ protected boolean matchesSafely(LDValue item, Description mismatchDescription) { } }; } + + // returns a socket factory that creates sockets that only connect to host and port + static SocketFactorySingleHost makeSocketFactorySingleHost(String host, int port) { + return new SocketFactorySingleHost(host, port); + } + + private static final class SocketSingleHost extends Socket { + private final String host; + private final int port; + + SocketSingleHost(String host, int port) { + this.host = host; + this.port = port; + } + + @Override public void connect(SocketAddress endpoint) throws IOException { + super.connect(new InetSocketAddress(this.host, this.port), 0); + } + + @Override public void connect(SocketAddress endpoint, int timeout) throws IOException { + super.connect(new InetSocketAddress(this.host, this.port), timeout); + } + } + + public static final class SocketFactorySingleHost extends SocketFactory { + private final String host; + private final int port; + + public SocketFactorySingleHost(String host, int port) { + this.host = host; + this.port = port; + } + + @Override public Socket createSocket() throws IOException { + return new SocketSingleHost(this.host, this.port); + } + + @Override public Socket createSocket(String host, int port) throws IOException { + Socket socket = createSocket(); + socket.connect(new InetSocketAddress(this.host, this.port)); + return socket; + } + + @Override public Socket createSocket(String host, int port, InetAddress localHost, int localPort) throws IOException { + Socket socket = createSocket(); + socket.connect(new InetSocketAddress(this.host, this.port)); + return socket; + } + + @Override public Socket createSocket(InetAddress host, int port) throws IOException { + Socket socket = createSocket(); + socket.connect(new InetSocketAddress(this.host, this.port)); + return socket; + } + + @Override public Socket createSocket(InetAddress host, int port, InetAddress localAddress, int localPort) throws IOException { + Socket socket = createSocket(); + socket.connect(new InetSocketAddress(this.host, this.port)); + return socket; + } + } } From fa90fbf33f661e09d65f5f8b88d5e01dfff94c31 Mon Sep 17 00:00:00 2001 From: Harpo Roeder Date: Wed, 7 Oct 2020 11:27:47 -0700 Subject: [PATCH 553/673] add httpClientCanUseCustomSocketFactory() test for DefaultEventSenderTest --- .../sdk/server/DefaultEventSenderTest.java | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/test/java/com/launchdarkly/sdk/server/DefaultEventSenderTest.java b/src/test/java/com/launchdarkly/sdk/server/DefaultEventSenderTest.java index 1e8d3401c..c80b4d624 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DefaultEventSenderTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultEventSenderTest.java @@ -16,6 +16,7 @@ import java.util.concurrent.TimeUnit; import static com.launchdarkly.sdk.server.TestComponents.clientContext; +import static com.launchdarkly.sdk.server.TestUtil.makeSocketFactorySingleHost; import static com.launchdarkly.sdk.server.TestHttpUtil.httpsServerWithSelfSignedCert; import static com.launchdarkly.sdk.server.TestHttpUtil.makeStartedServer; import static com.launchdarkly.sdk.server.interfaces.EventSender.EventDataKind.ANALYTICS; @@ -32,6 +33,7 @@ import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; +import okhttp3.HttpUrl; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; import okhttp3.mockwebserver.RecordedRequest; @@ -300,6 +302,25 @@ public void httpClientCanUseCustomTlsConfig() throws Exception { } } + @Test + public void httpClientCanUseCustomSocketFactory() throws Exception { + try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { + HttpUrl serverUrl = server.url("/"); + LDConfig config = new LDConfig.Builder() + .http(Components.httpConfiguration().socketFactory(makeSocketFactorySingleHost(serverUrl.host(), serverUrl.port()))) + .build(); + + try (EventSender es = makeEventSender(config)) { + EventSender.Result result = es.sendEventData(DIAGNOSTICS, FAKE_DATA, 1, getBaseUri(server)); + + assertTrue(result.isSuccess()); + assertFalse(result.isMustShutDown()); + } + + assertEquals(1, server.getRequestCount()); + } + } + @Test public void baseUriDoesNotNeedToEndInSlash() throws Exception { try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { From a0b7894d374fb3d5a7ed6a82c998d1f36cba7904 Mon Sep 17 00:00:00 2001 From: Harpo Roeder Date: Wed, 7 Oct 2020 11:32:06 -0700 Subject: [PATCH 554/673] add httpClientCanUseCustomSocketFactory() test to StreamProcessorTest --- .../sdk/server/StreamProcessorTest.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java index 338ca289a..c67c42405 100644 --- a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java @@ -48,6 +48,7 @@ import static com.launchdarkly.sdk.server.TestComponents.dataStoreThatThrowsException; import static com.launchdarkly.sdk.server.TestHttpUtil.eventStreamResponse; import static com.launchdarkly.sdk.server.TestHttpUtil.makeStartedServer; +import static com.launchdarkly.sdk.server.TestUtil.makeSocketFactorySingleHost; import static com.launchdarkly.sdk.server.TestUtil.requireDataSourceStatus; import static com.launchdarkly.sdk.server.TestUtil.shouldNotTimeOut; import static com.launchdarkly.sdk.server.TestUtil.shouldTimeOut; @@ -654,6 +655,27 @@ public void httpClientCanUseCustomTlsConfig() throws Exception { } } } + + @Test + public void httpClientCanUseCustomSocketFactory() throws Exception { + final ConnectionErrorSink errorSink = new ConnectionErrorSink(); + URI localhostUri = URI.create("http://localhost"); + try (MockWebServer server = makeStartedServer(eventStreamResponse(STREAM_RESPONSE_WITH_EMPTY_DATA))) { + HttpUrl serverUrl = server.url("/"); + LDConfig config = new LDConfig.Builder() + .http(Components.httpConfiguration().socketFactory(makeSocketFactorySingleHost(serverUrl.host(), serverUrl.port()))) + .build(); + + try (StreamProcessor sp = createStreamProcessorWithRealHttp(config, localhostUri)) { + sp.connectionErrorHandler = errorSink; + Future ready = sp.start(); + ready.get(); + + assertNull(errorSink.errors.peek()); + assertEquals(1, server.getRequestCount()); + } + } + } @Test public void httpClientCanUseProxyConfig() throws Exception { From 86a601e1f0278763b714176c96ecbcfad802b680 Mon Sep 17 00:00:00 2001 From: Harpo Roeder Date: Fri, 9 Oct 2020 16:43:04 -0700 Subject: [PATCH 555/673] pass URI to in customSocketFactory event test --- .../com/launchdarkly/sdk/server/DefaultEventSenderTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/launchdarkly/sdk/server/DefaultEventSenderTest.java b/src/test/java/com/launchdarkly/sdk/server/DefaultEventSenderTest.java index 5887edd71..73dcc3754 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DefaultEventSenderTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultEventSenderTest.java @@ -311,7 +311,7 @@ public void httpClientCanUseCustomSocketFactory() throws Exception { .build(); try (EventSender es = makeEventSender(config)) { - EventSender.Result result = es.sendEventData(DIAGNOSTICS, FAKE_DATA, 1, getBaseUri(server)); + EventSender.Result result = es.sendEventData(DIAGNOSTICS, FAKE_DATA, 1, URI.create("http://localhost")); assertTrue(result.isSuccess()); assertFalse(result.isMustShutDown()); From 8919a07d433d2f3655c3bc26cd4b802d78f831ba Mon Sep 17 00:00:00 2001 From: Harpo Roeder Date: Fri, 9 Oct 2020 17:19:18 -0700 Subject: [PATCH 556/673] make test less ambiguous --- .../com/launchdarkly/sdk/server/DefaultEventSenderTest.java | 3 ++- .../launchdarkly/sdk/server/DefaultFeatureRequestorTest.java | 4 ++-- .../java/com/launchdarkly/sdk/server/StreamProcessorTest.java | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/test/java/com/launchdarkly/sdk/server/DefaultEventSenderTest.java b/src/test/java/com/launchdarkly/sdk/server/DefaultEventSenderTest.java index 73dcc3754..b67a3ce40 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DefaultEventSenderTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultEventSenderTest.java @@ -310,8 +310,9 @@ public void httpClientCanUseCustomSocketFactory() throws Exception { .http(Components.httpConfiguration().socketFactory(makeSocketFactorySingleHost(serverUrl.host(), serverUrl.port()))) .build(); + URI uriWithWrongPort = URI.create("http://localhost:1"); try (EventSender es = makeEventSender(config)) { - EventSender.Result result = es.sendEventData(DIAGNOSTICS, FAKE_DATA, 1, URI.create("http://localhost")); + EventSender.Result result = es.sendEventData(DIAGNOSTICS, FAKE_DATA, 1, uriWithWrongPort); assertTrue(result.isSuccess()); assertFalse(result.isMustShutDown()); diff --git a/src/test/java/com/launchdarkly/sdk/server/DefaultFeatureRequestorTest.java b/src/test/java/com/launchdarkly/sdk/server/DefaultFeatureRequestorTest.java index 520018d3e..4b254e556 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DefaultFeatureRequestorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultFeatureRequestorTest.java @@ -173,14 +173,14 @@ public void httpClientCanUseCustomTlsConfig() throws Exception { @Test public void httpClientCanUseCustomSocketFactory() throws Exception { - URI localhostUri = URI.create("http://localhost"); try (MockWebServer server = makeStartedServer(jsonResponse(allDataJson))) { HttpUrl serverUrl = server.url("/"); LDConfig config = new LDConfig.Builder() .http(Components.httpConfiguration().socketFactory(makeSocketFactorySingleHost(serverUrl.host(), serverUrl.port()))) .build(); - try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(makeHttpConfig(config), localhostUri)) { + URI uriWithWrongPort = URI.create("http://localhost:1"); + try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(makeHttpConfig(config), uriWithWrongPort)) { FeatureRequestor.AllData data = r.getAllData(false); verifyExpectedData(data); diff --git a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java index c6e8ede71..8a66319a5 100644 --- a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java @@ -674,14 +674,14 @@ public void httpClientCanUseCustomTlsConfig() throws Exception { @Test public void httpClientCanUseCustomSocketFactory() throws Exception { final ConnectionErrorSink errorSink = new ConnectionErrorSink(); - URI localhostUri = URI.create("http://localhost"); try (MockWebServer server = makeStartedServer(eventStreamResponse(STREAM_RESPONSE_WITH_EMPTY_DATA))) { HttpUrl serverUrl = server.url("/"); LDConfig config = new LDConfig.Builder() .http(Components.httpConfiguration().socketFactory(makeSocketFactorySingleHost(serverUrl.host(), serverUrl.port()))) .build(); - try (StreamProcessor sp = createStreamProcessorWithRealHttp(config, localhostUri)) { + URI uriWithWrongPort = URI.create("http://localhost:1"); + try (StreamProcessor sp = createStreamProcessorWithRealHttp(config, uriWithWrongPort)) { sp.connectionErrorHandler = errorSink; Future ready = sp.start(); ready.get(); From d727737f32a292d74ca0a5aa4f2621df68cc612f Mon Sep 17 00:00:00 2001 From: Ben Woskow <48036130+bwoskow-ld@users.noreply.github.com> Date: Tue, 1 Dec 2020 15:36:31 -0800 Subject: [PATCH 557/673] copy rules to new FlagBuilder instances (#273) --- .../sdk/server/integrations/TestData.java | 1 + .../sdk/server/integrations/TestDataTest.java | 13 ++++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/TestData.java b/src/main/java/com/launchdarkly/sdk/server/integrations/TestData.java index 0ddc9b1dd..e0caeb418 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/TestData.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/TestData.java @@ -225,6 +225,7 @@ private FlagBuilder(FlagBuilder from) { this.fallthroughVariation = from.fallthroughVariation; this.variations = new CopyOnWriteArrayList<>(from.variations); this.targets = from.targets == null ? null : new HashMap<>(from.targets); + this.rules = from.rules == null ? null : new ArrayList<>(from.rules); } private boolean isBooleanFlag() { diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataTest.java index 407c535c0..050591b38 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataTest.java @@ -106,7 +106,10 @@ public void addsFlag() throws Exception { @Test public void updatesFlag() throws Exception { TestData td = TestData.dataSource(); - td.update(td.flag("flag1").on(false)); + td.update(td.flag("flag1") + .on(false) + .variationForUser("a", true) + .ifMatch(UserAttribute.NAME, LDValue.of("Lucy")).thenReturn(true)); DataSource ds = td.createDataSource(null, updates); Future started = ds.start(); @@ -123,6 +126,14 @@ public void updatesFlag() throws Exception { ItemDescriptor flag1 = up.item; assertThat(flag1.getVersion(), equalTo(2)); assertThat(flagJson(flag1).get("on").booleanValue(), is(true)); + + String expectedJson = "{\"trackEventsFallthrough\":false,\"deleted\":false," + + "\"variations\":[true,false],\"clientSide\":false,\"rules\":[{\"clauses\":" + + "[{\"op\":\"in\",\"negate\":false,\"values\":[\"Lucy\"],\"attribute\":\"name\"}]," + + "\"id\":\"rule0\",\"trackEvents\":false,\"variation\":0}],\"trackEvents\":false," + + "\"fallthrough\":{\"variation\":0},\"offVariation\":1,\"version\":2,\"targets\":" + + "[{\"values\":[\"a\"],\"variation\":0}],\"key\":\"flag1\",\"on\":true}"; + assertThat(flagJson(flag1), equalTo(LDValue.parse(expectedJson))); } @Test From ace4ad4c3b14f64b75e94a3139456163962a06f1 Mon Sep 17 00:00:00 2001 From: Ben Woskow <48036130+bwoskow-ld@users.noreply.github.com> Date: Wed, 6 Jan 2021 11:42:59 -0800 Subject: [PATCH 558/673] Bump guava version (#274) --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 648dce998..8fa52fc6e 100644 --- a/build.gradle +++ b/build.gradle @@ -70,7 +70,7 @@ ext.libraries = [:] ext.versions = [ "commonsCodec": "1.10", "gson": "2.7", - "guava": "28.2-jre", + "guava": "30.1-jre", "jackson": "2.11.2", "launchdarklyJavaSdkCommon": "1.0.0", "okhttp": "4.8.1", // specify this for the SDK build instead of relying on the transitive dependency from okhttp-eventsource From 88a2e8bfafbeb59150e5e888029851962c9e3dfa Mon Sep 17 00:00:00 2001 From: Ben Woskow <48036130+bwoskow-ld@users.noreply.github.com> Date: Wed, 3 Feb 2021 15:13:53 -0800 Subject: [PATCH 559/673] Removed the guides link --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 749870003..760700a9c 100644 --- a/README.md +++ b/README.md @@ -68,4 +68,3 @@ We encourage pull requests and other contributions from the community. Check out * [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 From 9f2ab7bbb7d8a81fb602bf71254b9eb1e05bb93a Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 19 Feb 2021 14:45:48 -0800 Subject: [PATCH 560/673] increment versions when loading file data, so FlagTracker will work (#275) * increment versions when loading file data, so FlagTracker will work * update doc comment about flag change events with file data --- .../integrations/FileDataSourceImpl.java | 10 ++++-- .../integrations/FileDataSourceParsing.java | 25 +++++++------ .../sdk/server/interfaces/FlagTracker.java | 5 +++ .../ClientWithFileDataSourceTest.java | 2 -- .../server/integrations/DataLoaderTest.java | 35 ++++++++++++++++++- 5 files changed, 60 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java index 20b831cf4..0f7ef4a83 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java @@ -41,6 +41,7 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import static com.launchdarkly.sdk.server.DataModel.FEATURES; import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; @@ -212,9 +213,11 @@ public void stop() { */ static final class DataLoader { private final List sources; + private final AtomicInteger lastVersion; public DataLoader(List sources) { this.sources = new ArrayList<>(sources); + this.lastVersion = new AtomicInteger(0); } public Iterable getSources() { @@ -223,6 +226,7 @@ public Iterable getSources() { public void load(DataBuilder builder) throws FileDataException { + int version = lastVersion.incrementAndGet(); for (SourceInfo s: sources) { try { byte[] data = s.readData(); @@ -230,17 +234,17 @@ public void load(DataBuilder builder) throws FileDataException FlagFileRep fileContents = parser.parse(new ByteArrayInputStream(data)); if (fileContents.flags != null) { for (Map.Entry e: fileContents.flags.entrySet()) { - builder.add(FEATURES, e.getKey(), FlagFactory.flagFromJson(e.getValue())); + builder.add(FEATURES, e.getKey(), FlagFactory.flagFromJson(e.getValue(), version)); } } if (fileContents.flagValues != null) { for (Map.Entry e: fileContents.flagValues.entrySet()) { - builder.add(FEATURES, e.getKey(), FlagFactory.flagWithValue(e.getKey(), e.getValue())); + builder.add(FEATURES, e.getKey(), FlagFactory.flagWithValue(e.getKey(), e.getValue(), version)); } } if (fileContents.segments != null) { for (Map.Entry e: fileContents.segments.entrySet()) { - builder.add(SEGMENTS, e.getKey(), FlagFactory.segmentFromJson(e.getValue())); + builder.add(SEGMENTS, e.getKey(), FlagFactory.segmentFromJson(e.getValue(), version)); } } } catch (FileDataException e) { diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceParsing.java b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceParsing.java index 63c31fcdb..03bfd3676 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceParsing.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceParsing.java @@ -5,6 +5,7 @@ import com.google.gson.JsonObject; import com.google.gson.JsonSyntaxException; import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.ObjectBuilder; import com.launchdarkly.sdk.server.integrations.FileDataSourceBuilder.SourceInfo; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; @@ -176,21 +177,18 @@ public FlagFileRep parse(InputStream input) throws FileDataException, IOExceptio static abstract class FlagFactory { private FlagFactory() {} - static ItemDescriptor flagFromJson(String jsonString) { - return FEATURES.deserialize(jsonString); - } - - static ItemDescriptor flagFromJson(LDValue jsonTree) { - return flagFromJson(jsonTree.toJsonString()); + static ItemDescriptor flagFromJson(LDValue jsonTree, int version) { + return FEATURES.deserialize(replaceVersion(jsonTree, version).toJsonString()); } /** * 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 ItemDescriptor flagWithValue(String key, LDValue jsonValue) { + static ItemDescriptor flagWithValue(String key, LDValue jsonValue, int version) { LDValue o = LDValue.buildObject() .put("key", key) + .put("version", version) .put("on", true) .put("variations", LDValue.buildArray().add(jsonValue).build()) .put("fallthrough", LDValue.buildObject().put("variation", 0).build()) @@ -200,12 +198,17 @@ static ItemDescriptor flagWithValue(String key, LDValue jsonValue) { return FEATURES.deserialize(o.toJsonString()); } - static ItemDescriptor segmentFromJson(String jsonString) { - return SEGMENTS.deserialize(jsonString); + static ItemDescriptor segmentFromJson(LDValue jsonTree, int version) { + return SEGMENTS.deserialize(replaceVersion(jsonTree, version).toJsonString()); } - static ItemDescriptor segmentFromJson(LDValue jsonTree) { - return segmentFromJson(jsonTree.toJsonString()); + private static LDValue replaceVersion(LDValue objectValue, int version) { + ObjectBuilder b = LDValue.buildObject(); + for (String key: objectValue.keys()) { + b.put(key, objectValue.get(key)); + } + b.put("version", version); + return b.build(); } } } diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagTracker.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagTracker.java index e3627ee7f..fbe580c32 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagTracker.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/FlagTracker.java @@ -25,6 +25,11 @@ public interface FlagTracker { * previously returned for some user. If you want to track flag value changes, use * {@link #addFlagValueChangeListener(String, LDUser, FlagValueChangeListener)} instead. *

    + * If using the file data source ({@link com.launchdarkly.sdk.server.integrations.FileData}), any change in + * a data file will be treated as a change to every flag. Again, use + * {@link #addFlagValueChangeListener(String, LDUser, FlagValueChangeListener)} (or just re-evaluate the flag + * yourself) if you want to know whether this is a change that really affects a flag's value. + *

    * Change events only work if the SDK is actually connecting to LaunchDarkly (or using the file data source). * If the SDK is only reading flags from a database ({@link Components#externalUpdatesOnly()}) then it cannot * know when there is a change, because flags are read on an as-needed basis. diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/ClientWithFileDataSourceTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/ClientWithFileDataSourceTest.java index 9f0b8430b..57951edae 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/ClientWithFileDataSourceTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/ClientWithFileDataSourceTest.java @@ -5,8 +5,6 @@ import com.launchdarkly.sdk.server.Components; import com.launchdarkly.sdk.server.LDClient; import com.launchdarkly.sdk.server.LDConfig; -import com.launchdarkly.sdk.server.integrations.FileData; -import com.launchdarkly.sdk.server.integrations.FileDataSourceBuilder; import org.junit.Test; diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/DataLoaderTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/DataLoaderTest.java index 60deba210..965740a13 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/DataLoaderTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/DataLoaderTest.java @@ -3,11 +3,14 @@ import com.google.gson.Gson; import com.google.gson.JsonElement; import com.google.gson.JsonObject; +import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.server.integrations.FileDataSourceImpl.DataBuilder; import com.launchdarkly.sdk.server.integrations.FileDataSourceImpl.DataLoader; import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FileDataException; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; import org.junit.Assert; import org.junit.Test; @@ -74,7 +77,7 @@ public void flagValueIsConvertedToFlag() throws Exception { DataLoader ds = new DataLoader(FileData.dataSource().filePaths(resourceFilePath("value-only.json")).sources); JsonObject expected = gson.fromJson( "{\"key\":\"flag2\",\"on\":true,\"fallthrough\":{\"variation\":0},\"variations\":[\"value2\"]," + - "\"trackEvents\":false,\"deleted\":false,\"version\":0}", + "\"trackEvents\":false,\"deleted\":false,\"version\":1}", JsonObject.class); ds.load(builder); ItemDescriptor flag = toDataMap(builder.build()).get(FEATURES).get(FLAG_VALUE_1_KEY); @@ -125,10 +128,40 @@ public void duplicateSegmentKeyThrowsException() throws Exception { } } + @Test + public void versionsAreIncrementedForEachLoad() throws Exception { + DataLoader ds = new DataLoader(FileData.dataSource().filePaths( + resourceFilePath("flag-only.json"), + resourceFilePath("segment-only.json"), + resourceFilePath("value-only.json") + ).sources); + + DataBuilder data1 = new DataBuilder(); + ds.load(data1); + assertVersionsMatch(data1.build(), 1); + + DataBuilder data2 = new DataBuilder(); + ds.load(data2); + assertVersionsMatch(data2.build(), 2); + } + private void assertDataHasItemsOfKind(DataKind kind) { Map items = toDataMap(builder.build()).get(kind); if (items == null || items.size() == 0) { Assert.fail("expected at least one item in \"" + kind.getName() + "\", received: " + builder.build()); } } + + private void assertVersionsMatch(FullDataSet data, int expectedVersion) { + for (Map.Entry> kv1: data.getData()) { + DataKind kind = kv1.getKey(); + for (Map.Entry kv2: kv1.getValue().getItems()) { + ItemDescriptor item = kv2.getValue(); + String jsonData = kind.serialize(item); + assertThat("descriptor version of " + kv2.getKey(), item.getVersion(), equalTo(expectedVersion)); + assertThat("version in data model object of " + kv2.getKey(), LDValue.parse(jsonData).get("version"), + equalTo(LDValue.of(expectedVersion))); + } + } + } } From 0d6e3c62e72479a0b57a5808cd4adef9be3030a6 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 8 Mar 2021 20:42:35 -0800 Subject: [PATCH 561/673] add ability to ignore duplicate keys in file data (#276) --- .../sdk/server/integrations/FileData.java | 18 ++++++ .../integrations/FileDataSourceBuilder.java | 17 ++++- .../integrations/FileDataSourceImpl.java | 19 +++++- .../server/integrations/DataLoaderTest.java | 62 ++++++++++++++----- .../integrations/FileDataSourceTestData.java | 2 +- .../resources/filesource/all-properties.json | 2 +- .../resources/filesource/all-properties.yml | 2 +- .../filesource/flag-with-duplicate-key.json | 2 +- .../resources/filesource/segment-only.json | 2 +- .../resources/filesource/segment-only.yml | 2 +- .../segment-with-duplicate-key.json | 4 +- .../filesource/value-with-duplicate-key.json | 2 +- 12 files changed, 105 insertions(+), 29 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/FileData.java b/src/main/java/com/launchdarkly/sdk/server/integrations/FileData.java index a6081eb98..57f4357dc 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/FileData.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/FileData.java @@ -14,6 +14,24 @@ * @see TestData */ public abstract class FileData { + /** + * Determines how duplicate feature flag or segment keys are handled. + * + * @see FileDataSourceBuilder#duplicateKeysHandling + * @since 5.3.0 + */ + public enum DuplicateKeysHandling { + /** + * Data loading will fail if keys are duplicated across files. + */ + FAIL, + + /** + * Keys that are duplicated across files will be ignored, and the first occurrence will be used. + */ + IGNORE + } + /** * Creates a {@link FileDataSourceBuilder} which you can use to configure the file data source. * This allows you to use local files (or classpath resources containing file data) as a source of diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBuilder.java b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBuilder.java index 5df65352b..af7838fe0 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBuilder.java @@ -28,6 +28,7 @@ public final class FileDataSourceBuilder implements DataSourceFactory { final List sources = new ArrayList<>(); // visible for tests private boolean autoUpdate = false; + private FileData.DuplicateKeysHandling duplicateKeysHandling = FileData.DuplicateKeysHandling.FAIL; /** * Adds any number of source files for loading flag data, specifying each file path as a string. The files will @@ -101,13 +102,27 @@ public FileDataSourceBuilder autoUpdate(boolean autoUpdate) { this.autoUpdate = autoUpdate; return this; } + + /** + * Specifies how to handle keys that are duplicated across files. + *

    + * By default, data loading will fail if keys are duplicated across files ({@link FileData.DuplicateKeysHandling#FAIL}). + * + * @param duplicateKeysHandling specifies how to handle duplicate keys + * @return the same factory object + * @since 5.3.0 + */ + public FileDataSourceBuilder duplicateKeysHandling(FileData.DuplicateKeysHandling duplicateKeysHandling) { + this.duplicateKeysHandling = duplicateKeysHandling; + return this; + } /** * Used internally by the LaunchDarkly client. */ @Override public DataSource createDataSource(ClientContext context, DataSourceUpdates dataSourceUpdates) { - return new FileDataSourceImpl(dataSourceUpdates, sources, autoUpdate); + return new FileDataSourceImpl(dataSourceUpdates, sources, autoUpdate, duplicateKeysHandling); } static abstract class SourceInfo { diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java index 0f7ef4a83..e1ade6b6f 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java @@ -58,12 +58,19 @@ final class FileDataSourceImpl implements DataSource { private final DataSourceUpdates dataSourceUpdates; private final DataLoader dataLoader; + private final FileData.DuplicateKeysHandling duplicateKeysHandling; private final AtomicBoolean inited = new AtomicBoolean(false); private final FileWatcher fileWatcher; - FileDataSourceImpl(DataSourceUpdates dataSourceUpdates, List sources, boolean autoUpdate) { + FileDataSourceImpl( + DataSourceUpdates dataSourceUpdates, + List sources, + boolean autoUpdate, + FileData.DuplicateKeysHandling duplicateKeysHandling + ) { this.dataSourceUpdates = dataSourceUpdates; this.dataLoader = new DataLoader(sources); + this.duplicateKeysHandling = duplicateKeysHandling; FileWatcher fw = null; if (autoUpdate) { @@ -97,7 +104,7 @@ public Future start() { } private boolean reload() { - DataBuilder builder = new DataBuilder(); + DataBuilder builder = new DataBuilder(duplicateKeysHandling); try { dataLoader.load(builder); } catch (FileDataException e) { @@ -262,6 +269,11 @@ public void load(DataBuilder builder) throws FileDataException */ static final class DataBuilder { private final Map> allData = new HashMap<>(); + private final FileData.DuplicateKeysHandling duplicateKeysHandling; + + public DataBuilder(FileData.DuplicateKeysHandling duplicateKeysHandling) { + this.duplicateKeysHandling = duplicateKeysHandling; + } public FullDataSet build() { ImmutableList.Builder>> allBuilder = ImmutableList.builder(); @@ -278,6 +290,9 @@ public void add(DataKind kind, String key, ItemDescriptor item) throws FileDataE allData.put(kind, items); } if (items.containsKey(key)) { + if (duplicateKeysHandling == FileData.DuplicateKeysHandling.IGNORE) { + return; + } throw new FileDataException("in " + kind.getName() + ", key \"" + key + "\" was already defined", null, null); } items.put(key, item); diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/DataLoaderTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/DataLoaderTest.java index 965740a13..357f8fb4a 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/DataLoaderTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/DataLoaderTest.java @@ -1,8 +1,5 @@ package com.launchdarkly.sdk.server.integrations; -import com.google.gson.Gson; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.server.integrations.FileDataSourceImpl.DataBuilder; import com.launchdarkly.sdk.server.integrations.FileDataSourceImpl.DataLoader; @@ -29,8 +26,7 @@ @SuppressWarnings("javadoc") public class DataLoaderTest { - private static final Gson gson = new Gson(); - private DataBuilder builder = new DataBuilder(); + private DataBuilder builder = new DataBuilder(FileData.DuplicateKeysHandling.FAIL); @Test public void canLoadFromFilePath() throws Exception { @@ -75,22 +71,20 @@ public void canLoadMultipleFiles() throws Exception { @Test public void flagValueIsConvertedToFlag() throws Exception { DataLoader ds = new DataLoader(FileData.dataSource().filePaths(resourceFilePath("value-only.json")).sources); - JsonObject expected = gson.fromJson( + LDValue expected = LDValue.parse( "{\"key\":\"flag2\",\"on\":true,\"fallthrough\":{\"variation\":0},\"variations\":[\"value2\"]," + - "\"trackEvents\":false,\"deleted\":false,\"version\":1}", - JsonObject.class); + "\"trackEvents\":false,\"deleted\":false,\"version\":1}"); ds.load(builder); - ItemDescriptor flag = toDataMap(builder.build()).get(FEATURES).get(FLAG_VALUE_1_KEY); - JsonObject actual = gson.toJsonTree(flag.getItem()).getAsJsonObject(); + LDValue actual = getItemAsJson(builder, FEATURES, FLAG_VALUE_1_KEY); // 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())); + for (String key: expected.keys()) { + assertThat(actual.get(key), equalTo(expected.get(key))); } } @Test - public void duplicateFlagKeyInFlagsThrowsException() throws Exception { + public void duplicateFlagKeyInFlagsThrowsExceptionByDefault() throws Exception { try { DataLoader ds = new DataLoader(FileData.dataSource().filePaths( resourceFilePath("flag-only.json"), @@ -103,7 +97,7 @@ public void duplicateFlagKeyInFlagsThrowsException() throws Exception { } @Test - public void duplicateFlagKeyInFlagsAndFlagValuesThrowsException() throws Exception { + public void duplicateFlagKeyInFlagsAndFlagValuesThrowsExceptionByDefault() throws Exception { try { DataLoader ds = new DataLoader(FileData.dataSource().filePaths( resourceFilePath("flag-only.json"), @@ -116,7 +110,7 @@ public void duplicateFlagKeyInFlagsAndFlagValuesThrowsException() throws Excepti } @Test - public void duplicateSegmentKeyThrowsException() throws Exception { + public void duplicateSegmentKeyThrowsExceptionByDefault() throws Exception { try { DataLoader ds = new DataLoader(FileData.dataSource().filePaths( resourceFilePath("segment-only.json"), @@ -128,6 +122,35 @@ public void duplicateSegmentKeyThrowsException() throws Exception { } } + @Test + public void duplicateKeysCanBeAllowed() throws Exception { + DataBuilder data1 = new DataBuilder(FileData.DuplicateKeysHandling.IGNORE); + DataLoader loader1 = new DataLoader(FileData.dataSource().filePaths( + resourceFilePath("flag-only.json"), + resourceFilePath("flag-with-duplicate-key.json") + ).sources); + loader1.load(data1); + assertThat(getItemAsJson(data1, FEATURES, "flag1").get("on"), equalTo(LDValue.of(true))); // value from first file + + DataBuilder data2 = new DataBuilder(FileData.DuplicateKeysHandling.IGNORE); + DataLoader loader2 = new DataLoader(FileData.dataSource().filePaths( + resourceFilePath("value-with-duplicate-key.json"), + resourceFilePath("flag-only.json") + ).sources); + loader2.load(data2); + assertThat(getItemAsJson(data2, FEATURES, "flag2").get("variations"), + equalTo(LDValue.buildArray().add(LDValue.of("value2a")).build())); // value from first file + + DataBuilder data3 = new DataBuilder(FileData.DuplicateKeysHandling.IGNORE); + DataLoader loader3 = new DataLoader(FileData.dataSource().filePaths( + resourceFilePath("segment-only.json"), + resourceFilePath("segment-with-duplicate-key.json") + ).sources); + loader3.load(data3); + assertThat(getItemAsJson(data3, SEGMENTS, "seg1").get("included"), + equalTo(LDValue.buildArray().add(LDValue.of("user1")).build())); // value from first file + } + @Test public void versionsAreIncrementedForEachLoad() throws Exception { DataLoader ds = new DataLoader(FileData.dataSource().filePaths( @@ -136,11 +159,11 @@ public void versionsAreIncrementedForEachLoad() throws Exception { resourceFilePath("value-only.json") ).sources); - DataBuilder data1 = new DataBuilder(); + DataBuilder data1 = new DataBuilder(FileData.DuplicateKeysHandling.FAIL); ds.load(data1); assertVersionsMatch(data1.build(), 1); - DataBuilder data2 = new DataBuilder(); + DataBuilder data2 = new DataBuilder(FileData.DuplicateKeysHandling.FAIL); ds.load(data2); assertVersionsMatch(data2.build(), 2); } @@ -164,4 +187,9 @@ private void assertVersionsMatch(FullDataSet data, int expectedV } } } + + private LDValue getItemAsJson(DataBuilder builder, DataKind kind, String key) { + ItemDescriptor flag = toDataMap(builder.build()).get(kind).get(key); + return LDValue.parse(kind.serialize(flag)); + } } diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTestData.java b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTestData.java index ab1d55023..e0c18399c 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTestData.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTestData.java @@ -29,7 +29,7 @@ public class FileDataSourceTestData { ImmutableMap.of(FLAG_VALUE_1_KEY, FLAG_VALUE_1); public static final String FULL_SEGMENT_1_KEY = "seg1"; - public static final LDValue FULL_SEGMENT_1 = LDValue.parse("{\"key\":\"seg1\",\"include\":[\"user1\"]}"); + public static final LDValue FULL_SEGMENT_1 = LDValue.parse("{\"key\":\"seg1\",\"included\":[\"user1\"]}"); public static final Map FULL_SEGMENTS = ImmutableMap.of(FULL_SEGMENT_1_KEY, FULL_SEGMENT_1); diff --git a/src/test/resources/filesource/all-properties.json b/src/test/resources/filesource/all-properties.json index 13e8a74bd..bdcefaa69 100644 --- a/src/test/resources/filesource/all-properties.json +++ b/src/test/resources/filesource/all-properties.json @@ -15,7 +15,7 @@ "segments": { "seg1": { "key": "seg1", - "include": ["user1"] + "included": ["user1"] } } } diff --git a/src/test/resources/filesource/all-properties.yml b/src/test/resources/filesource/all-properties.yml index de8b71f90..ce0234687 100644 --- a/src/test/resources/filesource/all-properties.yml +++ b/src/test/resources/filesource/all-properties.yml @@ -14,4 +14,4 @@ flagValues: segments: seg1: key: seg1 - include: ["user1"] + included: ["user1"] diff --git a/src/test/resources/filesource/flag-with-duplicate-key.json b/src/test/resources/filesource/flag-with-duplicate-key.json index 2a3735ae1..b6a1ae0a6 100644 --- a/src/test/resources/filesource/flag-with-duplicate-key.json +++ b/src/test/resources/filesource/flag-with-duplicate-key.json @@ -6,7 +6,7 @@ }, "flag1": { "key": "flag1", - "on": true + "on": false } } } \ No newline at end of file diff --git a/src/test/resources/filesource/segment-only.json b/src/test/resources/filesource/segment-only.json index 6f9e31dd2..da134d5b8 100644 --- a/src/test/resources/filesource/segment-only.json +++ b/src/test/resources/filesource/segment-only.json @@ -2,7 +2,7 @@ "segments": { "seg1": { "key": "seg1", - "include": ["user1"] + "included": ["user1"] } } } diff --git a/src/test/resources/filesource/segment-only.yml b/src/test/resources/filesource/segment-only.yml index b7db027ad..cfbab40a2 100644 --- a/src/test/resources/filesource/segment-only.yml +++ b/src/test/resources/filesource/segment-only.yml @@ -2,4 +2,4 @@ segments: seg1: key: seg1 - include: ["user1"] + included: ["user1"] diff --git a/src/test/resources/filesource/segment-with-duplicate-key.json b/src/test/resources/filesource/segment-with-duplicate-key.json index 44ed90d16..a71b5e6a9 100644 --- a/src/test/resources/filesource/segment-with-duplicate-key.json +++ b/src/test/resources/filesource/segment-with-duplicate-key.json @@ -2,11 +2,11 @@ "segments": { "another": { "key": "another", - "include": [] + "included": [] }, "seg1": { "key": "seg1", - "include": ["user1"] + "included": ["user1a"] } } } diff --git a/src/test/resources/filesource/value-with-duplicate-key.json b/src/test/resources/filesource/value-with-duplicate-key.json index d366f8fa9..abd6dc7b6 100644 --- a/src/test/resources/filesource/value-with-duplicate-key.json +++ b/src/test/resources/filesource/value-with-duplicate-key.json @@ -1,6 +1,6 @@ { "flagValues": { "flag1": "value1", - "flag2": "value2" + "flag2": "value2a" } } \ No newline at end of file From 57e4dd2fb3fd3d0c23f0c0c7ceedb7d3f807008f Mon Sep 17 00:00:00 2001 From: Elliot <35050275+Apache-HB@users.noreply.github.com> Date: Mon, 5 Apr 2021 20:31:41 +0100 Subject: [PATCH 562/673] add alias events (#278) * add alias events and function * update tests for new functionality * update javadoc strings --- .../launchdarkly/sdk/server/EventFactory.java | 14 ++- .../sdk/server/EventOutputFormatter.java | 20 +++- .../com/launchdarkly/sdk/server/LDClient.java | 5 + .../sdk/server/interfaces/Event.java | 88 ++++++++++++++++- .../server/interfaces/LDClientInterface.java | 16 +++- .../sdk/server/EventOutputTest.java | 94 ++++++++++++++++++- .../sdk/server/LDClientEventTest.java | 16 ++++ 7 files changed, 244 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/EventFactory.java b/src/main/java/com/launchdarkly/sdk/server/EventFactory.java index 0b5b1aa7c..d7daf9cea 100644 --- a/src/main/java/com/launchdarkly/sdk/server/EventFactory.java +++ b/src/main/java/com/launchdarkly/sdk/server/EventFactory.java @@ -36,7 +36,9 @@ abstract Event.FeatureRequest newUnknownFeatureRequestEvent( abstract Event.Custom newCustomEvent(String key, LDUser user, LDValue data, Double metricValue); abstract Event.Identify newIdentifyEvent(LDUser user); - + + abstract Event.AliasEvent newAliasEvent(LDUser user, LDUser previousUser); + final Event.FeatureRequest newFeatureRequestEvent( DataModel.FeatureFlag flag, LDUser user, @@ -166,6 +168,11 @@ Event.Custom newCustomEvent(String key, LDUser user, LDValue data, Double metric Event.Identify newIdentifyEvent(LDUser user) { return new Event.Identify(timestampFn.get(), user); } + + @Override + Event.AliasEvent newAliasEvent(LDUser user, LDUser previousUser) { + return new Event.AliasEvent(timestampFn.get(), user, previousUser); + } } static final class Disabled extends EventFactory { @@ -191,6 +198,11 @@ final Custom newCustomEvent(String key, LDUser user, LDValue data, Double metric final Identify newIdentifyEvent(LDUser user) { return null; } + + @Override + Event.AliasEvent newAliasEvent(LDUser user, LDUser previousUser) { + return null; + } } private static boolean isExperiment(DataModel.FeatureFlag flag, EvaluationReason reason) { diff --git a/src/main/java/com/launchdarkly/sdk/server/EventOutputFormatter.java b/src/main/java/com/launchdarkly/sdk/server/EventOutputFormatter.java index 9674b965c..afd74e16f 100644 --- a/src/main/java/com/launchdarkly/sdk/server/EventOutputFormatter.java +++ b/src/main/java/com/launchdarkly/sdk/server/EventOutputFormatter.java @@ -64,26 +64,38 @@ private final void writeOutputEvent(Event event, JsonWriter jw) throws IOExcepti jw.value(fe.getPrereqOf()); } writeEvaluationReason("reason", fe.getReason(), jw); - jw.endObject(); + if (!fe.getContextKind().equals("user")) { + jw.name("contextKind").value(fe.getContextKind()); + } } else if (event instanceof Event.Identify) { startEvent(event, "identify", event.getUser() == null ? null : event.getUser().getKey(), jw); writeUser(event.getUser(), jw); - jw.endObject(); } else if (event instanceof Event.Custom) { Event.Custom ce = (Event.Custom)event; startEvent(event, "custom", ce.getKey(), jw); writeUserOrKey(ce, false, jw); writeLDValue("data", ce.getData(), jw); + if (!ce.getContextKind().equals("user")) { + jw.name("contextKind").value(ce.getContextKind()); + } if (ce.getMetricValue() != null) { jw.name("metricValue"); jw.value(ce.getMetricValue()); } - jw.endObject(); } else if (event instanceof Event.Index) { startEvent(event, "index", null, jw); writeUser(event.getUser(), jw); - jw.endObject(); + } else if (event instanceof Event.AliasEvent) { + Event.AliasEvent ae = (Event.AliasEvent)event; + startEvent(event, "alias", ae.getKey(), jw); + jw.name("contextKind").value(ae.getContextKind()); + jw.name("previousKey").value(ae.getPreviousKey()); + jw.name("previousContextKind").value(ae.getPreviousContextKind()); + } else { + return; } + + jw.endObject(); } private final void writeSummaryEvent(EventSummarizer.EventSummary summary, JsonWriter jw) throws IOException { diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClient.java b/src/main/java/com/launchdarkly/sdk/server/LDClient.java index 2f50d0a73..edef1b31e 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClient.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClient.java @@ -526,6 +526,11 @@ public String secureModeHash(LDUser user) { return null; } + @Override + public void alias(LDUser user, LDUser previousUser) { + this.eventProcessor.sendEvent(eventFactoryDefault.newAliasEvent(user, previousUser)); + } + /** * Returns the current version string of the client library. * @return a version string conforming to Semantic Versioning (http://semver.org) diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/Event.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/Event.java index cb558f305..76614117f 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/Event.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/Event.java @@ -40,6 +40,15 @@ public long getCreationDate() { public LDUser getUser() { return user; } + + /** + * Convert a user into a context kind string + * @param user the user to get the context kind from + * @return the context kind string + */ + private static final String computeContextKind(LDUser user) { + return user != null && user.isAnonymous() ? "anonymousUser" : "user"; + } /** * A custom event created with {@link LDClientInterface#track(String, LDUser)} or one of its overloads. @@ -48,6 +57,7 @@ public static final class Custom extends Event { private final String key; private final LDValue data; private final Double metricValue; + private final String contextKind; /** * Constructs a custom event. @@ -61,8 +71,9 @@ public static final class Custom extends Event { public Custom(long timestamp, String key, LDUser user, LDValue data, Double metricValue) { super(timestamp, user); this.key = key; - this.data = data == null ? LDValue.ofNull() : data; + this.data = LDValue.normalize(data); this.metricValue = metricValue; + this.contextKind = computeContextKind(user); } /** @@ -88,6 +99,14 @@ public LDValue getData() { public Double getMetricValue() { return metricValue; } + + /** + * The context kind of the user that generated this event + * @return the context kind + */ + public String getContextKind() { + return contextKind; + } } /** @@ -132,6 +151,7 @@ public static final class FeatureRequest extends Event { private final long debugEventsUntilDate; private final EvaluationReason reason; private final boolean debug; + private final String contextKind; /** * Constructs a feature request event. @@ -162,6 +182,7 @@ public FeatureRequest(long timestamp, String key, LDUser user, int version, int this.debugEventsUntilDate = debugEventsUntilDate; this.reason = reason; this.debug = debug; + this.contextKind = computeContextKind(user); } /** @@ -243,5 +264,70 @@ public EvaluationReason getReason() { public boolean isDebug() { return debug; } + + /** + * The context kind of the user that generated this event + * @return the context kind + */ + public String getContextKind() { + return contextKind; + } + } + + /** + * An event generated by aliasing users + * @since 5.4.0 + */ + public static final class AliasEvent extends Event { + private final String key; + private final String contextKind; + private final String previousKey; + private final String previousContextKind; + + /** + * Constructs an alias event. + * @param timestamp when the event was created + * @param user the user being aliased to + * @param previousUser the user being aliased from + */ + public AliasEvent(long timestamp, LDUser user, LDUser previousUser) { + super(timestamp, user); + this.key = user.getKey(); + this.contextKind = computeContextKind(user); + this.previousKey = previousUser.getKey(); + this.previousContextKind = computeContextKind(previousUser); + } + + /** + * Get the key of the user being aliased to + * @return the user key + */ + public String getKey() { + return key; + } + + /** + * Get the kind of the user being aliased to + * @return the context kind + */ + public String getContextKind() { + return contextKind; + } + + /** + * Get the key of the user being aliased from + * @return the previous user key + */ + public String getPreviousKey() { + return previousKey; + } + + /** + * Get the kind of the user being aliased from + * @return the previous context kind + */ + public String getPreviousContextKind() { + return previousContextKind; + } } } \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/LDClientInterface.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/LDClientInterface.java index 0c5a48cb3..2996ed66a 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/LDClientInterface.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/LDClientInterface.java @@ -266,9 +266,23 @@ public interface LDClientInterface extends Closeable { * For more info: https://github.com/launchdarkly/js-client#secure-mode * @param user the user to be hashed along with the SDK key * @return the hash, or null if the hash could not be calculated - */ + */ String secureModeHash(LDUser user); + /** + * Associates two users for analytics purposes. + * + * This can be helpful in the situation where a person is represented by multiple + * LaunchDarkly users. This may happen, for example, when a person initially logs into + * an application-- the person might be represented by an anonymous user prior to logging + * in and a different user after logging in, as denoted by a different user key. + * + * @param user the newly identified user. + * @param previousUser the previously identified user. + * @since 5.4.0 + */ + void alias(LDUser user, LDUser previousUser); + /** * The current version string of the SDK. * @return a string in Semantic Versioning 2.0.0 format diff --git a/src/test/java/com/launchdarkly/sdk/server/EventOutputTest.java b/src/test/java/com/launchdarkly/sdk/server/EventOutputTest.java index 5450669c9..15165abac 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EventOutputTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EventOutputTest.java @@ -9,6 +9,7 @@ import com.launchdarkly.sdk.UserAttribute; import com.launchdarkly.sdk.server.EventSummarizer.EventSummary; import com.launchdarkly.sdk.server.interfaces.Event; +import com.launchdarkly.sdk.server.interfaces.Event.AliasEvent; import com.launchdarkly.sdk.server.interfaces.Event.FeatureRequest; import org.junit.Test; @@ -166,12 +167,16 @@ private void testPrivateAttributes(EventsConfiguration config, LDUser user, Stri assertEquals(o.build(), userJson); } - private ObjectBuilder buildFeatureEventProps(String key) { + private ObjectBuilder buildFeatureEventProps(String key, String userKey) { return LDValue.buildObject() .put("kind", "feature") .put("key", key) .put("creationDate", 100000) - .put("userKey", "userkey"); + .put("userKey", userKey); + } + + private ObjectBuilder buildFeatureEventProps(String key) { + return buildFeatureEventProps(key, "userkey"); } @Test @@ -180,6 +185,7 @@ public void featureEventIsSerialized() throws Exception { EventFactory factoryWithReason = eventFactoryWithTimestamp(100000, true); DataModel.FeatureFlag flag = flagBuilder("flag").version(11).build(); LDUser user = new LDUser.Builder("userkey").name("me").build(); + LDUser anon = new LDUser.Builder("anonymouskey").anonymous(true).build(); EventOutputFormatter f = new EventOutputFormatter(defaultEventsConfig()); FeatureRequest feWithVariation = factory.newFeatureRequestEvent(flag, user, @@ -266,6 +272,18 @@ public void featureEventIsSerialized() throws Exception { .put("prereqOf", "parent") .build(); assertEquals(feJson8, getSingleOutputEvent(f, prereqWithoutResult)); + + FeatureRequest anonFeWithVariation = factory.newFeatureRequestEvent(flag, anon, + new Evaluator.EvalResult(LDValue.of("flagvalue"), 1, EvaluationReason.off()), + LDValue.of("defaultvalue")); + LDValue anonFeJson1 = buildFeatureEventProps("flag", "anonymouskey") + .put("version", 11) + .put("variation", 1) + .put("value", "flagvalue") + .put("default", "defaultvalue") + .put("contextKind", "anonymousUser") + .build(); + assertEquals(anonFeJson1, getSingleOutputEvent(f, anonFeWithVariation)); } @Test @@ -288,6 +306,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(); + LDUser anon = new LDUser.Builder("userkey").name("me").anonymous(true).build(); EventOutputFormatter f = new EventOutputFormatter(defaultEventsConfig()); Event.Custom ceWithoutData = factory.newCustomEvent("customkey", user, LDValue.ofNull(), null); @@ -329,6 +348,18 @@ public void customEventIsSerialized() throws IOException { "\"metricValue\":2.5" + "}"); assertEquals(ceJson4, getSingleOutputEvent(f, ceWithDataAndMetric)); + + Event.Custom ceWithDataAndMetricAnon = factory.newCustomEvent("customkey", anon, LDValue.of("thing"), 2.5); + LDValue ceJson5 = parseValue("{" + + "\"kind\":\"custom\"," + + "\"creationDate\":100000," + + "\"key\":\"customkey\"," + + "\"userKey\":\"userkey\"," + + "\"data\":\"thing\"," + + "\"metricValue\":2.5," + + "\"contextKind\":\"anonymousUser\"" + + "}"); + assertEquals(ceJson5, getSingleOutputEvent(f, ceWithDataAndMetricAnon)); } @Test @@ -401,6 +432,65 @@ public void unknownEventClassIsNotSerialized() throws Exception { assertEquals("[]", w.toString()); } + @Test + public void aliasEventIsSerialized() throws IOException { + EventFactory factory = eventFactoryWithTimestamp(1000, false); + LDUser user1 = new LDUser.Builder("bob-key").build(); + LDUser user2 = new LDUser.Builder("jeff-key").build(); + LDUser anon1 = new LDUser.Builder("bob-key-anon").anonymous(true).build(); + LDUser anon2 = new LDUser.Builder("jeff-key-anon").anonymous(true).build(); + AliasEvent userToUser = factory.newAliasEvent(user1, user2); + AliasEvent userToAnon = factory.newAliasEvent(anon1, user1); + AliasEvent anonToUser = factory.newAliasEvent(user1, anon1); + AliasEvent anonToAnon = factory.newAliasEvent(anon1, anon2); + + EventOutputFormatter fmt = new EventOutputFormatter(defaultEventsConfig()); + + LDValue userToUserExpected = parseValue("{" + + "\"kind\":\"alias\"," + + "\"creationDate\":1000," + + "\"key\":\"bob-key\"," + + "\"contextKind\":\"user\"," + + "\"previousKey\":\"jeff-key\"," + + "\"previousContextKind\":\"user\"" + + "}"); + + assertEquals(userToUserExpected, getSingleOutputEvent(fmt, userToUser)); + + LDValue userToAnonExpected = parseValue("{" + + "\"kind\":\"alias\"," + + "\"creationDate\":1000," + + "\"key\":\"bob-key-anon\"," + + "\"contextKind\":\"anonymousUser\"," + + "\"previousKey\":\"bob-key\"," + + "\"previousContextKind\":\"user\"" + + "}"); + + assertEquals(userToAnonExpected, getSingleOutputEvent(fmt, userToAnon)); + + LDValue anonToUserExpected = parseValue("{" + + "\"kind\":\"alias\"," + + "\"creationDate\":1000," + + "\"key\":\"bob-key\"," + + "\"contextKind\":\"user\"," + + "\"previousKey\":\"bob-key-anon\"," + + "\"previousContextKind\":\"anonymousUser\"" + + "}"); + + assertEquals(anonToUserExpected, getSingleOutputEvent(fmt, anonToUser)); + + LDValue anonToAnonExpected = parseValue("{" + + "\"kind\":\"alias\"," + + "\"creationDate\":1000," + + "\"key\":\"bob-key-anon\"," + + "\"contextKind\":\"anonymousUser\"," + + "\"previousKey\":\"jeff-key-anon\"," + + "\"previousContextKind\":\"anonymousUser\"" + + "}"); + + assertEquals(anonToAnonExpected, getSingleOutputEvent(fmt, anonToAnon)); + } + private static class FakeEventClass extends Event { public FakeEventClass(long creationDate, LDUser user) { super(creationDate, user); diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientEventTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientEventTest.java index 14d474958..d91fe8ada 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientEventTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientEventTest.java @@ -526,6 +526,22 @@ public void flushWithEventsDisabledDoesNotCauseError() throws Exception { } } + @Test + public void aliasEventIsCorrectlyGenerated() { + LDUser anonymousUser = new LDUser.Builder("anonymous-key").anonymous(true).build(); + + client.alias(user, anonymousUser); + + assertEquals(1, eventSink.events.size()); + Event e = eventSink.events.get(0); + assertEquals(Event.AliasEvent.class, e.getClass()); + Event.AliasEvent evt = (Event.AliasEvent)e; + assertEquals(user.getKey(), evt.getKey()); + assertEquals("user", evt.getContextKind()); + assertEquals(anonymousUser.getKey(), evt.getPreviousKey()); + assertEquals("anonymousUser", evt.getPreviousContextKind()); + } + private void checkFeatureEvent(Event e, DataModel.FeatureFlag flag, LDValue value, LDValue defaultVal, String prereqOf, EvaluationReason reason) { assertEquals(Event.FeatureRequest.class, e.getClass()); From 89efd6893f82874a6a81cd64fc225d039aa6b49b Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 8 Apr 2021 12:04:45 -0700 Subject: [PATCH 563/673] add validation of javadoc build to CI --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 218275483..64fc00b7f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -144,7 +144,7 @@ jobs: - run: cat gradle.properties.example >>gradle.properties - run: name: checkstyle/javadoc - command: ./gradlew checkstyleMain + command: ./gradlew javadoc checkstyleMain - run: name: build all SDK jars command: ./gradlew publishToMavenLocal -P LD_SKIP_SIGNING=1 From 385e79de3ccf7cc1d18ca8317bc5266f1ffc5bd2 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 8 Apr 2021 12:14:05 -0700 Subject: [PATCH 564/673] update commons-codec to 1.15 (#279) --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 8fa52fc6e..ceef1ec45 100644 --- a/build.gradle +++ b/build.gradle @@ -68,7 +68,7 @@ ext { ext.libraries = [:] ext.versions = [ - "commonsCodec": "1.10", + "commonsCodec": "1.15", "gson": "2.7", "guava": "30.1-jre", "jackson": "2.11.2", From c53e39c9f277b145b73d83f6505243780832670e Mon Sep 17 00:00:00 2001 From: "Robert J. Neal" Date: Thu, 8 Apr 2021 14:33:19 -0700 Subject: [PATCH 565/673] Add support for experiment rollouts --- build.gradle | 2 +- .../launchdarkly/sdk/server/DataModel.java | 38 ++++++++++++++++++- .../launchdarkly/sdk/server/Evaluator.java | 19 ++++++++-- .../sdk/server/EvaluatorBucketing.java | 28 ++++++++++++-- .../sdk/server/DataModelTest.java | 3 +- .../sdk/server/EvaluatorBucketingTest.java | 9 +++-- .../sdk/server/ModelBuilders.java | 3 +- 7 files changed, 87 insertions(+), 15 deletions(-) diff --git a/build.gradle b/build.gradle index 8fa52fc6e..fc7b61ead 100644 --- a/build.gradle +++ b/build.gradle @@ -72,7 +72,7 @@ ext.versions = [ "gson": "2.7", "guava": "30.1-jre", "jackson": "2.11.2", - "launchdarklyJavaSdkCommon": "1.0.0", + "launchdarklyJavaSdkCommon": "1.1.0-alpha-expalloc.1", "okhttp": "4.8.1", // specify this for the SDK build instead of relying on the transitive dependency from okhttp-eventsource "okhttpEventsource": "2.3.1", "slf4j": "1.7.21", diff --git a/src/main/java/com/launchdarkly/sdk/server/DataModel.java b/src/main/java/com/launchdarkly/sdk/server/DataModel.java index 1aab7d6a6..4ff9e2f9d 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DataModel.java +++ b/src/main/java/com/launchdarkly/sdk/server/DataModel.java @@ -344,12 +344,23 @@ void setPreprocessed(EvaluatorPreprocessing.ClauseExtra preprocessed) { static final class Rollout { private List variations; private UserAttribute bucketBy; + private RolloutKind kind; + private Integer seed; Rollout() {} - Rollout(List variations, UserAttribute bucketBy) { + Rollout(List variations, UserAttribute bucketBy, RolloutKind kind) { this.variations = variations; this.bucketBy = bucketBy; + this.kind = kind; + this.seed = null; + } + + Rollout(List variations, UserAttribute bucketBy, RolloutKind kind, Integer seed) { + this.variations = variations; + this.bucketBy = bucketBy; + this.kind = kind; + this.seed = seed; } // Guaranteed non-null @@ -360,6 +371,14 @@ List getVariations() { UserAttribute getBucketBy() { return bucketBy; } + + RolloutKind getKind() { + return this.kind; + } + + Integer getSeed() { + return this.seed; + } } /** @@ -389,12 +408,20 @@ Rollout getRollout() { static final class WeightedVariation { private int variation; private int weight; + private boolean untracked; WeightedVariation() {} WeightedVariation(int variation, int weight) { this.variation = variation; this.weight = weight; + this.untracked = true; + } + + WeightedVariation(int variation, int weight, boolean untracked) { + this.variation = variation; + this.weight = weight; + this.untracked = untracked; } int getVariation() { @@ -404,6 +431,10 @@ int getVariation() { int getWeight() { return weight; } + + boolean isTracked() { + return !untracked; + } } @JsonAdapter(JsonHelpers.PostProcessingDeserializableTypeAdapterFactory.class) @@ -511,4 +542,9 @@ static enum Operator { semVerGreaterThan, segmentMatch } + + static enum RolloutKind { + rollout, + experiment + } } diff --git a/src/main/java/com/launchdarkly/sdk/server/Evaluator.java b/src/main/java/com/launchdarkly/sdk/server/Evaluator.java index e102c70c2..67a764ffd 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Evaluator.java +++ b/src/main/java/com/launchdarkly/sdk/server/Evaluator.java @@ -6,6 +6,7 @@ import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.LDValueType; +import com.launchdarkly.sdk.EvaluationReason.Kind; import com.launchdarkly.sdk.server.interfaces.Event; import org.slf4j.Logger; @@ -221,13 +222,25 @@ private EvalResult getOffValue(DataModel.FeatureFlag flag, EvaluationReason reas } private EvalResult getValueForVariationOrRollout(DataModel.FeatureFlag flag, DataModel.VariationOrRollout vr, LDUser user, EvaluationReason reason) { - Integer index = EvaluatorBucketing.variationIndexForUser(vr, user, flag.getKey(), flag.getSalt()); - if (index == null) { + EvaluatedVariation evaluatedVariation = EvaluatorBucketing.variationIndexForUser(vr, user, flag.getKey(), flag.getSalt()); + if (evaluatedVariation == null || evaluatedVariation.getIndex() == null) { logger.error("Data inconsistency in feature flag \"{}\": variation/rollout object with no variation or rollout", flag.getKey()); return EvalResult.error(EvaluationReason.ErrorKind.MALFORMED_FLAG); } else { - return getVariation(flag, index, reason); + if (evaluatedVariation.isInExperiment()) { + reason = experimentize(reason); + } + return getVariation(flag, evaluatedVariation.getIndex(), reason); + } + } + + private EvaluationReason experimentize(EvaluationReason reason) { + if (reason.getKind() == Kind.FALLTHROUGH) { + return EvaluationReason.fallthrough(true); + } else if (reason.getKind() == Kind.RULE_MATCH) { + return EvaluationReason.ruleMatch(reason.getRuleIndex(), reason.getRuleId(), true); } + return reason; } private boolean ruleMatchesUser(DataModel.FeatureFlag flag, DataModel.Rule rule, LDUser user) { diff --git a/src/main/java/com/launchdarkly/sdk/server/EvaluatorBucketing.java b/src/main/java/com/launchdarkly/sdk/server/EvaluatorBucketing.java index 6f6891dff..c27257926 100644 --- a/src/main/java/com/launchdarkly/sdk/server/EvaluatorBucketing.java +++ b/src/main/java/com/launchdarkly/sdk/server/EvaluatorBucketing.java @@ -3,9 +3,28 @@ import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.server.DataModel.WeightedVariation; import org.apache.commons.codec.digest.DigestUtils; +final class EvaluatedVariation { + private Integer index; + private boolean inExperiment; + + EvaluatedVariation(Integer index, boolean inExperiment) { + this.index = index; + this.inExperiment = inExperiment; + } + + public Integer getIndex() { + return index; + } + + public boolean isInExperiment() { + return inExperiment; + } +} + /** * Encapsulates the logic for percentage rollouts. */ @@ -16,10 +35,10 @@ private EvaluatorBucketing() {} // 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). - static Integer variationIndexForUser(DataModel.VariationOrRollout vr, LDUser user, String key, String salt) { + static EvaluatedVariation variationIndexForUser(DataModel.VariationOrRollout vr, LDUser user, String key, String salt) { Integer variation = vr.getVariation(); if (variation != null) { - return variation; + return new EvaluatedVariation(variation, false); } else { DataModel.Rollout rollout = vr.getRollout(); if (rollout != null && !rollout.getVariations().isEmpty()) { @@ -28,7 +47,7 @@ static Integer variationIndexForUser(DataModel.VariationOrRollout vr, LDUser use for (DataModel.WeightedVariation wv : rollout.getVariations()) { sum += (float) wv.getWeight() / 100000F; if (bucket < sum) { - return wv.getVariation(); + return new EvaluatedVariation(wv.getVariation(), wv.isTracked()); } } // The user's bucket value was greater than or equal to the end of the last bucket. This could happen due @@ -36,7 +55,8 @@ static Integer variationIndexForUser(DataModel.VariationOrRollout vr, LDUser use // 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.getVariations().get(rollout.getVariations().size() - 1).getVariation(); + WeightedVariation lastVariation = rollout.getVariations().get(rollout.getVariations().size() - 1); + return new EvaluatedVariation(lastVariation.getVariation(), lastVariation.isTracked()); } } return null; diff --git a/src/test/java/com/launchdarkly/sdk/server/DataModelTest.java b/src/test/java/com/launchdarkly/sdk/server/DataModelTest.java index 3b3c951e3..489701db2 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataModelTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataModelTest.java @@ -5,6 +5,7 @@ import com.launchdarkly.sdk.server.DataModel.Clause; import com.launchdarkly.sdk.server.DataModel.FeatureFlag; import com.launchdarkly.sdk.server.DataModel.Rollout; +import com.launchdarkly.sdk.server.DataModel.RolloutKind; import com.launchdarkly.sdk.server.DataModel.Rule; import com.launchdarkly.sdk.server.DataModel.Segment; import com.launchdarkly.sdk.server.DataModel.SegmentRule; @@ -84,7 +85,7 @@ public void segmentRuleClausesListCanNeverBeNull() { @Test public void rolloutVariationsListCanNeverBeNull() { - Rollout r = new Rollout(null, null); + Rollout r = new Rollout(null, null, RolloutKind.rollout); assertEquals(ImmutableList.of(), r.getVariations()); } } diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorBucketingTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorBucketingTest.java index 2e245874d..84f1cef06 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EvaluatorBucketingTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorBucketingTest.java @@ -3,6 +3,7 @@ import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.UserAttribute; import com.launchdarkly.sdk.server.DataModel.Rollout; +import com.launchdarkly.sdk.server.DataModel.RolloutKind; import com.launchdarkly.sdk.server.DataModel.VariationOrRollout; import com.launchdarkly.sdk.server.DataModel.WeightedVariation; @@ -36,9 +37,9 @@ public void variationIndexIsReturnedForBucket() { 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 Rollout(variations, null)); + VariationOrRollout vr = new VariationOrRollout(null, new Rollout(variations, null, RolloutKind.rollout)); - Integer resultVariation = EvaluatorBucketing.variationIndexForUser(vr, user, flagKey, salt); + Integer resultVariation = EvaluatorBucketing.variationIndexForUser(vr, user, flagKey, salt).getIndex(); assertEquals(Integer.valueOf(matchedVariation), resultVariation); } @@ -52,9 +53,9 @@ public void lastBucketIsUsedIfBucketValueEqualsTotalWeight() { int bucketValue = (int)(EvaluatorBucketing.bucketUser(user, flagKey, UserAttribute.KEY, salt) * 100000); List variations = Arrays.asList(new WeightedVariation(0, bucketValue)); - VariationOrRollout vr = new VariationOrRollout(null, new Rollout(variations, null)); + VariationOrRollout vr = new VariationOrRollout(null, new Rollout(variations, null, RolloutKind.rollout)); - Integer resultVariation = EvaluatorBucketing.variationIndexForUser(vr, user, flagKey, salt); + Integer resultVariation = EvaluatorBucketing.variationIndexForUser(vr, user, flagKey, salt).getIndex(); assertEquals(Integer.valueOf(0), resultVariation); } diff --git a/src/test/java/com/launchdarkly/sdk/server/ModelBuilders.java b/src/test/java/com/launchdarkly/sdk/server/ModelBuilders.java index ea5af29f5..818d801f1 100644 --- a/src/test/java/com/launchdarkly/sdk/server/ModelBuilders.java +++ b/src/test/java/com/launchdarkly/sdk/server/ModelBuilders.java @@ -6,6 +6,7 @@ import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.UserAttribute; import com.launchdarkly.sdk.server.DataModel.FeatureFlag; +import com.launchdarkly.sdk.server.DataModel.RolloutKind; import com.launchdarkly.sdk.server.DataModel.Segment; import java.util.ArrayList; @@ -76,7 +77,7 @@ public static DataModel.Prerequisite prerequisite(String key, int variation) { } public static DataModel.Rollout emptyRollout() { - return new DataModel.Rollout(ImmutableList.of(), null); + return new DataModel.Rollout(ImmutableList.of(), null, RolloutKind.rollout); } public static SegmentBuilder segmentBuilder(String key) { From 378b7e3f1f1254c160534ff218c3c76ae8156063 Mon Sep 17 00:00:00 2001 From: "Robert J. Neal" Date: Fri, 9 Apr 2021 14:04:15 -0700 Subject: [PATCH 566/673] add tests and use seed for allocating user to partition --- .../launchdarkly/sdk/server/DataModel.java | 4 + .../launchdarkly/sdk/server/Evaluator.java | 2 +- .../sdk/server/EvaluatorBucketing.java | 14 ++- .../sdk/server/EvaluatorBucketingTest.java | 57 ++++++++++-- .../sdk/server/EvaluatorTest.java | 91 +++++++++++++++++++ 5 files changed, 155 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/DataModel.java b/src/main/java/com/launchdarkly/sdk/server/DataModel.java index 4ff9e2f9d..60fa3ef5d 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DataModel.java +++ b/src/main/java/com/launchdarkly/sdk/server/DataModel.java @@ -379,6 +379,10 @@ RolloutKind getKind() { Integer getSeed() { return this.seed; } + + boolean isExperiment() { + return kind == RolloutKind.experiment; + } } /** diff --git a/src/main/java/com/launchdarkly/sdk/server/Evaluator.java b/src/main/java/com/launchdarkly/sdk/server/Evaluator.java index 67a764ffd..0c2b3ad1b 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Evaluator.java +++ b/src/main/java/com/launchdarkly/sdk/server/Evaluator.java @@ -357,7 +357,7 @@ private boolean segmentRuleMatchesUser(DataModel.SegmentRule segmentRule, LDUser } // All of the clauses are met. See if the user buckets in - double bucket = EvaluatorBucketing.bucketUser(user, segmentKey, segmentRule.getBucketBy(), salt); + double bucket = EvaluatorBucketing.bucketUser(null, user, segmentKey, segmentRule.getBucketBy(), salt); double weight = (double)segmentRule.getWeight() / 100000.0; return bucket < weight; } diff --git a/src/main/java/com/launchdarkly/sdk/server/EvaluatorBucketing.java b/src/main/java/com/launchdarkly/sdk/server/EvaluatorBucketing.java index c27257926..f85143a9d 100644 --- a/src/main/java/com/launchdarkly/sdk/server/EvaluatorBucketing.java +++ b/src/main/java/com/launchdarkly/sdk/server/EvaluatorBucketing.java @@ -42,12 +42,12 @@ static EvaluatedVariation variationIndexForUser(DataModel.VariationOrRollout vr, } else { DataModel.Rollout rollout = vr.getRollout(); if (rollout != null && !rollout.getVariations().isEmpty()) { - float bucket = bucketUser(user, key, rollout.getBucketBy(), salt); + float bucket = bucketUser(rollout.getSeed(), user, key, rollout.getBucketBy(), salt); float sum = 0F; for (DataModel.WeightedVariation wv : rollout.getVariations()) { sum += (float) wv.getWeight() / 100000F; if (bucket < sum) { - return new EvaluatedVariation(wv.getVariation(), wv.isTracked()); + return new EvaluatedVariation(wv.getVariation(), vr.getRollout().isExperiment() && wv.isTracked()); } } // The user's bucket value was greater than or equal to the end of the last bucket. This could happen due @@ -62,14 +62,20 @@ static EvaluatedVariation variationIndexForUser(DataModel.VariationOrRollout vr, return null; } - static float bucketUser(LDUser user, String key, UserAttribute attr, String salt) { + static float bucketUser(Integer seed, LDUser user, String key, UserAttribute attr, String salt) { LDValue userValue = user.getAttribute(attr == null ? UserAttribute.KEY : attr); String idHash = getBucketableStringValue(userValue); if (idHash != null) { + String prefix; + if (seed != null) { + prefix = seed.toString(); + } else { + prefix = key + "." + salt; + } if (user.getSecondary() != null) { idHash = idHash + "." + user.getSecondary(); } - String hash = DigestUtils.sha1Hex(key + "." + salt + "." + idHash).substring(0, 15); + String hash = DigestUtils.sha1Hex(prefix + "." + idHash).substring(0, 15); long longVal = Long.parseLong(hash, 16); return (float) longVal / LONG_SCALE; } diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorBucketingTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorBucketingTest.java index 84f1cef06..afae9b2d4 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EvaluatorBucketingTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorBucketingTest.java @@ -20,6 +20,8 @@ @SuppressWarnings("javadoc") public class EvaluatorBucketingTest { + private Integer noSeed = null; + @Test public void variationIndexIsReturnedForBucket() { LDUser user = new LDUser.Builder("userkey").build(); @@ -28,7 +30,7 @@ public void variationIndexIsReturnedForBucket() { // 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)(EvaluatorBucketing.bucketUser(user, flagKey, UserAttribute.KEY, salt) * 100000); + int bucketValue = (int)(EvaluatorBucketing.bucketUser(noSeed, user, flagKey, UserAttribute.KEY, salt) * 100000); assertThat(bucketValue, greaterThanOrEqualTo(1)); assertThat(bucketValue, Matchers.lessThan(100000)); @@ -43,6 +45,45 @@ public void variationIndexIsReturnedForBucket() { assertEquals(Integer.valueOf(matchedVariation), resultVariation); } + @Test + public void usingSeedIsDifferentThanSalt() { + LDUser user = new LDUser.Builder("userkey").build(); + String flagKey = "flagkey"; + String salt = "salt"; + Integer seed = 123; + + float bucketValue1 = EvaluatorBucketing.bucketUser(noSeed, user, flagKey, UserAttribute.KEY, salt); + float bucketValue2 = EvaluatorBucketing.bucketUser(seed, user, flagKey, UserAttribute.KEY, salt); + assert(bucketValue1 != bucketValue2); + } + + @Test + public void differentSeedsProduceDifferentAssignment() { + LDUser user = new LDUser.Builder("userkey").build(); + String flagKey = "flagkey"; + String salt = "salt"; + Integer seed1 = 123; + Integer seed2 = 456; + + float bucketValue1 = EvaluatorBucketing.bucketUser(seed1, user, flagKey, UserAttribute.KEY, salt); + float bucketValue2 = EvaluatorBucketing.bucketUser(seed2, user, flagKey, UserAttribute.KEY, salt); + assert(bucketValue1 != bucketValue2); + } + + @Test + public void flagKeyAndSaltDoNotMatterWhenSeedIsUsed() { + LDUser user = new LDUser.Builder("userkey").build(); + String flagKey1 = "flagkey"; + String flagKey2 = "flagkey2"; + String salt1 = "salt"; + String salt2 = "salt2"; + Integer seed = 123; + + float bucketValue1 = EvaluatorBucketing.bucketUser(seed, user, flagKey1, UserAttribute.KEY, salt1); + float bucketValue2 = EvaluatorBucketing.bucketUser(seed, user, flagKey2, UserAttribute.KEY, salt2); + assert(bucketValue1 == bucketValue2); + } + @Test public void lastBucketIsUsedIfBucketValueEqualsTotalWeight() { LDUser user = new LDUser.Builder("userkey").build(); @@ -50,7 +91,7 @@ public void lastBucketIsUsedIfBucketValueEqualsTotalWeight() { String salt = "salt"; // We'll construct a list of variations that stops right at the target bucket value - int bucketValue = (int)(EvaluatorBucketing.bucketUser(user, flagKey, UserAttribute.KEY, salt) * 100000); + int bucketValue = (int)(EvaluatorBucketing.bucketUser(noSeed, user, flagKey, UserAttribute.KEY, salt) * 100000); List variations = Arrays.asList(new WeightedVariation(0, bucketValue)); VariationOrRollout vr = new VariationOrRollout(null, new Rollout(variations, null, RolloutKind.rollout)); @@ -65,8 +106,8 @@ public void canBucketByIntAttributeSameAsString() { .custom("stringattr", "33333") .custom("intattr", 33333) .build(); - float resultForString = EvaluatorBucketing.bucketUser(user, "key", UserAttribute.forName("stringattr"), "salt"); - float resultForInt = EvaluatorBucketing.bucketUser(user, "key", UserAttribute.forName("intattr"), "salt"); + float resultForString = EvaluatorBucketing.bucketUser(noSeed, user, "key", UserAttribute.forName("stringattr"), "salt"); + float resultForInt = EvaluatorBucketing.bucketUser(noSeed, user, "key", UserAttribute.forName("intattr"), "salt"); assertEquals(resultForString, resultForInt, Float.MIN_VALUE); } @@ -75,7 +116,7 @@ public void cannotBucketByFloatAttribute() { LDUser user = new LDUser.Builder("key") .custom("floatattr", 33.5f) .build(); - float result = EvaluatorBucketing.bucketUser(user, "key", UserAttribute.forName("floatattr"), "salt"); + float result = EvaluatorBucketing.bucketUser(noSeed, user, "key", UserAttribute.forName("floatattr"), "salt"); assertEquals(0f, result, Float.MIN_VALUE); } @@ -84,7 +125,7 @@ public void cannotBucketByBooleanAttribute() { LDUser user = new LDUser.Builder("key") .custom("boolattr", true) .build(); - float result = EvaluatorBucketing.bucketUser(user, "key", UserAttribute.forName("boolattr"), "salt"); + float result = EvaluatorBucketing.bucketUser(noSeed, user, "key", UserAttribute.forName("boolattr"), "salt"); assertEquals(0f, result, Float.MIN_VALUE); } @@ -92,8 +133,8 @@ public void cannotBucketByBooleanAttribute() { public void userSecondaryKeyAffectsBucketValue() { LDUser user1 = new LDUser.Builder("key").build(); LDUser user2 = new LDUser.Builder("key").secondary("other").build(); - float result1 = EvaluatorBucketing.bucketUser(user1, "flagkey", UserAttribute.KEY, "salt"); - float result2 = EvaluatorBucketing.bucketUser(user2, "flagkey", UserAttribute.KEY, "salt"); + float result1 = EvaluatorBucketing.bucketUser(noSeed, user1, "flagkey", UserAttribute.KEY, "salt"); + float result2 = EvaluatorBucketing.bucketUser(noSeed, user2, "flagkey", UserAttribute.KEY, "salt"); assertNotEquals(result1, result2); } } diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java index 49a112a0d..64bf653fd 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java @@ -6,9 +6,15 @@ import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.server.DataModel.Rollout; +import com.launchdarkly.sdk.server.DataModel.RolloutKind; +import com.launchdarkly.sdk.server.DataModel.VariationOrRollout; +import com.launchdarkly.sdk.server.DataModel.WeightedVariation; import com.launchdarkly.sdk.server.ModelBuilders.FlagBuilder; import com.launchdarkly.sdk.server.interfaces.Event; +import java.util.ArrayList; +import java.util.List; import org.junit.Test; import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; @@ -62,6 +68,17 @@ private static FlagBuilder buildRedGreenFlag(String flagKey) { .variations(RED_GREEN_VARIATIONS) .version(versionFromKey(flagKey)); } + + private static Rollout buildRollout(boolean isExperient, boolean trackedVariations) { + List variations = new ArrayList(); + variations.add(new WeightedVariation(1, 50000, trackedVariations)); + variations.add(new WeightedVariation(2, 50000, trackedVariations)); + UserAttribute bucketBy = UserAttribute.KEY; + RolloutKind kind = isExperient ? RolloutKind.experiment : RolloutKind.rollout; + Integer seed = 123; + Rollout rollout = new Rollout(variations, bucketBy, kind, seed); + return rollout; + } private static int versionFromKey(String flagKey) { return Math.abs(flagKey.hashCode()); @@ -132,6 +149,80 @@ public void flagReturnsErrorIfFlagIsOffAndOffVariationIsNegative() throws Except assertThat(result.getPrerequisiteEvents(), emptyIterable()); } + @Test + public void flagReturnsFallthroughAndInExperimentWhenInExperimentVariation() throws Exception { + Rollout rollout = buildRollout(true, false); + VariationOrRollout vr = new VariationOrRollout(null, rollout); + + DataModel.FeatureFlag f = buildThreeWayFlag("feature") + .on(true) + .fallthrough(vr) + .build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + + assert(result.getReason().isInExperiment()); + } + + @Test + public void flagReturnsFallthroughAndNotInExperimentWhenNotInExperimentVariation() throws Exception { + Rollout rollout = buildRollout(true, true); + VariationOrRollout vr = new VariationOrRollout(null, rollout); + + DataModel.FeatureFlag f = buildThreeWayFlag("feature") + .on(true) + .fallthrough(vr) + .build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + + assert(!result.getReason().isInExperiment()); + } + + @Test + public void flagReturnsFallthroughAndNotInExperimentWhenInExperimentVariationButNonExperimentRollout() throws Exception { + Rollout rollout = buildRollout(false, false); + VariationOrRollout vr = new VariationOrRollout(null, rollout); + + DataModel.FeatureFlag f = buildThreeWayFlag("feature") + .on(true) + .fallthrough(vr) + .build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + + assert(!result.getReason().isInExperiment()); + } + + @Test + public void flagReturnsRuleMatchAndInExperimentWhenInExperimentVariation() throws Exception { + Rollout rollout = buildRollout(true, false); + + DataModel.Clause clause = clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of(BASE_USER.getKey())); + DataModel.Rule rule = ruleBuilder().id("ruleid0").clauses(clause).rollout(rollout).build(); + + DataModel.FeatureFlag f = buildThreeWayFlag("feature") + .on(true) + .rules(rule) + .build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + + assert(result.getReason().isInExperiment()); + } + + @Test + public void flagReturnsRuleMatchAndNotInExperimentWhenNotInExperimentVariation() throws Exception { + Rollout rollout = buildRollout(true, true); + + DataModel.Clause clause = clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of("userkey")); + DataModel.Rule rule = ruleBuilder().id("ruleid0").clauses(clause).rollout(rollout).build(); + + DataModel.FeatureFlag f = buildThreeWayFlag("feature") + .on(true) + .rules(rule) + .build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + + assert(!result.getReason().isInExperiment()); + } + @Test public void flagReturnsFallthroughIfFlagIsOnAndThereAreNoRules() throws Exception { DataModel.FeatureFlag f = buildThreeWayFlag("feature") From c84651c4205f343f1b8a29e566b830c2af6571c9 Mon Sep 17 00:00:00 2001 From: "Robert J. Neal" Date: Fri, 9 Apr 2021 16:20:55 -0700 Subject: [PATCH 567/673] test serialization and add check for isExperiment --- .../com/launchdarkly/sdk/server/EvaluatorBucketing.java | 2 +- .../launchdarkly/sdk/server/DataModelSerializationTest.java | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/EvaluatorBucketing.java b/src/main/java/com/launchdarkly/sdk/server/EvaluatorBucketing.java index f85143a9d..82841553a 100644 --- a/src/main/java/com/launchdarkly/sdk/server/EvaluatorBucketing.java +++ b/src/main/java/com/launchdarkly/sdk/server/EvaluatorBucketing.java @@ -56,7 +56,7 @@ static EvaluatedVariation variationIndexForUser(DataModel.VariationOrRollout vr, // 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. WeightedVariation lastVariation = rollout.getVariations().get(rollout.getVariations().size() - 1); - return new EvaluatedVariation(lastVariation.getVariation(), lastVariation.isTracked()); + return new EvaluatedVariation(lastVariation.getVariation(), vr.getRollout().isExperiment() && lastVariation.isTracked()); } } return null; diff --git a/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java b/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java index ca1dfff0d..c94c7596c 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java @@ -7,6 +7,7 @@ import com.launchdarkly.sdk.server.DataModel.Clause; import com.launchdarkly.sdk.server.DataModel.FeatureFlag; import com.launchdarkly.sdk.server.DataModel.Operator; +import com.launchdarkly.sdk.server.DataModel.RolloutKind; import com.launchdarkly.sdk.server.DataModel.Rule; import com.launchdarkly.sdk.server.DataModel.Segment; import com.launchdarkly.sdk.server.DataModel.SegmentRule; @@ -147,6 +148,8 @@ private LDValue flagWithAllPropertiesJson() { .build()) .build()) .put("bucketBy", "email") + .put("kind", "experiment") + .put("seed", 123) .build()) .build()) .build()) @@ -204,6 +207,9 @@ private void assertFlagHasAllProperties(FeatureFlag flag) { assertEquals(2, r1.getRollout().getVariations().get(0).getVariation()); assertEquals(100000, r1.getRollout().getVariations().get(0).getWeight()); assertEquals(UserAttribute.EMAIL, r1.getRollout().getBucketBy()); + assertEquals(RolloutKind.experiment, r1.getRollout().getKind()); + assert(r1.getRollout().isExperiment()); + assertEquals(new Integer(123), r1.getRollout().getSeed()); assertNotNull(flag.getFallthrough()); assertEquals(new Integer(1), flag.getFallthrough().getVariation()); From b4190ad0caeabcc87949dc7ab3102612d4f074bd Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 14 Apr 2021 14:37:04 -0700 Subject: [PATCH 568/673] fix PollingProcessorTest test race condition + other test issues (#282) --- .../PersistentDataStoreStatusManager.java | 12 +++++++++++- .../server/PersistentDataStoreWrapper.java | 1 + .../sdk/server/PollingProcessorTest.java | 19 +++++++++++++------ .../sdk/server/TestComponents.java | 7 +++++-- 4 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreStatusManager.java b/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreStatusManager.java index 971089fe1..211f57468 100644 --- a/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreStatusManager.java +++ b/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreStatusManager.java @@ -5,6 +5,7 @@ import org.slf4j.Logger; +import java.io.Closeable; import java.util.concurrent.Callable; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; @@ -17,7 +18,7 @@ * This is currently only used by PersistentDataStoreWrapper, but encapsulating it in its own class helps with * clarity and also lets us reuse this logic in tests. */ -final class PersistentDataStoreStatusManager { +final class PersistentDataStoreStatusManager implements Closeable { private static final Logger logger = Loggers.DATA_STORE; static final int POLL_INTERVAL_MS = 500; // visible for testing @@ -42,6 +43,15 @@ final class PersistentDataStoreStatusManager { this.scheduler = sharedExecutor; } + public void close() { + synchronized (this) { + if (pollerFuture != null) { + pollerFuture.cancel(true); + pollerFuture = null; + } + } + } + void updateAvailability(boolean available) { synchronized (this) { if (lastAvailable == available) { diff --git a/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapper.java b/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapper.java index 0da3495a6..ab2f27d8a 100644 --- a/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapper.java +++ b/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapper.java @@ -142,6 +142,7 @@ private static CacheBuilder newCacheBuilder( @Override public void close() throws IOException { + statusManager.close(); core.close(); } diff --git a/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java index 1dd75b174..584f6bef4 100644 --- a/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java @@ -284,8 +284,9 @@ private void testUnrecoverableHttpError(int statusCode) throws Exception { }); // Now test a scenario where we have a successful startup, but the next poll gets the error + dataSourceUpdates = TestComponents.dataSourceUpdates(new InMemoryDataStore(), new MockDataStoreStatusProvider()); withStatusQueue(statuses -> { - requestor.httpException = null; + requestor = new MockFeatureRequestor(); requestor.allData = new FeatureRequestor.AllData(new HashMap<>(), new HashMap<>()); try (PollingProcessor pollingProcessor = makeProcessor(BRIEF_INTERVAL)) { @@ -331,7 +332,7 @@ private void testRecoverableHttpError(int statusCode) throws Exception { assertEquals(ErrorKind.ERROR_RESPONSE, status0.getLastError().getKind()); assertEquals(statusCode, status0.getLastError().getStatusCode()); - verifyHttpErrorWasRecoverable(statuses, statusCode); + verifyHttpErrorWasRecoverable(statuses, statusCode, false); shouldNotTimeOut(initFuture, Duration.ofSeconds(2)); assertTrue(initFuture.isDone()); @@ -340,8 +341,9 @@ private void testRecoverableHttpError(int statusCode) throws Exception { }); // Now test a scenario where we have a successful startup, but the next poll gets the error + dataSourceUpdates = TestComponents.dataSourceUpdates(new InMemoryDataStore(), new MockDataStoreStatusProvider()); withStatusQueue(statuses -> { - requestor.httpException = null; + requestor = new MockFeatureRequestor(); requestor.allData = new FeatureRequestor.AllData(new HashMap<>(), new HashMap<>()); try (PollingProcessor pollingProcessor = makeProcessor(BRIEF_INTERVAL)) { @@ -359,12 +361,16 @@ private void testRecoverableHttpError(int statusCode) throws Exception { assertEquals(ErrorKind.ERROR_RESPONSE, status0.getLastError().getKind()); assertEquals(statusCode, status0.getLastError().getStatusCode()); - verifyHttpErrorWasRecoverable(statuses, statusCode); + verifyHttpErrorWasRecoverable(statuses, statusCode, true); } }); } - private void verifyHttpErrorWasRecoverable(BlockingQueue statuses, int statusCode) throws Exception { + private void verifyHttpErrorWasRecoverable( + BlockingQueue statuses, + int statusCode, + boolean didAlreadyConnect + ) throws Exception { long startTime = System.currentTimeMillis(); // first make it so the requestor will succeed after the previous error @@ -372,7 +378,8 @@ private void verifyHttpErrorWasRecoverable(BlockingQueue Date: Wed, 14 Apr 2021 16:30:06 -0700 Subject: [PATCH 569/673] use launchdarkly-java-sdk-common 1.1.0-alpha-expalloc.2 --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 0c05f272a..66502b5e3 100644 --- a/build.gradle +++ b/build.gradle @@ -72,7 +72,7 @@ ext.versions = [ "gson": "2.7", "guava": "30.1-jre", "jackson": "2.11.2", - "launchdarklyJavaSdkCommon": "1.1.0-alpha-expalloc.1", + "launchdarklyJavaSdkCommon": "1.1.0-alpha-expalloc.2", "okhttp": "4.8.1", // specify this for the SDK build instead of relying on the transitive dependency from okhttp-eventsource "okhttpEventsource": "2.3.1", "slf4j": "1.7.21", From 777b3f30a8e36720835c057478c79027ae419289 Mon Sep 17 00:00:00 2001 From: "Robert J. Neal" Date: Wed, 14 Apr 2021 16:44:02 -0700 Subject: [PATCH 570/673] Update src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java Co-authored-by: Sam Stokes --- src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java index 64bf653fd..69d9fc8ff 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java @@ -70,7 +70,7 @@ private static FlagBuilder buildRedGreenFlag(String flagKey) { } private static Rollout buildRollout(boolean isExperient, boolean trackedVariations) { - List variations = new ArrayList(); + List variations = new ArrayList<>(); variations.add(new WeightedVariation(1, 50000, trackedVariations)); variations.add(new WeightedVariation(2, 50000, trackedVariations)); UserAttribute bucketBy = UserAttribute.KEY; From 99fba91d820fa1bd5f946f6bb8455626e8340c5a Mon Sep 17 00:00:00 2001 From: "Robert J. Neal" Date: Wed, 14 Apr 2021 16:44:14 -0700 Subject: [PATCH 571/673] Update src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java Co-authored-by: Sam Stokes --- src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java index 69d9fc8ff..36366a821 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java @@ -69,7 +69,7 @@ private static FlagBuilder buildRedGreenFlag(String flagKey) { .version(versionFromKey(flagKey)); } - private static Rollout buildRollout(boolean isExperient, boolean trackedVariations) { + private static Rollout buildRollout(boolean isExperiment, boolean trackedVariations) { List variations = new ArrayList<>(); variations.add(new WeightedVariation(1, 50000, trackedVariations)); variations.add(new WeightedVariation(2, 50000, trackedVariations)); From 2809609b8f37c394732d675690e8451b633ab2fd Mon Sep 17 00:00:00 2001 From: "Robert J. Neal" Date: Wed, 14 Apr 2021 16:47:49 -0700 Subject: [PATCH 572/673] Update src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java Co-authored-by: Sam Stokes --- src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java index 36366a821..304b0742a 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java @@ -150,7 +150,7 @@ public void flagReturnsErrorIfFlagIsOffAndOffVariationIsNegative() throws Except } @Test - public void flagReturnsFallthroughAndInExperimentWhenInExperimentVariation() throws Exception { + public void flagReturnsInExperimentForFallthroughWhenInExperimentVariation() throws Exception { Rollout rollout = buildRollout(true, false); VariationOrRollout vr = new VariationOrRollout(null, rollout); From 61ab96d13b2e3325acdc556e49688a89b1ec59ad Mon Sep 17 00:00:00 2001 From: "Robert J. Neal" Date: Wed, 14 Apr 2021 16:48:42 -0700 Subject: [PATCH 573/673] Update src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java Co-authored-by: Sam Stokes --- src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java index 304b0742a..7f8c25917 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java @@ -192,7 +192,7 @@ public void flagReturnsFallthroughAndNotInExperimentWhenInExperimentVariationBut } @Test - public void flagReturnsRuleMatchAndInExperimentWhenInExperimentVariation() throws Exception { + public void flagReturnsInExperimentForRuleMatchWhenInExperimentVariation() throws Exception { Rollout rollout = buildRollout(true, false); DataModel.Clause clause = clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of(BASE_USER.getKey())); From d2b53a30305d525dd7a6af94277e3dcfb7d1ffcf Mon Sep 17 00:00:00 2001 From: "Robert J. Neal" Date: Wed, 14 Apr 2021 17:21:41 -0700 Subject: [PATCH 574/673] changes per code review comments --- .../launchdarkly/sdk/server/DataModel.java | 2 +- .../launchdarkly/sdk/server/EventFactory.java | 19 +++++++++++++++++-- .../sdk/server/EvaluatorTest.java | 6 +++--- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/DataModel.java b/src/main/java/com/launchdarkly/sdk/server/DataModel.java index 60fa3ef5d..5ca03d487 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DataModel.java +++ b/src/main/java/com/launchdarkly/sdk/server/DataModel.java @@ -419,7 +419,7 @@ static final class WeightedVariation { WeightedVariation(int variation, int weight) { this.variation = variation; this.weight = weight; - this.untracked = true; + this.untracked = false; } WeightedVariation(int variation, int weight, boolean untracked) { diff --git a/src/main/java/com/launchdarkly/sdk/server/EventFactory.java b/src/main/java/com/launchdarkly/sdk/server/EventFactory.java index d7daf9cea..df6387e6b 100644 --- a/src/main/java/com/launchdarkly/sdk/server/EventFactory.java +++ b/src/main/java/com/launchdarkly/sdk/server/EventFactory.java @@ -5,6 +5,7 @@ import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.server.DataModel.FeatureFlag; +import com.launchdarkly.sdk.server.DataModel.VariationOrRollout; import com.launchdarkly.sdk.server.interfaces.Event; import com.launchdarkly.sdk.server.interfaces.Event.Custom; import com.launchdarkly.sdk.server.interfaces.Event.FeatureRequest; @@ -212,7 +213,7 @@ private static boolean isExperiment(DataModel.FeatureFlag flag, EvaluationReason } switch (reason.getKind()) { case FALLTHROUGH: - return flag.isTrackEventsFallthrough(); + return shouldEmitFullEvent(flag.getFallthrough(), reason, flag.isTrackEventsFallthrough()); case RULE_MATCH: int ruleIndex = reason.getRuleIndex(); // Note, it is OK to rely on the rule index rather than the unique ID in this context, because the @@ -220,11 +221,25 @@ private static boolean isExperiment(DataModel.FeatureFlag flag, EvaluationReason // evaluated, so we cannot be out of sync with its rule list. if (ruleIndex >= 0 && ruleIndex < flag.getRules().size()) { DataModel.Rule rule = flag.getRules().get(ruleIndex); - return rule.isTrackEvents(); + return shouldEmitFullEvent(rule, reason, rule.isTrackEvents()); } return false; default: return false; } } + + private static boolean shouldEmitFullEvent(VariationOrRollout vr, EvaluationReason reason, boolean trackEventsOverride) { + // This should return true if a full feature event should be emitted for this evaluation regardless of the value of + // f.TrackEvents; a true value also causes the evaluation reason to be included in the event regardless of whether it + // otherwise would have been. + // + // For new-style experiments, as identified by the rollout kind, this is determined from the evaluation reason. Legacy + // experiments instead use TrackEventsFallthrough or rule.TrackEvents for this purpose. + + if (!vr.getRollout().isExperiment()) { + return trackEventsOverride; + } + return reason.isInExperiment(); + } } diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java index 64bf653fd..4dfb51483 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java @@ -164,7 +164,7 @@ public void flagReturnsFallthroughAndInExperimentWhenInExperimentVariation() thr } @Test - public void flagReturnsFallthroughAndNotInExperimentWhenNotInExperimentVariation() throws Exception { + public void flagReturnsNotInExperimentForFallthroughWhenNotInExperimentVariation() throws Exception { Rollout rollout = buildRollout(true, true); VariationOrRollout vr = new VariationOrRollout(null, rollout); @@ -178,7 +178,7 @@ public void flagReturnsFallthroughAndNotInExperimentWhenNotInExperimentVariation } @Test - public void flagReturnsFallthroughAndNotInExperimentWhenInExperimentVariationButNonExperimentRollout() throws Exception { + public void flagReturnsNotInExperimentForFallthrougWhenInExperimentVariationButNonExperimentRollout() throws Exception { Rollout rollout = buildRollout(false, false); VariationOrRollout vr = new VariationOrRollout(null, rollout); @@ -208,7 +208,7 @@ public void flagReturnsRuleMatchAndInExperimentWhenInExperimentVariation() throw } @Test - public void flagReturnsRuleMatchAndNotInExperimentWhenNotInExperimentVariation() throws Exception { + public void flagReturnsNotInExperimentForRuleMatchWhenNotInExperimentVariation() throws Exception { Rollout rollout = buildRollout(true, true); DataModel.Clause clause = clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of("userkey")); From 47b9ebb4023f03e7a9d840264cba879583d7140c Mon Sep 17 00:00:00 2001 From: "Robert J. Neal" Date: Thu, 15 Apr 2021 10:14:20 -0700 Subject: [PATCH 575/673] Please enter the commit message for your changes. Lines starting --- .../server/DataModelSerializationTest.java | 40 ++++++++ .../sdk/server/EvaluatorTest.java | 8 +- .../sdk/server/EventFactoryTest.java | 82 ++++++++++++++++ .../RolloutRandomizationConsistencyTest.java | 96 +++++++++++++++++++ 4 files changed, 222 insertions(+), 4 deletions(-) create mode 100644 src/test/java/com/launchdarkly/sdk/server/EventFactoryTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/RolloutRandomizationConsistencyTest.java diff --git a/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java b/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java index c94c7596c..d277c0cef 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java @@ -40,6 +40,7 @@ public void flagIsDeserializedWithAllProperties() { @Test public void flagIsDeserializedWithMinimalProperties() { String json = LDValue.buildObject().put("key", "flag-key").put("version", 99).build().toJsonString(); + FeatureFlag flag = (FeatureFlag)FEATURES.deserialize(json).getItem(); assertEquals("flag-key", flag.getKey()); assertEquals(99, flag.getVersion()); @@ -59,6 +60,45 @@ public void flagIsDeserializedWithMinimalProperties() { assertNull(flag.getDebugEventsUntilDate()); } + @Test + public void flagIsDeserializedWithOptionalExperimentProperties() { + String json = LDValue.buildObject().put("key", "flag-key").put("version", 99) + .put("rules", LDValue.buildArray() + .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()) + .build().toJsonString(); + FeatureFlag flag = (FeatureFlag)FEATURES.deserialize(json).getItem(); + assertEquals("flag-key", flag.getKey()); + assertEquals(99, flag.getVersion()); + assertFalse(flag.isOn()); + assertNull(flag.getSalt()); + assertNotNull(flag.getTargets()); + assertEquals(0, flag.getTargets().size()); + assertNotNull(flag.getRules()); + assertEquals(1, flag.getRules().size()); + assert(!flag.getRules().get(0).getRollout().isExperiment()); + assertNull(flag.getRules().get(0).getRollout().getSeed()); + assertNull(flag.getFallthrough()); + assertNull(flag.getOffVariation()); + assertNotNull(flag.getVariations()); + assertEquals(0, flag.getVariations().size()); + assertFalse(flag.isClientSide()); + assertFalse(flag.isTrackEvents()); + assertFalse(flag.isTrackEventsFallthrough()); + assertNull(flag.getDebugEventsUntilDate()); + } + @Test public void deletedFlagIsConvertedToAndFromJsonPlaceholder() { String json0 = LDValue.buildObject().put("version", 99) diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java index ec30d4009..6864c9bf3 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java @@ -69,12 +69,12 @@ private static FlagBuilder buildRedGreenFlag(String flagKey) { .version(versionFromKey(flagKey)); } - private static Rollout buildRollout(boolean isExperiment, boolean trackedVariations) { + private static Rollout buildRollout(boolean isExperiment, boolean untrackedVariations) { List variations = new ArrayList<>(); - variations.add(new WeightedVariation(1, 50000, trackedVariations)); - variations.add(new WeightedVariation(2, 50000, trackedVariations)); + variations.add(new WeightedVariation(1, 50000, untrackedVariations)); + variations.add(new WeightedVariation(2, 50000, untrackedVariations)); UserAttribute bucketBy = UserAttribute.KEY; - RolloutKind kind = isExperient ? RolloutKind.experiment : RolloutKind.rollout; + RolloutKind kind = isExperiment ? RolloutKind.experiment : RolloutKind.rollout; Integer seed = 123; Rollout rollout = new Rollout(variations, bucketBy, kind, seed); return rollout; diff --git a/src/test/java/com/launchdarkly/sdk/server/EventFactoryTest.java b/src/test/java/com/launchdarkly/sdk/server/EventFactoryTest.java new file mode 100644 index 000000000..ca50c76ca --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/EventFactoryTest.java @@ -0,0 +1,82 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.DataModel.Rollout; +import com.launchdarkly.sdk.server.DataModel.RolloutKind; +import com.launchdarkly.sdk.server.DataModel.VariationOrRollout; +import com.launchdarkly.sdk.server.DataModel.WeightedVariation; +import com.launchdarkly.sdk.server.interfaces.Event.FeatureRequest; + +import org.junit.Test; + +import static com.launchdarkly.sdk.server.ModelBuilders.*; + +import java.util.ArrayList; +import java.util.List; + +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.UserAttribute; + +public class EventFactoryTest { + private static final LDUser BASE_USER = new LDUser.Builder("x").build(); + private static Rollout buildRollout(boolean isExperiment, boolean untrackedVariations) { + List variations = new ArrayList<>(); + variations.add(new WeightedVariation(1, 50000, untrackedVariations)); + variations.add(new WeightedVariation(2, 50000, untrackedVariations)); + UserAttribute bucketBy = UserAttribute.KEY; + RolloutKind kind = isExperiment ? RolloutKind.experiment : RolloutKind.rollout; + Integer seed = 123; + Rollout rollout = new Rollout(variations, bucketBy, kind, seed); + return rollout; + } + + @Test + public void trackEventFalseTest() { + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(false).build(); + LDUser user = new LDUser("moniker"); + FeatureRequest fr = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, null, null); + + assert(!fr.isTrackEvents()); + } + + @Test + public void trackEventTrueTest() { + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); + LDUser user = new LDUser("moniker"); + FeatureRequest fr = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, null, null); + + assert(fr.isTrackEvents()); + } + + @Test + public void trackEventTrueWhenTrackEventsFalseButExperimentFallthroughReasonTest() { + Rollout rollout = buildRollout(true, false); + VariationOrRollout vr = new VariationOrRollout(null, rollout); + + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(false) + .fallthrough(vr).build(); + LDUser user = new LDUser("moniker"); + FeatureRequest fr = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, null, 0, + EvaluationReason.fallthrough(true), null, null); + + assert(fr.isTrackEvents()); + } + + @Test + public void trackEventTrueWhenTrackEventsFalseButExperimentRuleMatchReasonTest() { + Rollout rollout = buildRollout(true, false); + + DataModel.Clause clause = clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of(BASE_USER.getKey())); + DataModel.Rule rule = ruleBuilder().id("ruleid0").clauses(clause).rollout(rollout).build(); + + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(false) + .rules(rule).build(); + LDUser user = new LDUser("moniker"); + FeatureRequest fr = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, null, 0, + EvaluationReason.ruleMatch(0, "something", true), null, null); + + assert(fr.isTrackEvents()); + } + +} diff --git a/src/test/java/com/launchdarkly/sdk/server/RolloutRandomizationConsistencyTest.java b/src/test/java/com/launchdarkly/sdk/server/RolloutRandomizationConsistencyTest.java new file mode 100644 index 000000000..31d6efb9c --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/RolloutRandomizationConsistencyTest.java @@ -0,0 +1,96 @@ +package com.launchdarkly.sdk.server; + +import static org.junit.Assert.assertEquals; + +import java.util.ArrayList; +import java.util.List; + +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.server.DataModel.Rollout; +import com.launchdarkly.sdk.server.DataModel.RolloutKind; +import com.launchdarkly.sdk.server.DataModel.VariationOrRollout; +import com.launchdarkly.sdk.server.DataModel.WeightedVariation; + +import org.junit.Test; + +/* + * Note: These tests are meant to be exact duplicates of tests + * in other SDKs. Do not change any of the values unless they + * are also changed in other SDKs. These are not traditional behavioral + * tests so much as consistency tests to guarantee that the implementation + * is identical across SDKs. + */ +public class RolloutRandomizationConsistencyTest { + private Integer noSeed = null; + + private static Rollout buildRollout(boolean isExperiment, boolean untrackedVariations) { + List variations = new ArrayList<>(); + variations.add(new WeightedVariation(0, 10000, untrackedVariations)); + variations.add(new WeightedVariation(1, 20000, untrackedVariations)); + variations.add(new WeightedVariation(0, 70000, true)); + UserAttribute bucketBy = UserAttribute.KEY; + RolloutKind kind = isExperiment ? RolloutKind.experiment : RolloutKind.rollout; + Integer seed = 61; + Rollout rollout = new Rollout(variations, bucketBy, kind, seed); + return rollout; + } + + @Test + public void variationIndexForUserInExperimentTest() { + // seed here carefully chosen so users fall into different buckets + Rollout rollout = buildRollout(true, false); + VariationOrRollout vr = new VariationOrRollout(null, rollout); + + LDUser user1 = new LDUser("userKeyA"); + EvaluatedVariation ev1 = EvaluatorBucketing.variationIndexForUser(vr, user1, "hashKey", "saltyA"); + // bucketVal = 0.09801207 + assertEquals(new Integer(0), ev1.getIndex()); + assert(ev1.isInExperiment()); + + LDUser user2 = new LDUser("userKeyB"); + EvaluatedVariation ev2 = EvaluatorBucketing.variationIndexForUser(vr, user2, "hashKey", "saltyA"); + // bucketVal = 0.14483777 + assertEquals(new Integer(1), ev2.getIndex()); + assert(ev2.isInExperiment()); + + LDUser user3 = new LDUser("userKeyC"); + EvaluatedVariation ev3 = EvaluatorBucketing.variationIndexForUser(vr, user3, "hashKey", "saltyA"); + // bucketVal = 0.9242641 + assertEquals(new Integer(0), ev3.getIndex()); + assert(!ev3.isInExperiment()); + } + + @Test + public void bucketUserByKeyTest() { + LDUser user1 = new LDUser("userKeyA"); + Float point1 = EvaluatorBucketing.bucketUser(noSeed, user1, "hashKey", UserAttribute.KEY, "saltyA"); + assertEquals(0.42157587, point1, 0.0000001); + + LDUser user2 = new LDUser("userKeyB"); + Float point2 = EvaluatorBucketing.bucketUser(noSeed, user2, "hashKey", UserAttribute.KEY, "saltyA"); + assertEquals(0.6708485, point2, 0.0000001); + + LDUser user3 = new LDUser("userKeyC"); + Float point3 = EvaluatorBucketing.bucketUser(noSeed, user3, "hashKey", UserAttribute.KEY, "saltyA"); + assertEquals(0.10343106, point3, 0.0000001); + } + + @Test + public void bucketUserWithSeedTest() { + Integer seed = 61; + + LDUser user1 = new LDUser("userKeyA"); + Float point1 = EvaluatorBucketing.bucketUser(seed, user1, "hashKey", UserAttribute.KEY, "saltyA"); + assertEquals(0.09801207, point1, 0.0000001); + + LDUser user2 = new LDUser("userKeyB"); + Float point2 = EvaluatorBucketing.bucketUser(seed, user2, "hashKey", UserAttribute.KEY, "saltyA"); + assertEquals(0.14483777, point2, 0.0000001); + + LDUser user3 = new LDUser("userKeyC"); + Float point3 = EvaluatorBucketing.bucketUser(seed, user3, "hashKey", UserAttribute.KEY, "saltyA"); + assertEquals(0.9242641, point3, 0.0000001); + } + +} From 3903d542bb19a24cbeebb80b028ac354cd5f1eef Mon Sep 17 00:00:00 2001 From: "Robert J. Neal" Date: Fri, 16 Apr 2021 11:31:56 -0700 Subject: [PATCH 576/673] fix null pointer exception --- src/main/java/com/launchdarkly/sdk/server/EventFactory.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/EventFactory.java b/src/main/java/com/launchdarkly/sdk/server/EventFactory.java index df6387e6b..50d108a67 100644 --- a/src/main/java/com/launchdarkly/sdk/server/EventFactory.java +++ b/src/main/java/com/launchdarkly/sdk/server/EventFactory.java @@ -237,7 +237,7 @@ private static boolean shouldEmitFullEvent(VariationOrRollout vr, EvaluationReas // For new-style experiments, as identified by the rollout kind, this is determined from the evaluation reason. Legacy // experiments instead use TrackEventsFallthrough or rule.TrackEvents for this purpose. - if (!vr.getRollout().isExperiment()) { + if (vr == null || vr.getRollout() == null || !vr.getRollout().isExperiment()) { return trackEventsOverride; } return reason.isInExperiment(); From 112f05297fad706fd23550071d9d1110eba27c86 Mon Sep 17 00:00:00 2001 From: "Robert J. Neal" Date: Mon, 19 Apr 2021 09:20:59 -0700 Subject: [PATCH 577/673] address code review comments --- .../java/com/launchdarkly/sdk/server/DataModel.java | 6 ------ .../com/launchdarkly/sdk/server/EventFactory.java | 4 ++-- .../sdk/server/DataModelSerializationTest.java | 12 ++++++------ .../sdk/server/EvaluatorBucketingTest.java | 8 ++++---- 4 files changed, 12 insertions(+), 18 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/DataModel.java b/src/main/java/com/launchdarkly/sdk/server/DataModel.java index 5ca03d487..acf1a527c 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DataModel.java +++ b/src/main/java/com/launchdarkly/sdk/server/DataModel.java @@ -416,12 +416,6 @@ static final class WeightedVariation { WeightedVariation() {} - WeightedVariation(int variation, int weight) { - this.variation = variation; - this.weight = weight; - this.untracked = false; - } - WeightedVariation(int variation, int weight, boolean untracked) { this.variation = variation; this.weight = weight; diff --git a/src/main/java/com/launchdarkly/sdk/server/EventFactory.java b/src/main/java/com/launchdarkly/sdk/server/EventFactory.java index 50d108a67..e38af8b5d 100644 --- a/src/main/java/com/launchdarkly/sdk/server/EventFactory.java +++ b/src/main/java/com/launchdarkly/sdk/server/EventFactory.java @@ -231,11 +231,11 @@ private static boolean isExperiment(DataModel.FeatureFlag flag, EvaluationReason private static boolean shouldEmitFullEvent(VariationOrRollout vr, EvaluationReason reason, boolean trackEventsOverride) { // This should return true if a full feature event should be emitted for this evaluation regardless of the value of - // f.TrackEvents; a true value also causes the evaluation reason to be included in the event regardless of whether it + // f.isTrackEvents(); a true value also causes the evaluation reason to be included in the event regardless of whether it // otherwise would have been. // // For new-style experiments, as identified by the rollout kind, this is determined from the evaluation reason. Legacy - // experiments instead use TrackEventsFallthrough or rule.TrackEvents for this purpose. + // experiments instead use isTrackEventsFallthrough() or rule.isTrackEvents() for this purpose. if (vr == null || vr.getRollout() == null || !vr.getRollout().isExperiment()) { return trackEventsOverride; diff --git a/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java b/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java index d277c0cef..77cdf36a2 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java @@ -87,7 +87,7 @@ public void flagIsDeserializedWithOptionalExperimentProperties() { assertEquals(0, flag.getTargets().size()); assertNotNull(flag.getRules()); assertEquals(1, flag.getRules().size()); - assert(!flag.getRules().get(0).getRollout().isExperiment()); + assertFalse(flag.getRules().get(0).getRollout().isExperiment()); assertNull(flag.getRules().get(0).getRollout().getSeed()); assertNull(flag.getFallthrough()); assertNull(flag.getOffVariation()); @@ -222,7 +222,7 @@ private void assertFlagHasAllProperties(FeatureFlag flag) { Rule r0 = flag.getRules().get(0); assertEquals("id0", r0.getId()); assertTrue(r0.isTrackEvents()); - assertEquals(new Integer(2), r0.getVariation()); + assertEquals(Integer.valueOf(2), r0.getVariation()); assertNull(r0.getRollout()); assertNotNull(r0.getClauses()); @@ -249,17 +249,17 @@ private void assertFlagHasAllProperties(FeatureFlag flag) { assertEquals(UserAttribute.EMAIL, r1.getRollout().getBucketBy()); assertEquals(RolloutKind.experiment, r1.getRollout().getKind()); assert(r1.getRollout().isExperiment()); - assertEquals(new Integer(123), r1.getRollout().getSeed()); + assertEquals(Integer.valueOf(123), r1.getRollout().getSeed()); assertNotNull(flag.getFallthrough()); - assertEquals(new Integer(1), flag.getFallthrough().getVariation()); + assertEquals(Integer.valueOf(1), flag.getFallthrough().getVariation()); assertNull(flag.getFallthrough().getRollout()); - assertEquals(new Integer(2), flag.getOffVariation()); + assertEquals(Integer.valueOf(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()); + assertEquals(Long.valueOf(1000), flag.getDebugEventsUntilDate()); } private LDValue segmentWithAllPropertiesJson() { diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorBucketingTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorBucketingTest.java index afae9b2d4..d5cd41317 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EvaluatorBucketingTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorBucketingTest.java @@ -36,9 +36,9 @@ public void variationIndexIsReturnedForBucket() { 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))); + new WeightedVariation(badVariationA, bucketValue, true), // end of bucket range is not inclusive, so it will *not* match the target value + new WeightedVariation(matchedVariation, 1, true), // size of this bucket is 1, so it only matches that specific value + new WeightedVariation(badVariationB, 100000 - (bucketValue + 1), true)); VariationOrRollout vr = new VariationOrRollout(null, new Rollout(variations, null, RolloutKind.rollout)); Integer resultVariation = EvaluatorBucketing.variationIndexForUser(vr, user, flagKey, salt).getIndex(); @@ -93,7 +93,7 @@ public void lastBucketIsUsedIfBucketValueEqualsTotalWeight() { // We'll construct a list of variations that stops right at the target bucket value int bucketValue = (int)(EvaluatorBucketing.bucketUser(noSeed, user, flagKey, UserAttribute.KEY, salt) * 100000); - List variations = Arrays.asList(new WeightedVariation(0, bucketValue)); + List variations = Arrays.asList(new WeightedVariation(0, bucketValue, true)); VariationOrRollout vr = new VariationOrRollout(null, new Rollout(variations, null, RolloutKind.rollout)); Integer resultVariation = EvaluatorBucketing.variationIndexForUser(vr, user, flagKey, salt).getIndex(); From 4765c66f5b50663c08be34abc01d9f4301d59600 Mon Sep 17 00:00:00 2001 From: "Robert J. Neal" Date: Mon, 19 Apr 2021 10:12:01 -0700 Subject: [PATCH 578/673] address more comments --- .../launchdarkly/sdk/server/DataModel.java | 2 +- .../sdk/server/EvaluatorBucketing.java | 4 ++-- .../server/DataModelSerializationTest.java | 23 ++++++++++++++----- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/DataModel.java b/src/main/java/com/launchdarkly/sdk/server/DataModel.java index acf1a527c..6086a4c64 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DataModel.java +++ b/src/main/java/com/launchdarkly/sdk/server/DataModel.java @@ -430,7 +430,7 @@ int getWeight() { return weight; } - boolean isTracked() { + boolean isUntracked() { return !untracked; } } diff --git a/src/main/java/com/launchdarkly/sdk/server/EvaluatorBucketing.java b/src/main/java/com/launchdarkly/sdk/server/EvaluatorBucketing.java index 82841553a..79b123900 100644 --- a/src/main/java/com/launchdarkly/sdk/server/EvaluatorBucketing.java +++ b/src/main/java/com/launchdarkly/sdk/server/EvaluatorBucketing.java @@ -47,7 +47,7 @@ static EvaluatedVariation variationIndexForUser(DataModel.VariationOrRollout vr, for (DataModel.WeightedVariation wv : rollout.getVariations()) { sum += (float) wv.getWeight() / 100000F; if (bucket < sum) { - return new EvaluatedVariation(wv.getVariation(), vr.getRollout().isExperiment() && wv.isTracked()); + return new EvaluatedVariation(wv.getVariation(), vr.getRollout().isExperiment() && !wv.isUntracked()); } } // The user's bucket value was greater than or equal to the end of the last bucket. This could happen due @@ -56,7 +56,7 @@ static EvaluatedVariation variationIndexForUser(DataModel.VariationOrRollout vr, // 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. WeightedVariation lastVariation = rollout.getVariations().get(rollout.getVariations().size() - 1); - return new EvaluatedVariation(lastVariation.getVariation(), vr.getRollout().isExperiment() && lastVariation.isTracked()); + return new EvaluatedVariation(lastVariation.getVariation(), vr.getRollout().isExperiment() && !lastVariation.isUntracked()); } } return null; diff --git a/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java b/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java index 77cdf36a2..185203369 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java @@ -62,7 +62,9 @@ public void flagIsDeserializedWithMinimalProperties() { @Test public void flagIsDeserializedWithOptionalExperimentProperties() { - String json = LDValue.buildObject().put("key", "flag-key").put("version", 99) + String json = LDValue.buildObject() + .put("key", "flag-key") + .put("version", 157) .put("rules", LDValue.buildArray() .add(LDValue.buildObject() .put("id", "id1") @@ -77,22 +79,27 @@ public void flagIsDeserializedWithOptionalExperimentProperties() { .build()) .build()) .build()) - .build().toJsonString(); + .put("fallthrough", LDValue.buildObject() + .put("variation", 1) + .build()) + .put("offVariation", 2) + .put("variations", LDValue.buildArray().add("a").add("b").add("c").build()) + .build().toJsonString(); FeatureFlag flag = (FeatureFlag)FEATURES.deserialize(json).getItem(); assertEquals("flag-key", flag.getKey()); - assertEquals(99, flag.getVersion()); + assertEquals(157, flag.getVersion()); assertFalse(flag.isOn()); assertNull(flag.getSalt()); assertNotNull(flag.getTargets()); assertEquals(0, flag.getTargets().size()); assertNotNull(flag.getRules()); assertEquals(1, flag.getRules().size()); + assertNull(flag.getRules().get(0).getRollout().getKind()); assertFalse(flag.getRules().get(0).getRollout().isExperiment()); assertNull(flag.getRules().get(0).getRollout().getSeed()); - assertNull(flag.getFallthrough()); - assertNull(flag.getOffVariation()); + assertTrue(flag.getRules().get(0).getRollout().getVariations().get(0).isUntracked()); assertNotNull(flag.getVariations()); - assertEquals(0, flag.getVariations().size()); + assertEquals(3, flag.getVariations().size()); assertFalse(flag.isClientSide()); assertFalse(flag.isTrackEvents()); assertFalse(flag.isTrackEventsFallthrough()); @@ -285,6 +292,10 @@ private LDValue segmentWithAllPropertiesJson() { .add(LDValue.buildObject() .build()) .build()) + .put("fallthrough", LDValue.buildObject() + .put("variation", 1) + .build()) + .put("variations", LDValue.buildArray().add("a").add("b").add("c").build()) .build(); } From 12ee4391171cd6ac0537086333113473ddc66c07 Mon Sep 17 00:00:00 2001 From: "Robert J. Neal" Date: Mon, 19 Apr 2021 10:33:52 -0700 Subject: [PATCH 579/673] missed a ! for isUntracked() --- src/main/java/com/launchdarkly/sdk/server/DataModel.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/DataModel.java b/src/main/java/com/launchdarkly/sdk/server/DataModel.java index 6086a4c64..329271d82 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DataModel.java +++ b/src/main/java/com/launchdarkly/sdk/server/DataModel.java @@ -431,7 +431,7 @@ int getWeight() { } boolean isUntracked() { - return !untracked; + return untracked; } } From 9ed9b2c5f5a3a05ebbb75d707cfb4edefdb42403 Mon Sep 17 00:00:00 2001 From: "Robert J. Neal" Date: Mon, 19 Apr 2021 10:46:54 -0700 Subject: [PATCH 580/673] fix default boolean for json --- src/main/java/com/launchdarkly/sdk/server/DataModel.java | 2 +- .../com/launchdarkly/sdk/server/DataModelSerializationTest.java | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/DataModel.java b/src/main/java/com/launchdarkly/sdk/server/DataModel.java index 329271d82..ada9e9223 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DataModel.java +++ b/src/main/java/com/launchdarkly/sdk/server/DataModel.java @@ -412,7 +412,7 @@ Rollout getRollout() { static final class WeightedVariation { private int variation; private int weight; - private boolean untracked; + private boolean untracked = true; WeightedVariation() {} diff --git a/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java b/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java index 185203369..4d59deacf 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java @@ -97,6 +97,8 @@ public void flagIsDeserializedWithOptionalExperimentProperties() { assertNull(flag.getRules().get(0).getRollout().getKind()); assertFalse(flag.getRules().get(0).getRollout().isExperiment()); assertNull(flag.getRules().get(0).getRollout().getSeed()); + assertEquals(2, flag.getRules().get(0).getRollout().getVariations().get(0).getVariation()); + assertEquals(100000, flag.getRules().get(0).getRollout().getVariations().get(0).getWeight()); assertTrue(flag.getRules().get(0).getRollout().getVariations().get(0).isUntracked()); assertNotNull(flag.getVariations()); assertEquals(3, flag.getVariations().size()); From d651d26802c264542d2213d7f805f042aa30b0db Mon Sep 17 00:00:00 2001 From: "Robert J. Neal" Date: Mon, 19 Apr 2021 11:11:28 -0700 Subject: [PATCH 581/673] make untracked FALSE by default --- src/main/java/com/launchdarkly/sdk/server/DataModel.java | 2 +- .../com/launchdarkly/sdk/server/DataModelSerializationTest.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/DataModel.java b/src/main/java/com/launchdarkly/sdk/server/DataModel.java index ada9e9223..329271d82 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DataModel.java +++ b/src/main/java/com/launchdarkly/sdk/server/DataModel.java @@ -412,7 +412,7 @@ Rollout getRollout() { static final class WeightedVariation { private int variation; private int weight; - private boolean untracked = true; + private boolean untracked; WeightedVariation() {} diff --git a/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java b/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java index 4d59deacf..940c8d80b 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java @@ -99,7 +99,7 @@ public void flagIsDeserializedWithOptionalExperimentProperties() { assertNull(flag.getRules().get(0).getRollout().getSeed()); assertEquals(2, flag.getRules().get(0).getRollout().getVariations().get(0).getVariation()); assertEquals(100000, flag.getRules().get(0).getRollout().getVariations().get(0).getWeight()); - assertTrue(flag.getRules().get(0).getRollout().getVariations().get(0).isUntracked()); + assertFalse(flag.getRules().get(0).getRollout().getVariations().get(0).isUntracked()); assertNotNull(flag.getVariations()); assertEquals(3, flag.getVariations().size()); assertFalse(flag.isClientSide()); From 176d7cef22ff5f5f2b9ff488a24e3944c5eeca9d Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 20 Apr 2021 10:23:12 -0700 Subject: [PATCH 582/673] refactoring of bucketing logic to remove the need for an extra result object (#283) --- .../launchdarkly/sdk/server/Evaluator.java | 41 +++++++++++--- .../sdk/server/EvaluatorBucketing.java | 48 ----------------- .../server/DataModelSerializationTest.java | 5 +- .../sdk/server/EvaluatorBucketingTest.java | 54 +++++++++++++++---- .../sdk/server/EvaluatorTestUtil.java | 3 -- .../sdk/server/ModelBuilders.java | 13 +++++ .../RolloutRandomizationConsistencyTest.java | 52 ++++++++++++------ 7 files changed, 131 insertions(+), 85 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/Evaluator.java b/src/main/java/com/launchdarkly/sdk/server/Evaluator.java index 0c2b3ad1b..8128f8c88 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Evaluator.java +++ b/src/main/java/com/launchdarkly/sdk/server/Evaluator.java @@ -7,6 +7,7 @@ import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.LDValueType; import com.launchdarkly.sdk.EvaluationReason.Kind; +import com.launchdarkly.sdk.server.DataModel.WeightedVariation; import com.launchdarkly.sdk.server.interfaces.Event; import org.slf4j.Logger; @@ -16,6 +17,7 @@ import java.util.Set; import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; +import static com.launchdarkly.sdk.server.EvaluatorBucketing.bucketUser; /** * Encapsulates the feature flag evaluation logic. The Evaluator has no knowledge of the rest of the SDK environment; @@ -222,15 +224,42 @@ private EvalResult getOffValue(DataModel.FeatureFlag flag, EvaluationReason reas } private EvalResult getValueForVariationOrRollout(DataModel.FeatureFlag flag, DataModel.VariationOrRollout vr, LDUser user, EvaluationReason reason) { - EvaluatedVariation evaluatedVariation = EvaluatorBucketing.variationIndexForUser(vr, user, flag.getKey(), flag.getSalt()); - if (evaluatedVariation == null || evaluatedVariation.getIndex() == null) { + int variation = -1; + boolean inExperiment = false; + Integer maybeVariation = vr.getVariation(); + if (maybeVariation != null) { + variation = maybeVariation.intValue(); + } else { + DataModel.Rollout rollout = vr.getRollout(); + if (rollout != null && !rollout.getVariations().isEmpty()) { + float bucket = bucketUser(rollout.getSeed(), user, flag.getKey(), rollout.getBucketBy(), flag.getSalt()); + float sum = 0F; + for (DataModel.WeightedVariation wv : rollout.getVariations()) { + sum += (float) wv.getWeight() / 100000F; + if (bucket < sum) { + variation = wv.getVariation(); + inExperiment = vr.getRollout().isExperiment() && !wv.isUntracked(); + break; + } + } + if (variation < 0) { + // 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. + WeightedVariation lastVariation = rollout.getVariations().get(rollout.getVariations().size() - 1); + variation = lastVariation.getVariation(); + inExperiment = vr.getRollout().isExperiment() && !lastVariation.isUntracked(); + } + } + } + + if (variation < 0) { logger.error("Data inconsistency in feature flag \"{}\": variation/rollout object with no variation or rollout", flag.getKey()); return EvalResult.error(EvaluationReason.ErrorKind.MALFORMED_FLAG); } else { - if (evaluatedVariation.isInExperiment()) { - reason = experimentize(reason); - } - return getVariation(flag, evaluatedVariation.getIndex(), reason); + return getVariation(flag, variation, inExperiment ? experimentize(reason) : reason); } } diff --git a/src/main/java/com/launchdarkly/sdk/server/EvaluatorBucketing.java b/src/main/java/com/launchdarkly/sdk/server/EvaluatorBucketing.java index 79b123900..b770020cb 100644 --- a/src/main/java/com/launchdarkly/sdk/server/EvaluatorBucketing.java +++ b/src/main/java/com/launchdarkly/sdk/server/EvaluatorBucketing.java @@ -3,28 +3,9 @@ import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.UserAttribute; -import com.launchdarkly.sdk.server.DataModel.WeightedVariation; import org.apache.commons.codec.digest.DigestUtils; -final class EvaluatedVariation { - private Integer index; - private boolean inExperiment; - - EvaluatedVariation(Integer index, boolean inExperiment) { - this.index = index; - this.inExperiment = inExperiment; - } - - public Integer getIndex() { - return index; - } - - public boolean isInExperiment() { - return inExperiment; - } -} - /** * Encapsulates the logic for percentage rollouts. */ @@ -33,35 +14,6 @@ private EvaluatorBucketing() {} private static final float LONG_SCALE = (float) 0xFFFFFFFFFFFFFFFL; - // 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). - static EvaluatedVariation variationIndexForUser(DataModel.VariationOrRollout vr, LDUser user, String key, String salt) { - Integer variation = vr.getVariation(); - if (variation != null) { - return new EvaluatedVariation(variation, false); - } else { - DataModel.Rollout rollout = vr.getRollout(); - if (rollout != null && !rollout.getVariations().isEmpty()) { - float bucket = bucketUser(rollout.getSeed(), user, key, rollout.getBucketBy(), salt); - float sum = 0F; - for (DataModel.WeightedVariation wv : rollout.getVariations()) { - sum += (float) wv.getWeight() / 100000F; - if (bucket < sum) { - return new EvaluatedVariation(wv.getVariation(), vr.getRollout().isExperiment() && !wv.isUntracked()); - } - } - // 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. - WeightedVariation lastVariation = rollout.getVariations().get(rollout.getVariations().size() - 1); - return new EvaluatedVariation(lastVariation.getVariation(), vr.getRollout().isExperiment() && !lastVariation.isUntracked()); - } - } - return null; - } - static float bucketUser(Integer seed, LDUser user, String key, UserAttribute attr, String salt) { LDValue userValue = user.getAttribute(attr == null ? UserAttribute.KEY : attr); String idHash = getBucketableStringValue(userValue); diff --git a/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java b/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java index 940c8d80b..e2512dfa1 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java @@ -40,7 +40,6 @@ public void flagIsDeserializedWithAllProperties() { @Test public void flagIsDeserializedWithMinimalProperties() { String json = LDValue.buildObject().put("key", "flag-key").put("version", 99).build().toJsonString(); - FeatureFlag flag = (FeatureFlag)FEATURES.deserialize(json).getItem(); assertEquals("flag-key", flag.getKey()); assertEquals(99, flag.getVersion()); @@ -219,7 +218,7 @@ private void assertFlagHasAllProperties(FeatureFlag flag) { 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); @@ -311,7 +310,7 @@ private void assertSegmentHasAllProperties(Segment segment) { assertNotNull(segment.getRules()); assertEquals(2, segment.getRules().size()); SegmentRule r0 = segment.getRules().get(0); - assertEquals(new Integer(50000), r0.getWeight()); + assertEquals(Integer.valueOf(50000), r0.getWeight()); assertNotNull(r0.getClauses()); assertEquals(1, r0.getClauses().size()); diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorBucketingTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorBucketingTest.java index d5cd41317..f2ca34451 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EvaluatorBucketingTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorBucketingTest.java @@ -1,20 +1,26 @@ package com.launchdarkly.sdk.server; +import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.server.DataModel.FeatureFlag; +import com.launchdarkly.sdk.server.DataModel.Operator; import com.launchdarkly.sdk.server.DataModel.Rollout; import com.launchdarkly.sdk.server.DataModel.RolloutKind; -import com.launchdarkly.sdk.server.DataModel.VariationOrRollout; import com.launchdarkly.sdk.server.DataModel.WeightedVariation; +import com.launchdarkly.sdk.server.Evaluator.EvalResult; -import org.hamcrest.Matchers; import org.junit.Test; import java.util.Arrays; import java.util.List; +import static com.launchdarkly.sdk.server.EvaluatorTestUtil.BASE_EVALUATOR; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.hamcrest.Matchers.lessThan; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotEquals; @@ -32,17 +38,16 @@ public void variationIndexIsReturnedForBucket() { // so we can construct a rollout whose second bucket just barely contains that value int bucketValue = (int)(EvaluatorBucketing.bucketUser(noSeed, user, flagKey, UserAttribute.KEY, salt) * 100000); assertThat(bucketValue, greaterThanOrEqualTo(1)); - assertThat(bucketValue, Matchers.lessThan(100000)); + assertThat(bucketValue, lessThan(100000)); int badVariationA = 0, matchedVariation = 1, badVariationB = 2; List variations = Arrays.asList( new WeightedVariation(badVariationA, bucketValue, true), // end of bucket range is not inclusive, so it will *not* match the target value new WeightedVariation(matchedVariation, 1, true), // size of this bucket is 1, so it only matches that specific value new WeightedVariation(badVariationB, 100000 - (bucketValue + 1), true)); - VariationOrRollout vr = new VariationOrRollout(null, new Rollout(variations, null, RolloutKind.rollout)); + Rollout rollout = new Rollout(variations, null, RolloutKind.rollout); - Integer resultVariation = EvaluatorBucketing.variationIndexForUser(vr, user, flagKey, salt).getIndex(); - assertEquals(Integer.valueOf(matchedVariation), resultVariation); + assertVariationIndexFromRollout(matchedVariation, rollout, user, flagKey, salt); } @Test @@ -94,10 +99,9 @@ public void lastBucketIsUsedIfBucketValueEqualsTotalWeight() { int bucketValue = (int)(EvaluatorBucketing.bucketUser(noSeed, user, flagKey, UserAttribute.KEY, salt) * 100000); List variations = Arrays.asList(new WeightedVariation(0, bucketValue, true)); - VariationOrRollout vr = new VariationOrRollout(null, new Rollout(variations, null, RolloutKind.rollout)); + Rollout rollout = new Rollout(variations, null, RolloutKind.rollout); - Integer resultVariation = EvaluatorBucketing.variationIndexForUser(vr, user, flagKey, salt).getIndex(); - assertEquals(Integer.valueOf(0), resultVariation); + assertVariationIndexFromRollout(0, rollout, user, flagKey, salt); } @Test @@ -137,4 +141,36 @@ public void userSecondaryKeyAffectsBucketValue() { float result2 = EvaluatorBucketing.bucketUser(noSeed, user2, "flagkey", UserAttribute.KEY, "salt"); assertNotEquals(result1, result2); } + + private static void assertVariationIndexFromRollout( + int expectedVariation, + Rollout rollout, + LDUser user, + String flagKey, + String salt + ) { + FeatureFlag flag1 = ModelBuilders.flagBuilder(flagKey) + .on(true) + .generatedVariations(3) + .fallthrough(rollout) + .salt(salt) + .build(); + EvalResult result1 = BASE_EVALUATOR.evaluate(flag1, user, EventFactory.DEFAULT); + assertThat(result1.getReason(), equalTo(EvaluationReason.fallthrough())); + assertThat(result1.getVariationIndex(), equalTo(expectedVariation)); + + // Make sure we consistently apply the rollout regardless of whether it's in a rule or a fallthrough + FeatureFlag flag2 = ModelBuilders.flagBuilder(flagKey) + .on(true) + .generatedVariations(3) + .rules(ModelBuilders.ruleBuilder() + .rollout(rollout) + .clauses(ModelBuilders.clause(UserAttribute.KEY, Operator.in, LDValue.of(user.getKey()))) + .build()) + .salt(salt) + .build(); + EvalResult result2 = BASE_EVALUATOR.evaluate(flag2, user, EventFactory.DEFAULT); + assertThat(result2.getReason().getKind(), equalTo(EvaluationReason.Kind.RULE_MATCH)); + assertThat(result2.getVariationIndex(), equalTo(expectedVariation)); + } } diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorTestUtil.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTestUtil.java index dcb9372f4..6a0cc5e22 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EvaluatorTestUtil.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTestUtil.java @@ -1,8 +1,5 @@ package com.launchdarkly.sdk.server; -import com.launchdarkly.sdk.server.DataModel; -import com.launchdarkly.sdk.server.Evaluator; - @SuppressWarnings("javadoc") public abstract class EvaluatorTestUtil { public static Evaluator BASE_EVALUATOR = evaluatorBuilder().build(); diff --git a/src/test/java/com/launchdarkly/sdk/server/ModelBuilders.java b/src/test/java/com/launchdarkly/sdk/server/ModelBuilders.java index 818d801f1..6d03d9777 100644 --- a/src/test/java/com/launchdarkly/sdk/server/ModelBuilders.java +++ b/src/test/java/com/launchdarkly/sdk/server/ModelBuilders.java @@ -165,6 +165,11 @@ FlagBuilder fallthroughVariation(int fallthroughVariation) { return this; } + FlagBuilder fallthrough(DataModel.Rollout rollout) { + this.fallthrough = new DataModel.VariationOrRollout(null, rollout); + return this; + } + FlagBuilder fallthrough(DataModel.VariationOrRollout fallthrough) { this.fallthrough = fallthrough; return this; @@ -189,6 +194,14 @@ FlagBuilder variations(boolean... variations) { return this; } + FlagBuilder generatedVariations(int numVariations) { + variations.clear(); + for (int i = 0; i < numVariations; i++) { + variations.add(LDValue.of(i)); + } + return this; + } + FlagBuilder clientSide(boolean clientSide) { this.clientSide = clientSide; return this; diff --git a/src/test/java/com/launchdarkly/sdk/server/RolloutRandomizationConsistencyTest.java b/src/test/java/com/launchdarkly/sdk/server/RolloutRandomizationConsistencyTest.java index 31d6efb9c..6614c5108 100644 --- a/src/test/java/com/launchdarkly/sdk/server/RolloutRandomizationConsistencyTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/RolloutRandomizationConsistencyTest.java @@ -1,19 +1,24 @@ package com.launchdarkly.sdk.server; -import static org.junit.Assert.assertEquals; - -import java.util.ArrayList; -import java.util.List; - +import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.server.DataModel.FeatureFlag; import com.launchdarkly.sdk.server.DataModel.Rollout; import com.launchdarkly.sdk.server.DataModel.RolloutKind; -import com.launchdarkly.sdk.server.DataModel.VariationOrRollout; import com.launchdarkly.sdk.server.DataModel.WeightedVariation; +import com.launchdarkly.sdk.server.Evaluator.EvalResult; import org.junit.Test; +import java.util.ArrayList; +import java.util.List; + +import static com.launchdarkly.sdk.server.EvaluatorTestUtil.BASE_EVALUATOR; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.junit.Assert.assertEquals; + /* * Note: These tests are meant to be exact duplicates of tests * in other SDKs. Do not change any of the values unless they @@ -40,27 +45,42 @@ private static Rollout buildRollout(boolean isExperiment, boolean untrackedVaria public void variationIndexForUserInExperimentTest() { // seed here carefully chosen so users fall into different buckets Rollout rollout = buildRollout(true, false); - VariationOrRollout vr = new VariationOrRollout(null, rollout); + String key = "hashKey"; + String salt = "saltyA"; LDUser user1 = new LDUser("userKeyA"); - EvaluatedVariation ev1 = EvaluatorBucketing.variationIndexForUser(vr, user1, "hashKey", "saltyA"); // bucketVal = 0.09801207 - assertEquals(new Integer(0), ev1.getIndex()); - assert(ev1.isInExperiment()); + assertVariationIndexAndExperimentStateForRollout(0, true, rollout, user1, key, salt); LDUser user2 = new LDUser("userKeyB"); - EvaluatedVariation ev2 = EvaluatorBucketing.variationIndexForUser(vr, user2, "hashKey", "saltyA"); // bucketVal = 0.14483777 - assertEquals(new Integer(1), ev2.getIndex()); - assert(ev2.isInExperiment()); + assertVariationIndexAndExperimentStateForRollout(1, true, rollout, user2, key, salt); LDUser user3 = new LDUser("userKeyC"); - EvaluatedVariation ev3 = EvaluatorBucketing.variationIndexForUser(vr, user3, "hashKey", "saltyA"); // bucketVal = 0.9242641 - assertEquals(new Integer(0), ev3.getIndex()); - assert(!ev3.isInExperiment()); + assertVariationIndexAndExperimentStateForRollout(0, false, rollout, user3, key, salt); } + private static void assertVariationIndexAndExperimentStateForRollout( + int expectedVariation, + boolean expectedInExperiment, + Rollout rollout, + LDUser user, + String flagKey, + String salt + ) { + FeatureFlag flag = ModelBuilders.flagBuilder(flagKey) + .on(true) + .generatedVariations(3) + .fallthrough(rollout) + .salt(salt) + .build(); + EvalResult result = BASE_EVALUATOR.evaluate(flag, user, EventFactory.DEFAULT); + assertThat(result.getVariationIndex(), equalTo(expectedVariation)); + assertThat(result.getReason().getKind(), equalTo(EvaluationReason.Kind.FALLTHROUGH)); + assertThat(result.getReason().isInExperiment(), equalTo(expectedInExperiment)); + } + @Test public void bucketUserByKeyTest() { LDUser user1 = new LDUser("userKeyA"); From de65f7ba93e4515c0e4ff9ead45685ac13f18c95 Mon Sep 17 00:00:00 2001 From: "Robert J. Neal" Date: Tue, 20 Apr 2021 12:51:10 -0700 Subject: [PATCH 583/673] add comment to enum --- src/main/java/com/launchdarkly/sdk/server/DataModel.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/com/launchdarkly/sdk/server/DataModel.java b/src/main/java/com/launchdarkly/sdk/server/DataModel.java index 329271d82..2b8ac0636 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DataModel.java +++ b/src/main/java/com/launchdarkly/sdk/server/DataModel.java @@ -541,6 +541,10 @@ static enum Operator { segmentMatch } + /** + * This enum is all lowercase so that when it is automatically deserialized from JSON, + * the lowercase properties properly map to these enumerations. + */ static enum RolloutKind { rollout, experiment From d8c06eced5e0d5a9c3a9ad30e448a4be615e062b Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 22 Apr 2021 15:49:09 -0700 Subject: [PATCH 584/673] various JSON fixes, update common-sdk (#284) --- build.gradle | 2 +- .../sdk/server/FeatureFlagsState.java | 36 ++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index ceef1ec45..eff787942 100644 --- a/build.gradle +++ b/build.gradle @@ -72,7 +72,7 @@ ext.versions = [ "gson": "2.7", "guava": "30.1-jre", "jackson": "2.11.2", - "launchdarklyJavaSdkCommon": "1.0.0", + "launchdarklyJavaSdkCommon": "1.1.1", "okhttp": "4.8.1", // specify this for the SDK build instead of relying on the transitive dependency from okhttp-eventsource "okhttpEventsource": "2.3.1", "slf4j": "1.7.21", diff --git a/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java b/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java index 71822714f..948d83a72 100644 --- a/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java +++ b/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java @@ -180,14 +180,48 @@ 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()); gsonInstance().toJson(entry.getValue(), LDValue.class, out); } + out.name("$flagsState"); - gsonInstance().toJson(state.flagMetadata, Map.class, out); + out.beginObject(); + for (Map.Entry entry: state.flagMetadata.entrySet()) { + out.name(entry.getKey()); + FlagMetadata meta = entry.getValue(); + out.beginObject(); + // Here we're serializing FlagMetadata properties individually because if we rely on + // Gson's reflection mechanism, it won't reliably drop null properties (that only works + // if the destination really is Gson, not if a Jackson adapter is being used). + if (meta.variation != null) { + out.name("variation"); + out.value(meta.variation.intValue()); + } + if (meta.reason != null) { + out.name("reason"); + gsonInstance().toJson(meta.reason, EvaluationReason.class, out); + } + if (meta.version != null) { + out.name("version"); + out.value(meta.version.intValue()); + } + if (meta.trackEvents != null) { + out.name("trackEvents"); + out.value(meta.trackEvents.booleanValue()); + } + if (meta.debugEventsUntilDate != null) { + out.name("debugEventsUntilDate"); + out.value(meta.debugEventsUntilDate.longValue()); + } + out.endObject(); + } + out.endObject(); + out.name("$valid"); out.value(state.valid); + out.endObject(); } From 95440c72bcaa161dd6898020ebf9ff083e7542a9 Mon Sep 17 00:00:00 2001 From: "Robert J. Neal" Date: Tue, 1 Jun 2021 11:37:21 -0700 Subject: [PATCH 585/673] simlpify the logic and make it match node/.Net sdks --- .../launchdarkly/sdk/server/EventFactory.java | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/EventFactory.java b/src/main/java/com/launchdarkly/sdk/server/EventFactory.java index e38af8b5d..675196fd0 100644 --- a/src/main/java/com/launchdarkly/sdk/server/EventFactory.java +++ b/src/main/java/com/launchdarkly/sdk/server/EventFactory.java @@ -211,9 +211,10 @@ private static boolean isExperiment(DataModel.FeatureFlag flag, EvaluationReason // doesn't happen in real life, but possible in testing return false; } + if (reason.isInExperiment()) return true; switch (reason.getKind()) { case FALLTHROUGH: - return shouldEmitFullEvent(flag.getFallthrough(), reason, flag.isTrackEventsFallthrough()); + return flag.isTrackEventsFallthrough(); case RULE_MATCH: int ruleIndex = reason.getRuleIndex(); // Note, it is OK to rely on the rule index rather than the unique ID in this context, because the @@ -221,25 +222,11 @@ private static boolean isExperiment(DataModel.FeatureFlag flag, EvaluationReason // evaluated, so we cannot be out of sync with its rule list. if (ruleIndex >= 0 && ruleIndex < flag.getRules().size()) { DataModel.Rule rule = flag.getRules().get(ruleIndex); - return shouldEmitFullEvent(rule, reason, rule.isTrackEvents()); + return rule.isTrackEvents(); } return false; default: return false; } } - - private static boolean shouldEmitFullEvent(VariationOrRollout vr, EvaluationReason reason, boolean trackEventsOverride) { - // This should return true if a full feature event should be emitted for this evaluation regardless of the value of - // f.isTrackEvents(); a true value also causes the evaluation reason to be included in the event regardless of whether it - // otherwise would have been. - // - // For new-style experiments, as identified by the rollout kind, this is determined from the evaluation reason. Legacy - // experiments instead use isTrackEventsFallthrough() or rule.isTrackEvents() for this purpose. - - if (vr == null || vr.getRollout() == null || !vr.getRollout().isExperiment()) { - return trackEventsOverride; - } - return reason.isInExperiment(); - } } From bf3c9c518335e4e18b817ce7f5595be58684102d Mon Sep 17 00:00:00 2001 From: "Robert J. Neal" Date: Tue, 1 Jun 2021 13:34:31 -0700 Subject: [PATCH 586/673] Update src/main/java/com/launchdarkly/sdk/server/EventFactory.java Co-authored-by: Sam Stokes --- src/main/java/com/launchdarkly/sdk/server/EventFactory.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/launchdarkly/sdk/server/EventFactory.java b/src/main/java/com/launchdarkly/sdk/server/EventFactory.java index 675196fd0..db26d75fa 100644 --- a/src/main/java/com/launchdarkly/sdk/server/EventFactory.java +++ b/src/main/java/com/launchdarkly/sdk/server/EventFactory.java @@ -212,6 +212,7 @@ private static boolean isExperiment(DataModel.FeatureFlag flag, EvaluationReason return false; } if (reason.isInExperiment()) return true; + switch (reason.getKind()) { case FALLTHROUGH: return flag.isTrackEventsFallthrough(); From ee1268b4809e081db528160afbb3eb0f5f81779e Mon Sep 17 00:00:00 2001 From: "Robert J. Neal" Date: Wed, 2 Jun 2021 09:15:36 -0700 Subject: [PATCH 587/673] add the same comment as the Node SDK --- src/main/java/com/launchdarkly/sdk/server/EventFactory.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/com/launchdarkly/sdk/server/EventFactory.java b/src/main/java/com/launchdarkly/sdk/server/EventFactory.java index db26d75fa..32ceb9c5d 100644 --- a/src/main/java/com/launchdarkly/sdk/server/EventFactory.java +++ b/src/main/java/com/launchdarkly/sdk/server/EventFactory.java @@ -211,6 +211,9 @@ private static boolean isExperiment(DataModel.FeatureFlag flag, EvaluationReason // doesn't happen in real life, but possible in testing return false; } + + // If the reason says we're in an experiment, we are. Otherwise, apply + // the legacy rule exclusion logic. if (reason.isInExperiment()) return true; switch (reason.getKind()) { From 58367e0da3a8f78fdcfe25ad20c6c1c3f8c352c5 Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Wed, 9 Jun 2021 19:19:52 +0000 Subject: [PATCH 588/673] Remove outdated/meaningless doc comment. (#286) --- src/main/java/com/launchdarkly/sdk/server/Components.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/Components.java b/src/main/java/com/launchdarkly/sdk/server/Components.java index d82cb8893..e2a4092f8 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Components.java +++ b/src/main/java/com/launchdarkly/sdk/server/Components.java @@ -204,9 +204,6 @@ public static PollingDataSourceBuilder pollingDataSource() { * .dataStore(Components.persistentDataStore(Redis.dataStore())) // assuming the Relay Proxy is using Redis * .build(); * - *

    - * (Note that the interface is still named {@link DataSourceFactory}, but in a future version it - * will be renamed to {@code DataSourceFactory}.) * * @return a factory object * @since 4.12.0 From 72c30969c65b92c067fea22d7542cd24e4fd5d2b Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 10 Jun 2021 12:43:14 -0700 Subject: [PATCH 589/673] protect against NPEs if flag/segment JSON contains a null value --- .../sdk/server/EvaluatorPreprocessing.java | 21 +- .../server/DataModelSerializationTest.java | 200 +++++++++++++++--- 2 files changed, 195 insertions(+), 26 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/EvaluatorPreprocessing.java b/src/main/java/com/launchdarkly/sdk/server/EvaluatorPreprocessing.java index 6a40c45cc..c3e95a0ba 100644 --- a/src/main/java/com/launchdarkly/sdk/server/EvaluatorPreprocessing.java +++ b/src/main/java/com/launchdarkly/sdk/server/EvaluatorPreprocessing.java @@ -61,6 +61,7 @@ static void preprocessFlag(FeatureFlag f) { for (int i = 0; i < n; i++) { preprocessFlagRule(rules.get(i), i); } + preprocessValueList(f.getVariations()); } static void preprocessSegment(Segment s) { @@ -92,6 +93,13 @@ static void preprocessSegmentRule(SegmentRule r, int ruleIndex) { } static void preprocessClause(Clause c) { + // If the clause values contain a null (which is valid in terms of the JSON schema, even if it + // can't ever produce a true result), Gson will give us an actual null. Change this to + // LDValue.ofNull() to avoid NPEs down the line. It's more efficient to do this just once at + // deserialization time than to do it in every clause match. + List values = c.getValues(); + preprocessValueList(values); + Operator op = c.getOp(); if (op == null) { return; @@ -102,7 +110,6 @@ static void preprocessClause(Clause c) { // clause values. Converting the value list to a Set allows us to do a fast lookup instead of // a linear search. We do not do this for other operators (or if there are fewer than two // values) because the slight extra overhead of a Set is not worthwhile in those case. - List values = c.getValues(); if (values.size() > 1) { c.setPreprocessed(new ClauseExtra(ImmutableSet.copyOf(values), null)); } @@ -130,6 +137,18 @@ static void preprocessClause(Clause c) { } } + static void preprocessValueList(List values) { + // If a list of values contains a null (which is valid in terms of the JSON schema, even if it + // isn't useful because the SDK considers this a non-value), Gson will give us an actual null. + // Change this to LDValue.ofNull() to avoid NPEs down the line. It's more efficient to do this + // just once at deserialization time than to do it in every clause match. + for (int i = 0; i < values.size(); i++) { + if (values.get(i) == null) { + values.set(i, LDValue.ofNull()); + } + } + } + private static ClauseExtra preprocessClauseValues( List values, Function f diff --git a/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java b/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java index ca1dfff0d..3489228ff 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java @@ -3,18 +3,24 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.ObjectBuilder; import com.launchdarkly.sdk.UserAttribute; import com.launchdarkly.sdk.server.DataModel.Clause; import com.launchdarkly.sdk.server.DataModel.FeatureFlag; import com.launchdarkly.sdk.server.DataModel.Operator; +import com.launchdarkly.sdk.server.DataModel.Prerequisite; import com.launchdarkly.sdk.server.DataModel.Rule; import com.launchdarkly.sdk.server.DataModel.Segment; import com.launchdarkly.sdk.server.DataModel.SegmentRule; import com.launchdarkly.sdk.server.DataModel.Target; +import com.launchdarkly.sdk.server.DataModel.WeightedVariation; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import org.junit.Test; +import java.util.Collections; +import java.util.function.Consumer; + import static com.launchdarkly.sdk.server.DataModel.FEATURES; import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; import static org.junit.Assert.assertEquals; @@ -27,35 +33,38 @@ public class DataModelSerializationTest { @Test public void flagIsDeserializedWithAllProperties() { - String json0 = flagWithAllPropertiesJson().toJsonString(); - FeatureFlag flag0 = (FeatureFlag)FEATURES.deserialize(json0).getItem(); - assertFlagHasAllProperties(flag0); - - String json1 = FEATURES.serialize(new ItemDescriptor(flag0.getVersion(), flag0)); - FeatureFlag flag1 = (FeatureFlag)FEATURES.deserialize(json1).getItem(); - assertFlagHasAllProperties(flag1); + assertFlagFromJson( + flagWithAllPropertiesJson(), + flag -> { + assertFlagHasAllProperties(flag); + + String json1 = FEATURES.serialize(new ItemDescriptor(flag.getVersion(), flag)); + assertFlagFromJson(LDValue.parse(json1), flag1 -> assertFlagHasAllProperties(flag1)); + }); } @Test public void flagIsDeserializedWithMinimalProperties() { - String json = LDValue.buildObject().put("key", "flag-key").put("version", 99).build().toJsonString(); - FeatureFlag flag = (FeatureFlag)FEATURES.deserialize(json).getItem(); - assertEquals("flag-key", flag.getKey()); - assertEquals(99, flag.getVersion()); - assertFalse(flag.isOn()); - assertNull(flag.getSalt()); - assertNotNull(flag.getTargets()); - assertEquals(0, flag.getTargets().size()); - assertNotNull(flag.getRules()); - assertEquals(0, flag.getRules().size()); - assertNull(flag.getFallthrough()); - assertNull(flag.getOffVariation()); - assertNotNull(flag.getVariations()); - assertEquals(0, flag.getVariations().size()); - assertFalse(flag.isClientSide()); - assertFalse(flag.isTrackEvents()); - assertFalse(flag.isTrackEventsFallthrough()); - assertNull(flag.getDebugEventsUntilDate()); + assertFlagFromJson( + LDValue.buildObject().put("key", "flag-key").put("version", 99).build(), + flag -> { + assertEquals("flag-key", flag.getKey()); + assertEquals(99, flag.getVersion()); + assertFalse(flag.isOn()); + assertNull(flag.getSalt()); + assertNotNull(flag.getTargets()); + assertEquals(0, flag.getTargets().size()); + assertNotNull(flag.getRules()); + assertEquals(0, flag.getRules().size()); + assertNull(flag.getFallthrough()); + assertNull(flag.getOffVariation()); + assertNotNull(flag.getVariations()); + assertEquals(0, flag.getVariations().size()); + assertFalse(flag.isClientSide()); + assertFalse(flag.isTrackEvents()); + assertFalse(flag.isTrackEventsFallthrough()); + assertNull(flag.getDebugEventsUntilDate()); + }); } @Test @@ -109,6 +118,147 @@ public void deletedSegmentIsConvertedToAndFromJsonPlaceholder() { assertEquals(LDValue.parse(json0), LDValue.parse(json1)); } + @Test + public void explicitNullsAreToleratedForNullableValues() { + // Nulls are not *always* valid-- it is OK to raise a deserialization error if a null appears + // where a non-nullable primitive type like boolean is expected, so for instance "version":null + // is invalid. But for anything that is optional, an explicit null is equivalent to omitting + // the property. Note: it would be nice to use Optional for things like this, but we can't + // do it because Gson does not play well with Optional. + assertFlagFromJson( + baseBuilder("flag-key").put("offVariation", LDValue.ofNull()).build(), + flag -> assertNull(flag.getOffVariation()) + ); + assertFlagFromJson( + baseBuilder("flag-key") + .put("fallthrough", LDValue.buildObject().put("rollout", LDValue.ofNull()).build()) + .build(), + flag -> assertNull(flag.getFallthrough().getRollout()) + ); + assertFlagFromJson( + baseBuilder("flag-key") + .put("fallthrough", LDValue.buildObject().put("variation", LDValue.ofNull()).build()) + .build(), + flag -> assertNull(flag.getFallthrough().getVariation()) + ); + + // Nulls for list values should always be considered equivalent to an empty list, because + // that's how Go would serialize a nil slice + assertFlagFromJson( + baseBuilder("flag-key").put("prerequisites", LDValue.ofNull()).build(), + flag -> assertEquals(Collections.emptyList(), flag.getPrerequisites()) + ); + assertFlagFromJson( + baseBuilder("flag-key").put("rules", LDValue.ofNull()).build(), + flag -> assertEquals(Collections.emptyList(), flag.getRules()) + ); + assertFlagFromJson( + baseBuilder("flag-key").put("targets", LDValue.ofNull()).build(), + flag -> assertEquals(Collections.emptyList(), flag.getTargets()) + ); + assertFlagFromJson( + baseBuilder("flag-key") + .put("rules", LDValue.arrayOf( + LDValue.buildObject().put("clauses", LDValue.ofNull()).build() + )) + .build(), + flag -> assertEquals(Collections.emptyList(), flag.getRules().get(0).getClauses()) + ); + assertFlagFromJson( + baseBuilder("flag-key") + .put("rules", LDValue.arrayOf( + LDValue.buildObject().put("clauses", LDValue.arrayOf( + LDValue.buildObject().put("values", LDValue.ofNull()).build() + )).build() + )) + .build(), + flag -> assertEquals(Collections.emptyList(), + flag.getRules().get(0).getClauses().get(0).getValues()) + ); + assertFlagFromJson( + baseBuilder("flag-key") + .put("targets", LDValue.arrayOf( + LDValue.buildObject().put("values", LDValue.ofNull()).build() + )) + .build(), + flag -> assertEquals(Collections.emptySet(), flag.getTargets().get(0).getValues()) + ); + assertFlagFromJson( + baseBuilder("flag-key") + .put("fallthrough", LDValue.buildObject().put("rollout", + LDValue.buildObject().put("variations", LDValue.ofNull()).build() + ).build()) + .build(), + flag -> assertEquals(Collections.emptyList(), + flag.getFallthrough().getRollout().getVariations()) + ); + assertSegmentFromJson( + baseBuilder("segment-key").put("rules", LDValue.ofNull()).build(), + segment -> assertEquals(Collections.emptyList(), segment.getRules()) + ); + assertSegmentFromJson( + baseBuilder("segment-key") + .put("rules", LDValue.arrayOf( + LDValue.buildObject().put("clauses", LDValue.ofNull()).build() + )) + .build(), + segment -> assertEquals(Collections.emptyList(), segment.getRules().get(0).getClauses()) + ); + + // Nulls in clause values are not useful since the clause can never match, but they're valid JSON; + // we should normalize them to LDValue.ofNull() to avoid potential NPEs down the line + assertFlagFromJson( + baseBuilder("flag-key") + .put("rules", LDValue.arrayOf( + LDValue.buildObject() + .put("clauses", LDValue.arrayOf( + LDValue.buildObject() + .put("values", LDValue.arrayOf(LDValue.ofNull())) + .build() + )) + .build() + )) + .build(), + flag -> assertEquals(LDValue.ofNull(), + flag.getRules().get(0).getClauses().get(0).getValues().get(0)) + ); + assertSegmentFromJson( + baseBuilder("segment-key") + .put("rules", LDValue.arrayOf( + LDValue.buildObject() + .put("clauses", LDValue.arrayOf( + LDValue.buildObject() + .put("values", LDValue.arrayOf(LDValue.ofNull())) + .build() + )) + .build() + )) + .build(), + segment -> assertEquals(LDValue.ofNull(), + segment.getRules().get(0).getClauses().get(0).getValues().get(0)) + ); + + // Similarly, null for a flag variation isn't a useful value but it is valid JSON + assertFlagFromJson( + baseBuilder("flagKey").put("variations", LDValue.arrayOf(LDValue.ofNull())).build(), + flag -> assertEquals(LDValue.ofNull(), flag.getVariations().get(0)) + ); + } + + private void assertFlagFromJson(LDValue flagJson, Consumer action) { + FeatureFlag flag = (FeatureFlag)FEATURES.deserialize(flagJson.toJsonString()).getItem(); + action.accept(flag); + } + + private void assertSegmentFromJson(LDValue segmentJson, Consumer action) { + Segment segment = (Segment)SEGMENTS.deserialize(segmentJson.toJsonString()).getItem(); + action.accept(segment); + } + + private ObjectBuilder baseBuilder(String key) { + return LDValue.buildObject().put("key", key).put("version", 99); + } + private LDValue flagWithAllPropertiesJson() { return LDValue.buildObject() .put("key", "flag-key") From c5ad21bf1c748692b1fda71a169e27c3082a3872 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 17 Jun 2021 14:50:24 -0700 Subject: [PATCH 590/673] use java-sdk-common 1.2.0 --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 66502b5e3..f25f3a3dd 100644 --- a/build.gradle +++ b/build.gradle @@ -72,7 +72,7 @@ ext.versions = [ "gson": "2.7", "guava": "30.1-jre", "jackson": "2.11.2", - "launchdarklyJavaSdkCommon": "1.1.0-alpha-expalloc.2", + "launchdarklyJavaSdkCommon": "1.2.0", "okhttp": "4.8.1", // specify this for the SDK build instead of relying on the transitive dependency from okhttp-eventsource "okhttpEventsource": "2.3.1", "slf4j": "1.7.21", From 01f642f9fdccb6665909b6f308a89f1a31528552 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 17 Jun 2021 18:07:36 -0700 Subject: [PATCH 591/673] fix Jackson-related build issues (again) (#288) --- build.gradle | 68 +++++++++++++++++++--------- packaging-test/Makefile | 5 +- packaging-test/test-app/build.gradle | 7 +++ 3 files changed, 57 insertions(+), 23 deletions(-) diff --git a/build.gradle b/build.gradle index f25f3a3dd..37039b6da 100644 --- a/build.gradle +++ b/build.gradle @@ -83,6 +83,16 @@ ext.versions = [ // 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. +// +// Note that Gson is included here but Jackson is not, even though there is some Jackson +// helper code in java-sdk-common. The reason is that the SDK always needs to use Gson for +// its own usual business, so (except in the "thin" jar) we will be embedding a shaded +// copy of Gson; but we do not use Jackson normally, we just provide those helpers for use +// by applications that are already using Jackson. So we do not want to embed it and we do +// not want it to show up as a dependency at all in our pom (and it's been excluded from +// the launchdarkly-java-sdk-common pom for the same reason). However, we do include +// Jackson in "libraries.optional" because we need to generate OSGi optional import +// headers for it. libraries.internal = [ "com.launchdarkly:launchdarkly-java-sdk-common:${versions.launchdarklyJavaSdkCommon}", "commons-codec:commons-codec:${versions.commonsCodec}", @@ -96,7 +106,15 @@ libraries.internal = [ // 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 = [ - "org.slf4j:slf4j-api:${versions.slf4j}", + "org.slf4j:slf4j-api:${versions.slf4j}" +] + +// Add dependencies to "libraries.optional" that are not exposed in our public API and are +// *not* embedded in the SDK jar. These are for optional things that will only work if +// they are already in the application classpath; we do not want show them as a dependency +// because that would cause them to be pulled in automatically in all builds. The reason +// we need to even mention them here at all is for the sake of OSGi optional import headers. +libraries.optional = [ "com.fasterxml.jackson.core:jackson-core:${versions.jackson}", "com.fasterxml.jackson.core:jackson-databind:${versions.jackson}" ] @@ -114,24 +132,29 @@ libraries.test = [ "com.fasterxml.jackson.core:jackson-databind:${versions.jackson}" ] +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 + optional + imports +} + dependencies { implementation libraries.internal api libraries.external testImplementation libraries.test, libraries.internal, libraries.external + optional libraries.optional commonClasses "com.launchdarkly:launchdarkly-java-sdk-common:${versions.launchdarklyJavaSdkCommon}" commonDoc "com.launchdarkly:launchdarkly-java-sdk-common:${versions.launchdarklyJavaSdkCommon}:sources" // 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 -} + shadow libraries.external, libraries.optional -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 + imports libraries.external } checkstyle { @@ -171,7 +194,6 @@ shadowJar { dependencies { exclude(dependency('org.slf4j:.*:.*')) - exclude(dependency('com.fasterxml.jackson.core:.*:.*')) } // Kotlin metadata for shaded classes should not be included - it confuses IDEs @@ -185,7 +207,7 @@ shadowJar { 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 ], []) + addOsgiManifest(project.tasks.shadowJar, [ project.configurations.imports ], []) } doLast { @@ -208,17 +230,17 @@ task shadowJarAll(type: com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJ exclude '**/*.kotlin_builtins' dependencies { - exclude(dependency('com.fasterxml.jackson.core:.*:.*')) + // Currently we don't need to exclude anything - SLF4J will be embedded, unshaded } // 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 + // The "all" jar exposes its bundled SLF4j dependency as an export - but, like the + // default jar, it *also* imports it ("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 ]) + addOsgiManifest(project.tasks.shadowJarAll, [ project.configurations.imports ], [ project.configurations.imports ]) } doLast { @@ -370,14 +392,18 @@ def addOsgiManifest(jarTask, List importConfigs, List bundleImport(p, a.moduleVersion.id.version, nextMajorVersion(a.moduleVersion.id.version)) }) + systemPackageImports - imports += "com.google.gson;resolution:=optional" - imports += "com.google.gson.reflect;resolution:=optional" - imports += "com.google.gson.stream;resolution:=optional" + + // We also always add *optional* imports for Gson and Jackson, so that GsonTypeAdapters and + // JacksonTypeAdapters will work *if* Gson or Jackson is present externally. Currently we + // are hard-coding the Gson packages but there is probably a better way. + def optImports = [ "com.google.gson", "com.google.gson.reflect", "com.google.gson.stream" ] + forEachArtifactAndVisiblePackage([ configurations.optional ]) { a, p -> optImports += p } + imports += (optImports.join(";") + ";resolution:=optional" ) + attributes("Import-Package": imports.join(",")) // Similarly, we're adding package exports for every package in whatever libraries we're @@ -391,9 +417,7 @@ def addOsgiManifest(jarTask, List importConfigs, List Date: Thu, 24 Jun 2021 16:35:39 -0700 Subject: [PATCH 592/673] update to okhttp-eventsource patch for stream retry bug, improve tests (#289) * update to okhttp-eventsource patch for stream retry bug, improve test * add test for appropriate stream retry --- build.gradle | 2 +- .../sdk/server/LDClientEndToEndTest.java | 43 ++++++++++++++++++- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/build.gradle b/build.gradle index 37039b6da..c3f0b805c 100644 --- a/build.gradle +++ b/build.gradle @@ -74,7 +74,7 @@ ext.versions = [ "jackson": "2.11.2", "launchdarklyJavaSdkCommon": "1.2.0", "okhttp": "4.8.1", // specify this for the SDK build instead of relying on the transitive dependency from okhttp-eventsource - "okhttpEventsource": "2.3.1", + "okhttpEventsource": "2.3.2", "slf4j": "1.7.21", "snakeyaml": "1.26", "jedis": "2.9.0" diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientEndToEndTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientEndToEndTest.java index 57a188aa3..cb1dd8aea 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientEndToEndTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientEndToEndTest.java @@ -4,10 +4,14 @@ import com.google.gson.JsonObject; import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; import org.junit.Test; import java.net.URI; +import java.time.Duration; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; import static com.launchdarkly.sdk.server.Components.externalUpdatesOnly; import static com.launchdarkly.sdk.server.Components.noEvents; @@ -112,19 +116,54 @@ public void clientStartsInStreamingMode() throws Exception { } } + @Test + public void clientStartsInStreamingModeAfterRecoverableError() throws Exception { + MockResponse errorResp = new MockResponse().setResponseCode(503); + + String streamData = "event: put\n" + + "data: {\"data\":" + makeAllDataJson() + "}\n\n"; + MockResponse streamResp = TestHttpUtil.eventStreamResponse(streamData); + + try (MockWebServer server = makeStartedServer(errorResp, streamResp)) { + LDConfig config = new LDConfig.Builder() + .dataSource(baseStreamingConfig(server)) + .events(noEvents()) + .build(); + + try (LDClient client = new LDClient(sdkKey, config)) { + assertTrue(client.isInitialized()); + assertTrue(client.boolVariation(flagKey, user, false)); + } + } + } + @Test public void clientFailsInStreamingModeWith401Error() throws Exception { MockResponse resp = new MockResponse().setResponseCode(401); - try (MockWebServer server = makeStartedServer(resp)) { + try (MockWebServer server = makeStartedServer(resp, resp, resp)) { LDConfig config = new LDConfig.Builder() - .dataSource(baseStreamingConfig(server)) + .dataSource(baseStreamingConfig(server).initialReconnectDelay(Duration.ZERO)) .events(noEvents()) .build(); try (LDClient client = new LDClient(sdkKey, config)) { assertFalse(client.isInitialized()); assertFalse(client.boolVariation(flagKey, user, false)); + + BlockingQueue statuses = new LinkedBlockingQueue<>(); + client.getDataSourceStatusProvider().addStatusListener(statuses::add); + + Thread.sleep(100); // make sure it didn't retry the connection + assertThat(client.getDataSourceStatusProvider().getStatus().getState(), + equalTo(DataSourceStatusProvider.State.OFF)); + while (!statuses.isEmpty()) { + // The status listener may or may not have been registered early enough to receive + // the OFF notification, but we should at least not see any *other* statuses. + assertThat(statuses.take().getState(), equalTo(DataSourceStatusProvider.State.OFF)); + } + assertThat(statuses.isEmpty(), equalTo(true)); + assertThat(server.getRequestCount(), equalTo(1)); // no retries } } } From 4b847a560f078ad38c2cfb307be56a443472a514 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 25 Jun 2021 16:14:25 -0700 Subject: [PATCH 593/673] add public builder for FeatureFlagsState (#290) * add public builder for FeatureFlagsState * javadoc fixes --- .../sdk/server/FeatureFlagsState.java | 139 +++++++++++++----- .../com/launchdarkly/sdk/server/LDClient.java | 2 +- .../sdk/server/FeatureFlagsStateTest.java | 108 +++++++------- 3 files changed, 156 insertions(+), 93 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java b/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java index 948d83a72..b7befe5f6 100644 --- a/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java +++ b/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java @@ -1,5 +1,7 @@ package com.launchdarkly.sdk.server; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Maps; import com.google.gson.TypeAdapter; import com.google.gson.annotations.JsonAdapter; import com.google.gson.stream.JsonReader; @@ -10,7 +12,6 @@ import com.launchdarkly.sdk.server.interfaces.LDClientInterface; import java.io.IOException; -import java.util.Collections; import java.util.HashMap; import java.util.Map; import java.util.Objects; @@ -36,19 +37,20 @@ */ @JsonAdapter(FeatureFlagsState.JsonSerialization.class) public final class FeatureFlagsState implements JsonSerializable { - private final Map flagValues; - private final Map flagMetadata; + private final ImmutableMap flagMetadata; private final boolean valid; static class FlagMetadata { + final LDValue value; final Integer variation; final EvaluationReason reason; final Integer version; final Boolean trackEvents; final Long debugEventsUntilDate; - FlagMetadata(Integer variation, EvaluationReason reason, Integer version, boolean trackEvents, - Long debugEventsUntilDate) { + FlagMetadata(LDValue value, Integer variation, EvaluationReason reason, Integer version, + boolean trackEvents, Long debugEventsUntilDate) { + this.value = LDValue.normalize(value); this.variation = variation; this.reason = reason; this.version = version; @@ -60,7 +62,8 @@ static class FlagMetadata { public boolean equals(Object other) { if (other instanceof FlagMetadata) { FlagMetadata o = (FlagMetadata)other; - return Objects.equals(variation, o.variation) && + return value.equals(o.value) && + Objects.equals(variation, o.variation) && Objects.equals(reason, o.reason) && Objects.equals(version, o.version) && Objects.equals(trackEvents, o.trackEvents) && @@ -75,13 +78,27 @@ public int hashCode() { } } - private FeatureFlagsState(Map flagValues, - Map flagMetadata, boolean valid) { - this.flagValues = Collections.unmodifiableMap(flagValues); - this.flagMetadata = Collections.unmodifiableMap(flagMetadata); + private FeatureFlagsState(ImmutableMap flagMetadata, boolean valid) { + this.flagMetadata = flagMetadata; this.valid = valid; } + /** + * Returns a {@link Builder} for creating instances. + *

    + * Application code will not normally use this builder, since the SDK creates its own instances. + * However, it may be useful in testing, to simulate values that might be returned by + * {@link LDClient#allFlagsState(com.launchdarkly.sdk.LDUser, FlagsStateOption...)}. + * + * @param options the same {@link FlagsStateOption}s, if any, that would be passed to + * {@link LDClient#allFlagsState(com.launchdarkly.sdk.LDUser, FlagsStateOption...)} + * @return a builder object + * @since 5.6.0 + */ + public static Builder builder(FlagsStateOption... options) { + return new Builder(options); + } + /** * 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). @@ -98,7 +115,8 @@ public boolean isValid() { * {@code null} if there was no such flag */ public LDValue getFlagValue(String key) { - return flagValues.get(key); + FlagMetadata data = flagMetadata.get(key); + return data == null ? null : data.value; } /** @@ -115,20 +133,21 @@ public EvaluationReason getFlagReason(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. *

    + * The returned map is unmodifiable. + *

    * Do not use this method if you are passing data to the front end to "bootstrap" the JavaScript client. * 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; + return Maps.transformValues(flagMetadata, v -> v.value); } @Override public boolean equals(Object other) { if (other instanceof FeatureFlagsState) { FeatureFlagsState o = (FeatureFlagsState)other; - return flagValues.equals(o.flagValues) && - flagMetadata.equals(o.flagMetadata) && + return flagMetadata.equals(o.flagMetadata) && valid == o.valid; } return false; @@ -136,43 +155,78 @@ public boolean equals(Object other) { @Override public int hashCode() { - return Objects.hash(flagValues, flagMetadata, valid); + return Objects.hash(flagMetadata, valid); } - static class Builder { - private Map flagValues = new HashMap<>(); - private Map flagMetadata = new HashMap<>(); + /** + * A builder for a {@link FeatureFlagsState} instance. + *

    + * Application code will not normally use this builder, since the SDK creates its own instances. + * However, it may be useful in testing, to simulate values that might be returned by + * {@link LDClient#allFlagsState(com.launchdarkly.sdk.LDUser, FlagsStateOption...)}. + * + * @since 5.6.0 + */ + public static class Builder { + private ImmutableMap.Builder flagMetadata = ImmutableMap.builder(); private final boolean saveReasons; private final boolean detailsOnlyForTrackedFlags; private boolean valid = true; - Builder(FlagsStateOption... options) { + private Builder(FlagsStateOption... options) { saveReasons = FlagsStateOption.hasOption(options, FlagsStateOption.WITH_REASONS); detailsOnlyForTrackedFlags = FlagsStateOption.hasOption(options, FlagsStateOption.DETAILS_ONLY_FOR_TRACKED_FLAGS); } - Builder valid(boolean valid) { + /** + * Sets the {@link FeatureFlagsState#isValid()} property. This is true by default. + * + * @param valid the new property value + * @return the builder + */ + public Builder valid(boolean valid) { this.valid = valid; return this; } - Builder addFlag(DataModel.FeatureFlag flag, Evaluator.EvalResult eval) { - flagValues.put(flag.getKey(), eval.getValue()); - final boolean flagIsTracked = flag.isTrackEvents() || - (flag.getDebugEventsUntilDate() != null && flag.getDebugEventsUntilDate() > System.currentTimeMillis()); + public Builder add( + String flagKey, + LDValue value, + Integer variationIndex, + EvaluationReason reason, + int flagVersion, + boolean trackEvents, + Long debugEventsUntilDate + ) { + final boolean flagIsTracked = trackEvents || + (debugEventsUntilDate != null && debugEventsUntilDate > System.currentTimeMillis()); final boolean wantDetails = !detailsOnlyForTrackedFlags || flagIsTracked; FlagMetadata data = new FlagMetadata( + value, + variationIndex, + (saveReasons && wantDetails) ? reason : null, + wantDetails ? Integer.valueOf(flagVersion) : null, + trackEvents, + debugEventsUntilDate + ); + flagMetadata.put(flagKey, data); + return this; + } + + Builder addFlag(DataModel.FeatureFlag flag, Evaluator.EvalResult eval) { + return add( + flag.getKey(), + eval.getValue(), eval.isDefault() ? null : eval.getVariationIndex(), - (saveReasons && wantDetails) ? eval.getReason() : null, - wantDetails ? flag.getVersion() : null, + eval.getReason(), + flag.getVersion(), flag.isTrackEvents(), - flag.getDebugEventsUntilDate()); - flagMetadata.put(flag.getKey(), data); - return this; + flag.getDebugEventsUntilDate() + ); } FeatureFlagsState build() { - return new FeatureFlagsState(flagValues, flagMetadata, valid); + return new FeatureFlagsState(flagMetadata.build(), valid); } } @@ -181,9 +235,9 @@ static class JsonSerialization extends TypeAdapter { public void write(JsonWriter out, FeatureFlagsState state) throws IOException { out.beginObject(); - for (Map.Entry entry: state.flagValues.entrySet()) { + for (Map.Entry entry: state.flagMetadata.entrySet()) { out.name(entry.getKey()); - gsonInstance().toJson(entry.getValue(), LDValue.class, out); + gsonInstance().toJson(entry.getValue().value, LDValue.class, out); } out.name("$flagsState"); @@ -229,7 +283,7 @@ public void write(JsonWriter out, FeatureFlagsState state) throws IOException { @Override public FeatureFlagsState read(JsonReader in) throws IOException { Map flagValues = new HashMap<>(); - Map flagMetadata = new HashMap<>(); + Map flagMetadataWithoutValues = new HashMap<>(); boolean valid = true; in.beginObject(); while (in.hasNext()) { @@ -239,7 +293,7 @@ public FeatureFlagsState read(JsonReader in) throws IOException { while (in.hasNext()) { String metaName = in.nextName(); FlagMetadata meta = gsonInstance().fromJson(in, FlagMetadata.class); - flagMetadata.put(metaName, meta); + flagMetadataWithoutValues.put(metaName, meta); } in.endObject(); } else if (name.equals("$valid")) { @@ -250,7 +304,22 @@ public FeatureFlagsState read(JsonReader in) throws IOException { } } in.endObject(); - return new FeatureFlagsState(flagValues, flagMetadata, valid); + ImmutableMap.Builder allFlagMetadata = ImmutableMap.builder(); + for (Map.Entry e: flagValues.entrySet()) { + FlagMetadata m0 = flagMetadataWithoutValues.get(e.getKey()); + if (m0 != null) { + FlagMetadata m1 = new FlagMetadata( + e.getValue(), + m0.variation, + m0.reason, + m0.version, + m0.trackEvents != null && m0.trackEvents.booleanValue(), + m0.debugEventsUntilDate + ); + allFlagMetadata.put(e.getKey(), m1); + } + } + return new FeatureFlagsState(allFlagMetadata.build(), valid); } } } diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClient.java b/src/main/java/com/launchdarkly/sdk/server/LDClient.java index edef1b31e..2b51c3027 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClient.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClient.java @@ -268,7 +268,7 @@ private void sendFlagRequestEvent(Event.FeatureRequest event) { @Override public FeatureFlagsState allFlagsState(LDUser user, FlagsStateOption... options) { - FeatureFlagsState.Builder builder = new FeatureFlagsState.Builder(options); + FeatureFlagsState.Builder builder = FeatureFlagsState.builder(options); if (isOffline()) { Loggers.EVALUATION.debug("allFlagsState() was called when client is in offline mode."); diff --git a/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java b/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java index 88657ebc7..2a0828884 100644 --- a/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java @@ -29,85 +29,78 @@ public class FeatureFlagsStateTest { @Test public void canGetFlagValue() { - Evaluator.EvalResult eval = new Evaluator.EvalResult(LDValue.of("value"), 1, EvaluationReason.off()); - DataModel.FeatureFlag flag = flagBuilder("key").build(); - FeatureFlagsState state = new FeatureFlagsState.Builder().addFlag(flag, eval).build(); + FeatureFlagsState state = FeatureFlagsState.builder() + .add("key", LDValue.of("value"), 1, null, 10, false, null) + .build(); assertEquals(LDValue.of("value"), state.getFlagValue("key")); } @Test public void unknownFlagReturnsNullValue() { - FeatureFlagsState state = new FeatureFlagsState.Builder().build(); + FeatureFlagsState state = FeatureFlagsState.builder().build(); assertNull(state.getFlagValue("key")); } @Test public void canGetFlagReason() { - Evaluator.EvalResult eval = new Evaluator.EvalResult(LDValue.of("value1"), 1, EvaluationReason.off()); - DataModel.FeatureFlag flag = flagBuilder("key").build(); - FeatureFlagsState state = new FeatureFlagsState.Builder(FlagsStateOption.WITH_REASONS) - .addFlag(flag, eval).build(); + FeatureFlagsState state = FeatureFlagsState.builder(WITH_REASONS) + .add("key", LDValue.of("value"), 1, EvaluationReason.off(), 10, false, null) + .build(); assertEquals(EvaluationReason.off(), state.getFlagReason("key")); } @Test public void unknownFlagReturnsNullReason() { - FeatureFlagsState state = new FeatureFlagsState.Builder().build(); + FeatureFlagsState state = FeatureFlagsState.builder().build(); assertNull(state.getFlagReason("key")); } @Test public void reasonIsNullIfReasonsWereNotRecorded() { - Evaluator.EvalResult eval = new Evaluator.EvalResult(LDValue.of("value1"), 1, EvaluationReason.off()); - DataModel.FeatureFlag flag = flagBuilder("key").build(); - FeatureFlagsState state = new FeatureFlagsState.Builder().addFlag(flag, eval).build(); + FeatureFlagsState state = FeatureFlagsState.builder() + .add("key", LDValue.of("value"), 1, EvaluationReason.off(), 10, false, null) + .build(); assertNull(state.getFlagReason("key")); } @Test public void flagIsTreatedAsTrackedIfDebugEventsUntilDateIsInFuture() { - Evaluator.EvalResult eval = new Evaluator.EvalResult(LDValue.of("value1"), 1, EvaluationReason.off()); - DataModel.FeatureFlag flag = flagBuilder("key").debugEventsUntilDate(System.currentTimeMillis() + 1000000).build(); - FeatureFlagsState state = new FeatureFlagsState.Builder( - FlagsStateOption.WITH_REASONS, DETAILS_ONLY_FOR_TRACKED_FLAGS - ).addFlag(flag, eval).build(); + FeatureFlagsState state = FeatureFlagsState.builder(WITH_REASONS, DETAILS_ONLY_FOR_TRACKED_FLAGS) + .add("key", LDValue.of("value"), 1, EvaluationReason.off(), 10, false, System.currentTimeMillis() + 1000000) + .build(); assertNotNull(state.getFlagReason("key")); } @Test public void flagIsNotTreatedAsTrackedIfDebugEventsUntilDateIsInPast() { - Evaluator.EvalResult eval = new Evaluator.EvalResult(LDValue.of("value1"), 1, EvaluationReason.off()); - DataModel.FeatureFlag flag = flagBuilder("key").debugEventsUntilDate(System.currentTimeMillis() - 1000000).build(); - FeatureFlagsState state = new FeatureFlagsState.Builder( - FlagsStateOption.WITH_REASONS, DETAILS_ONLY_FOR_TRACKED_FLAGS - ).addFlag(flag, eval).build(); + FeatureFlagsState state = FeatureFlagsState.builder(WITH_REASONS, DETAILS_ONLY_FOR_TRACKED_FLAGS) + .add("key", LDValue.of("value"), 1, EvaluationReason.off(), 10, false, System.currentTimeMillis() - 1000000) + .build(); assertNull(state.getFlagReason("key")); } @Test public void flagCanHaveNullValue() { - Evaluator.EvalResult eval = new Evaluator.EvalResult(LDValue.ofNull(), 1, null); - DataModel.FeatureFlag flag = flagBuilder("key").build(); - FeatureFlagsState state = new FeatureFlagsState.Builder().addFlag(flag, eval).build(); + FeatureFlagsState state = FeatureFlagsState.builder() + .add("key", LDValue.ofNull(), 1, null, 10, false, null) + .build(); assertEquals(LDValue.ofNull(), state.getFlagValue("key")); } @Test public void canConvertToValuesMap() { - Evaluator.EvalResult eval1 = new Evaluator.EvalResult(LDValue.of("value1"), 0, EvaluationReason.off()); - DataModel.FeatureFlag flag1 = flagBuilder("key1").build(); - Evaluator.EvalResult eval2 = new Evaluator.EvalResult(LDValue.of("value2"), 1, EvaluationReason.fallthrough()); - DataModel.FeatureFlag flag2 = flagBuilder("key2").build(); - FeatureFlagsState state = new FeatureFlagsState.Builder() - .addFlag(flag1, eval1).addFlag(flag2, eval2).build(); + FeatureFlagsState state = FeatureFlagsState.builder() + .add("key1", LDValue.of("value1"), 0, null, 10, false, null) + .add("key2", LDValue.of("value2"), 1, null, 10, false, null) + .build(); ImmutableMap expected = ImmutableMap.of("key1", LDValue.of("value1"), "key2", LDValue.of("value2")); assertEquals(expected, state.toValuesMap()); @@ -115,24 +108,23 @@ public void canConvertToValuesMap() { @Test public void equalInstancesAreEqual() { - DataModel.FeatureFlag flag1 = flagBuilder("key1").build(); - DataModel.FeatureFlag flag2 = flagBuilder("key2").build(); - Evaluator.EvalResult eval1a = new Evaluator.EvalResult(LDValue.of("value1"), 0, EvaluationReason.off()); - Evaluator.EvalResult eval1b = new Evaluator.EvalResult(LDValue.of("value1"), 0, EvaluationReason.off()); - Evaluator.EvalResult eval2a = new Evaluator.EvalResult(LDValue.of("value2"), 1, EvaluationReason.fallthrough()); - Evaluator.EvalResult eval2b = new Evaluator.EvalResult(LDValue.of("value2"), 1, EvaluationReason.fallthrough()); - Evaluator.EvalResult eval3a = new Evaluator.EvalResult(LDValue.of("value1"), 1, EvaluationReason.off()); - - FeatureFlagsState justOneFlag = new FeatureFlagsState.Builder() - .addFlag(flag1, eval1a).build(); - FeatureFlagsState sameFlagsDifferentInstances1 = new FeatureFlagsState.Builder(WITH_REASONS) - .addFlag(flag1, eval1a).addFlag(flag2, eval2a).build(); - FeatureFlagsState sameFlagsDifferentInstances2 = new FeatureFlagsState.Builder(WITH_REASONS) - .addFlag(flag2, eval2b).addFlag(flag1, eval1b).build(); - FeatureFlagsState sameFlagsDifferentMetadata = new FeatureFlagsState.Builder(WITH_REASONS) - .addFlag(flag1, eval3a).addFlag(flag2, eval2a).build(); - FeatureFlagsState noFlagsButValid = new FeatureFlagsState.Builder(WITH_REASONS).build(); - FeatureFlagsState noFlagsAndNotValid = new FeatureFlagsState.Builder(WITH_REASONS).valid(false).build(); + FeatureFlagsState justOneFlag = FeatureFlagsState.builder(WITH_REASONS) + .add("key1", LDValue.of("value1"), 0, EvaluationReason.off(), 10, false, null) + .build(); + FeatureFlagsState sameFlagsDifferentInstances1 = FeatureFlagsState.builder(WITH_REASONS) + .add("key1", LDValue.of("value1"), 0, EvaluationReason.off(), 10, false, null) + .add("key2", LDValue.of("value2"), 1, EvaluationReason.fallthrough(), 10, false, null) + .build(); + FeatureFlagsState sameFlagsDifferentInstances2 = FeatureFlagsState.builder(WITH_REASONS) + .add("key1", LDValue.of("value1"), 0, EvaluationReason.off(), 10, false, null) + .add("key2", LDValue.of("value2"), 1, EvaluationReason.fallthrough(), 10, false, null) + .build(); + FeatureFlagsState sameFlagsDifferentMetadata = FeatureFlagsState.builder(WITH_REASONS) + .add("key1", LDValue.of("value1"), 1, EvaluationReason.off(), 10, false, null) + .add("key2", LDValue.of("value2"), 1, EvaluationReason.fallthrough(), 10, false, null) + .build(); + FeatureFlagsState noFlagsButValid = FeatureFlagsState.builder(WITH_REASONS).build(); + FeatureFlagsState noFlagsAndNotValid = FeatureFlagsState.builder(WITH_REASONS).valid(false).build(); assertEquals(sameFlagsDifferentInstances1, sameFlagsDifferentInstances2); assertEquals(sameFlagsDifferentInstances1.hashCode(), sameFlagsDifferentInstances2.hashCode()); @@ -148,13 +140,15 @@ public void equalMetadataInstancesAreEqual() { // Testing this various cases is easier at a low level - equalInstancesAreEqual() above already // verifies that we test for metadata equality in general List> allPermutations = new ArrayList<>(); - for (Integer variation: new Integer[] { null, 0, 1 }) { - for (EvaluationReason reason: new EvaluationReason[] { null, EvaluationReason.off(), EvaluationReason.fallthrough() }) { - for (Integer version: new Integer[] { null, 10, 11 }) { - for (boolean trackEvents: new boolean[] { false, true }) { - for (Long debugEventsUntilDate: new Long[] { null, 1000L, 1001L }) { - allPermutations.add(() -> new FeatureFlagsState.FlagMetadata( - variation, reason, version, trackEvents, debugEventsUntilDate)); + for (LDValue value: new LDValue[] { LDValue.of(1), LDValue.of(2) }) { + for (Integer variation: new Integer[] { null, 0, 1 }) { + for (EvaluationReason reason: new EvaluationReason[] { null, EvaluationReason.off(), EvaluationReason.fallthrough() }) { + for (Integer version: new Integer[] { null, 10, 11 }) { + for (boolean trackEvents: new boolean[] { false, true }) { + for (Long debugEventsUntilDate: new Long[] { null, 1000L, 1001L }) { + allPermutations.add(() -> new FeatureFlagsState.FlagMetadata( + value, variation, reason, version, trackEvents, debugEventsUntilDate)); + } } } } @@ -189,7 +183,7 @@ private static FeatureFlagsState makeInstanceForSerialization() { DataModel.FeatureFlag flag2 = flagBuilder("key2").version(200).trackEvents(true).debugEventsUntilDate(1000L).build(); Evaluator.EvalResult eval3 = new Evaluator.EvalResult(LDValue.of("default"), NO_VARIATION, EvaluationReason.error(MALFORMED_FLAG)); DataModel.FeatureFlag flag3 = flagBuilder("key3").version(300).build(); - return new FeatureFlagsState.Builder(FlagsStateOption.WITH_REASONS) + return FeatureFlagsState.builder(FlagsStateOption.WITH_REASONS) .addFlag(flag1, eval1).addFlag(flag2, eval2).addFlag(flag3, eval3).build(); } From b66328d962e11b3213e3851fd27fcdb62f492653 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 25 Jun 2021 16:35:34 -0700 Subject: [PATCH 594/673] clarify FileData doc comment to say you shouldn't use offline mode (#291) --- .../launchdarkly/sdk/server/integrations/FileData.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/FileData.java b/src/main/java/com/launchdarkly/sdk/server/integrations/FileData.java index 57f4357dc..630b53c26 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/FileData.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/FileData.java @@ -1,5 +1,7 @@ package com.launchdarkly.sdk.server.integrations; +import com.launchdarkly.sdk.server.LDConfig; + /** * Integration between the LaunchDarkly SDK and file data. *

    @@ -56,8 +58,10 @@ public enum DuplicateKeysHandling { *

    * 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.sdk.server.Components#noEvents()} or - * {@link com.launchdarkly.sdk.server.LDConfig.Builder#offline(boolean)}. + * this with {@link com.launchdarkly.sdk.server.Components#noEvents()}. IMPORTANT: Do not + * set {@link LDConfig.Builder#offline(boolean)} to {@code true}; doing so would not just put the + * SDK "offline" with regard to LaunchDarkly, but will completely turn off all flag data sources + * to the SDK including the file data source. *

    * Flag data files can be either JSON or YAML. They contain an object with three possible * properties: From 04b9db7d70c635d911a47bde2e39133bf1beb764 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 2 Jul 2021 12:36:57 -0700 Subject: [PATCH 595/673] improve validation of SDK key so we won't throw an exception that contains the key (#293) --- .../com/launchdarkly/sdk/server/LDClient.java | 26 +++++++++++++++++ .../com/launchdarkly/sdk/server/Util.java | 19 ++++++++++++ .../launchdarkly/sdk/server/LDClientTest.java | 29 +++++++++++++++++++ 3 files changed, 74 insertions(+) diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClient.java b/src/main/java/com/launchdarkly/sdk/server/LDClient.java index 2b51c3027..578f789a5 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClient.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClient.java @@ -41,6 +41,7 @@ import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; import static com.launchdarkly.sdk.server.DataModel.FEATURES; import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; +import static com.launchdarkly.sdk.server.Util.isAsciiHeaderValue; /** * A client for the LaunchDarkly API. Client instances are thread-safe. Applications should instantiate @@ -82,8 +83,15 @@ public final class LDClient implements LDClientInterface { * values; it will still continue trying to connect in the background. You can detect whether * initialization has succeeded by calling {@link #isInitialized()}. If you prefer to customize * this behavior, use {@link LDClient#LDClient(String, LDConfig)} instead. + *

    + * For rules regarding the throwing of unchecked exceptions for error conditions, see + * {@link LDClient#LDClient(String, LDConfig)}. * * @param sdkKey the SDK key for your LaunchDarkly environment + * @throws IllegalArgumentException if a parameter contained a grossly malformed value; + * for security reasons, in case of an illegal SDK key, the exception message does + * not include the key + * @throws NullPointerException if a non-nullable parameter was null * @see LDClient#LDClient(String, LDConfig) */ public LDClient(String sdkKey) { @@ -136,14 +144,32 @@ private static final DataModel.Segment getSegment(DataStore store, String key) { * // do whatever is appropriate if initialization has timed out * } * + *

    + * This constructor can throw unchecked exceptions if it is immediately apparent that + * the SDK cannot work with these parameters. For instance, if the SDK key contains a + * non-printable character that cannot be used in an HTTP header, it will throw an + * {@link IllegalArgumentException} since the SDK key is normally sent to LaunchDarkly + * in an HTTP header and no such value could possibly be valid. Similarly, a null + * value for a non-nullable parameter may throw a {@link NullPointerException}. The + * constructor will not throw an exception for any error condition that could only be + * detected after making a request to LaunchDarkly (such as an SDK key that is simply + * wrong despite being valid ASCII, so it is invalid but not illegal); those are logged + * and treated as an unsuccessful initialization, as described above. * * @param sdkKey the SDK key for your LaunchDarkly environment * @param config a client configuration object + * @throws IllegalArgumentException if a parameter contained a grossly malformed value; + * for security reasons, in case of an illegal SDK key, the exception message does + * not include the key + * @throws NullPointerException if a non-nullable parameter was null * @see LDClient#LDClient(String, LDConfig) */ public LDClient(String sdkKey, LDConfig config) { checkNotNull(config, "config must not be null"); this.sdkKey = checkNotNull(sdkKey, "sdkKey must not be null"); + if (!isAsciiHeaderValue(sdkKey) ) { + throw new IllegalArgumentException("SDK key contained an invalid character"); + } this.offline = config.offline; this.sharedExecutor = createSharedExecutor(config); diff --git a/src/main/java/com/launchdarkly/sdk/server/Util.java b/src/main/java/com/launchdarkly/sdk/server/Util.java index 1827080e5..fc6a4cc02 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Util.java +++ b/src/main/java/com/launchdarkly/sdk/server/Util.java @@ -37,6 +37,25 @@ static Headers.Builder getHeadersBuilderFor(HttpConfiguration config) { return builder; } + // This is specifically testing whether the string would be considered a valid HTTP header value + // *by the OkHttp client*. The actual HTTP spec does not prohibit characters >= 127; OkHttp's + // check is overly strict, as was pointed out in https://github.com/square/okhttp/issues/2016. + // But all OkHttp 3.x and 4.x versions so far have continued to enforce that check. Control + // characters other than a tab are always illegal. + // + // The value we're mainly concerned with is the SDK key (Authorization header). If an SDK key + // accidentally has (for instance) a newline added to it, we don't want to end up having OkHttp + // throw an exception mentioning the value, which might get logged (https://github.com/square/okhttp/issues/6738). + static boolean isAsciiHeaderValue(String value) { + for (int i = 0; i < value.length(); i++) { + char ch = value.charAt(i); + if ((ch < 0x20 || ch > 0x7e) && ch != '\t') { + return false; + } + } + return true; + } + static void configureHttpClientBuilder(HttpConfiguration config, OkHttpClient.Builder builder) { builder.connectionPool(new ConnectionPool(5, 5, TimeUnit.SECONDS)) .connectTimeout(config.getConnectTimeout()) diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java index 85efc339a..d16083f9f 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java @@ -37,6 +37,9 @@ import static org.easymock.EasyMock.capture; import static org.easymock.EasyMock.expect; import static org.easymock.EasyMock.isA; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; @@ -83,6 +86,32 @@ public void constructorWithConfigThrowsExceptionForNullSdkKey() throws Exception } } + @Test + public void constructorThrowsExceptionForSdkKeyWithControlCharacter() throws Exception { + try (LDClient client = new LDClient(SDK_KEY + "\n")) { + fail("expected exception"); + } catch (IllegalArgumentException e) { + assertThat(e.getMessage(), not(containsString(SDK_KEY))); + } + } + + @Test + public void constructorWithConfigThrowsExceptionForSdkKeyWithControlCharacter() throws Exception { + try (LDClient client = new LDClient(SDK_KEY + "\n", LDConfig.DEFAULT)) { + fail("expected exception"); + } catch (IllegalArgumentException e) { + assertThat(e.getMessage(), not(containsString(SDK_KEY))); + } + } + + @Test + public void constructorAllowsSdkKeyToBeEmpty() throws Exception { + // It may seem counter-intuitive to allow this, but if someone is using the SDK in offline + // mode, or with a file data source or a test fixture, they may reasonably assume that it's + // OK to pass an empty string since the key won't actually be used. + try (LDClient client = new LDClient(SDK_KEY + "")) {} + } + @Test public void constructorThrowsExceptionForNullConfig() throws Exception { try (LDClient client = new LDClient(SDK_KEY, null)) { From 721f757f7fcc5865791cd7753d2846d36600f147 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 2 Jul 2021 13:37:10 -0700 Subject: [PATCH 596/673] fix javadoc link in FileData comment (#294) --- .../launchdarkly/sdk/server/integrations/FileData.java | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/FileData.java b/src/main/java/com/launchdarkly/sdk/server/integrations/FileData.java index 630b53c26..1c7c60fb1 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/FileData.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/FileData.java @@ -1,7 +1,5 @@ package com.launchdarkly.sdk.server.integrations; -import com.launchdarkly.sdk.server.LDConfig; - /** * Integration between the LaunchDarkly SDK and file data. *

    @@ -59,9 +57,9 @@ public enum DuplicateKeysHandling { * 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.sdk.server.Components#noEvents()}. IMPORTANT: Do not - * set {@link LDConfig.Builder#offline(boolean)} to {@code true}; doing so would not just put the - * SDK "offline" with regard to LaunchDarkly, but will completely turn off all flag data sources - * to the SDK including the file data source. + * set {@link com.launchdarkly.sdk.server.LDConfig.Builder#offline(boolean)} to {@code true}; doing so + * would not just put the SDK "offline" with regard to LaunchDarkly, but will completely turn off + * all flag data sources to the SDK including the file data source. *

    * Flag data files can be either JSON or YAML. They contain an object with three possible * properties: From 2e1b2107c9595e276cc3461e8d9c8bf7135ae1de Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 2 Jul 2021 14:06:38 -0700 Subject: [PATCH 597/673] fix PollingProcessor 401 behavior and use new HTTP test helpers (#292) --- build.gradle | 6 +- .../launchdarkly/sdk/server/Components.java | 5 + .../sdk/server/ComponentsImpl.java | 8 +- .../sdk/server/PollingProcessor.java | 4 + .../sdk/server/StreamProcessor.java | 75 +- .../sdk/server/DataStoreTestTypes.java | 25 + .../sdk/server/DefaultEventSenderTest.java | 246 ++--- .../server/DefaultFeatureRequestorTest.java | 175 ++-- .../sdk/server/LDClientEndToEndTest.java | 251 +++-- .../sdk/server/PollingProcessorTest.java | 432 ++++---- .../sdk/server/StreamProcessorTest.java | 961 ++++++++---------- .../sdk/server/TestComponents.java | 111 +- .../launchdarkly/sdk/server/TestHttpUtil.java | 209 ++-- .../com/launchdarkly/sdk/server/TestUtil.java | 26 + src/test/resources/logback.xml | 3 + 15 files changed, 1247 insertions(+), 1290 deletions(-) diff --git a/build.gradle b/build.gradle index c3f0b805c..541c54980 100644 --- a/build.gradle +++ b/build.gradle @@ -121,15 +121,13 @@ libraries.optional = [ // Add dependencies to "libraries.test" that are used only in unit tests. libraries.test = [ - // Note that the okhttp3 test deps must be kept in sync with the okhttp version used in okhttp-eventsource - "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", "ch.qos.logback:logback-classic:1.1.7", "com.fasterxml.jackson.core:jackson-core:${versions.jackson}", - "com.fasterxml.jackson.core:jackson-databind:${versions.jackson}" + "com.fasterxml.jackson.core:jackson-databind:${versions.jackson}", + "com.launchdarkly:test-helpers:1.0.0" ] configurations { diff --git a/src/main/java/com/launchdarkly/sdk/server/Components.java b/src/main/java/com/launchdarkly/sdk/server/Components.java index e2a4092f8..0b2694fff 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Components.java +++ b/src/main/java/com/launchdarkly/sdk/server/Components.java @@ -187,6 +187,11 @@ public static PollingDataSourceBuilder pollingDataSource() { return new PollingDataSourceBuilderImpl(); } + // For testing only - allows us to override the minimum polling interval + static PollingDataSourceBuilderImpl pollingDataSourceInternal() { + return new PollingDataSourceBuilderImpl(); + } + /** * Returns a configuration object that disables a direct connection with LaunchDarkly for feature flag updates. *

    diff --git a/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java b/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java index 370f1a1e3..33ddaa4fb 100644 --- a/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java @@ -33,6 +33,7 @@ import java.net.InetSocketAddress; import java.net.Proxy; import java.net.URI; +import java.time.Duration; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Future; @@ -143,7 +144,6 @@ public DataSource createDataSource(ClientContext context, DataSourceUpdates data return new StreamProcessor( context.getHttp(), dataSourceUpdates, - null, context.getBasic().getThreadPriority(), ClientContextImpl.get(context).diagnosticAccumulator, streamUri, @@ -165,6 +165,12 @@ public LDValue describeConfiguration(BasicConfiguration basicConfiguration) { } static final class PollingDataSourceBuilderImpl extends PollingDataSourceBuilder implements DiagnosticDescription { + // for testing only + PollingDataSourceBuilderImpl pollIntervalWithNoMinimum(Duration pollInterval) { + this.pollInterval = pollInterval; + return this; + } + @Override public DataSource createDataSource(ClientContext context, DataSourceUpdates dataSourceUpdates) { // Note, we log startup messages under the LDClient class to keep logs more readable diff --git a/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java b/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java index 45f49cb09..31ad32a39 100644 --- a/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java @@ -111,6 +111,10 @@ private void poll() { } else { dataSourceUpdates.updateStatus(State.OFF, errorInfo); initFuture.complete(null); // if client is initializing, make it stop waiting; has no effect if already inited + if (task != null) { + task.cancel(true); + task = null; + } } } catch (IOException e) { checkIfErrorIsRecoverableAndLog(logger, e.toString(), ERROR_CONTEXT_MESSAGE, 0, WILL_RETRY_MESSAGE); diff --git a/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java index 36c4e6cab..b0758eac4 100644 --- a/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java @@ -84,7 +84,6 @@ final class StreamProcessor implements DataSource { @VisibleForTesting final URI streamUri; @VisibleForTesting final Duration initialReconnectDelay; private final DiagnosticAccumulator diagnosticAccumulator; - private final EventSourceCreator eventSourceCreator; private final int threadPriority; private final DataStoreStatusProvider.StatusListener statusListener; private volatile EventSource es; @@ -94,34 +93,9 @@ final class StreamProcessor implements DataSource { ConnectionErrorHandler connectionErrorHandler = createDefaultConnectionErrorHandler(); // exposed for testing - static final class EventSourceParams { - final EventHandler handler; - final URI streamUri; - final Duration initialReconnectDelay; - final ConnectionErrorHandler errorHandler; - final Headers headers; - final HttpConfiguration httpConfig; - - EventSourceParams(EventHandler handler, URI streamUri, Duration initialReconnectDelay, - ConnectionErrorHandler errorHandler, Headers headers, HttpConfiguration httpConfig) { - this.handler = handler; - this.streamUri = streamUri; - this.initialReconnectDelay = initialReconnectDelay; - this.errorHandler = errorHandler; - this.headers = headers; - this.httpConfig = httpConfig; - } - } - - @FunctionalInterface - static interface EventSourceCreator { - EventSource createEventSource(EventSourceParams params); - } - StreamProcessor( HttpConfiguration httpConfig, DataSourceUpdates dataSourceUpdates, - EventSourceCreator eventSourceCreator, int threadPriority, DiagnosticAccumulator diagnosticAccumulator, URI streamUri, @@ -130,7 +104,6 @@ static interface EventSourceCreator { this.dataSourceUpdates = dataSourceUpdates; this.httpConfig = httpConfig; this.diagnosticAccumulator = diagnosticAccumulator; - this.eventSourceCreator = eventSourceCreator != null ? eventSourceCreator : this::defaultEventSourceCreator; this.threadPriority = threadPriority; this.streamUri = streamUri; this.initialReconnectDelay = initialReconnectDelay; @@ -202,13 +175,26 @@ public Future start() { }; EventHandler handler = new StreamEventHandler(initFuture); - - es = eventSourceCreator.createEventSource(new EventSourceParams(handler, - concatenateUriPath(streamUri, STREAM_URI_PATH), - initialReconnectDelay, - wrappedConnectionErrorHandler, - headers, - httpConfig)); + URI endpointUri = concatenateUriPath(streamUri, STREAM_URI_PATH); + + EventSource.Builder builder = new EventSource.Builder(handler, endpointUri) + .threadPriority(threadPriority) + .loggerBaseName(Loggers.DATA_SOURCE_LOGGER_NAME) + .clientBuilderActions(new EventSource.Builder.ClientConfigurer() { + public void configure(OkHttpClient.Builder builder) { + configureHttpClientBuilder(httpConfig, builder); + } + }) + .connectionErrorHandler(wrappedConnectionErrorHandler) + .headers(headers) + .reconnectTime(initialReconnectDelay) + .readTimeout(DEAD_CONNECTION_INTERVAL); + // Note that this is not the same read timeout that can be set in LDConfig. We default to a smaller one + // there because we don't expect long delays within any *non*-streaming response that the LD client gets. + // A read timeout on the stream will result in the connection being cycled, so we set this to be slightly + // more than the expected interval between heartbeat signals. + + es = builder.build(); esStarted = System.currentTimeMillis(); es.start(); return initFuture; @@ -356,27 +342,6 @@ public void onError(Throwable throwable) { } } - private EventSource defaultEventSourceCreator(EventSourceParams params) { - EventSource.Builder builder = new EventSource.Builder(params.handler, params.streamUri) - .threadPriority(threadPriority) - .loggerBaseName(Loggers.DATA_SOURCE_LOGGER_NAME) - .clientBuilderActions(new EventSource.Builder.ClientConfigurer() { - public void configure(OkHttpClient.Builder builder) { - configureHttpClientBuilder(params.httpConfig, builder); - } - }) - .connectionErrorHandler(params.errorHandler) - .headers(params.headers) - .reconnectTime(params.initialReconnectDelay) - .readTimeout(DEAD_CONNECTION_INTERVAL); - // Note that this is not the same read timeout that can be set in LDConfig. We default to a smaller one - // there because we don't expect long delays within any *non*-streaming response that the LD client gets. - // A read timeout on the stream will result in the connection being cycled, so we set this to be slightly - // more than the expected interval between heartbeat signals. - - return builder.build(); - } - private static Map.Entry getKindAndKeyFromStreamApiPath(String path) throws StreamInputException { if (path == null) { throw new StreamInputException("missing item path"); diff --git a/src/test/java/com/launchdarkly/sdk/server/DataStoreTestTypes.java b/src/test/java/com/launchdarkly/sdk/server/DataStoreTestTypes.java index 5d728d018..4a4a28595 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataStoreTestTypes.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataStoreTestTypes.java @@ -3,6 +3,8 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.Maps; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.ObjectBuilder; import com.launchdarkly.sdk.server.DataModel.VersionedData; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; @@ -137,6 +139,15 @@ private static ItemDescriptor deserializeTestItem(String s) { public static class DataBuilder { private Map> data = new HashMap<>(); + public static DataBuilder forStandardTypes() { + // This just ensures that we use realistic-looking data sets in our tests when simulating + // an LD service response, which will always include "flags" and "segments" even if empty. + DataBuilder ret = new DataBuilder(); + ret.add(DataModel.FEATURES); + ret.add(DataModel.SEGMENTS); + return ret; + } + public DataBuilder add(DataKind kind, TestItem... items) { return addAny(kind, items); } @@ -182,5 +193,19 @@ public FullDataSet buildSerialized() { ) ).entrySet()); } + + public LDValue buildJson() { + FullDataSet allData = buildSerialized(); + ObjectBuilder allBuilder = LDValue.buildObject(); + for (Map.Entry> coll: allData.getData()) { + String namespace = coll.getKey().getName().equals("features") ? "flags" : coll.getKey().getName(); + ObjectBuilder itemsBuilder = LDValue.buildObject(); + for (Map.Entry item: coll.getValue().getItems()) { + itemsBuilder.put(item.getKey(), LDValue.parse(item.getValue().getSerializedItem())); + } + allBuilder.put(namespace, itemsBuilder.build()); + } + return allBuilder.build(); + } } } diff --git a/src/test/java/com/launchdarkly/sdk/server/DefaultEventSenderTest.java b/src/test/java/com/launchdarkly/sdk/server/DefaultEventSenderTest.java index b67a3ce40..a24d1d002 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DefaultEventSenderTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultEventSenderTest.java @@ -4,6 +4,10 @@ import com.launchdarkly.sdk.server.interfaces.EventSender; import com.launchdarkly.sdk.server.interfaces.EventSenderFactory; import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; +import com.launchdarkly.testhelpers.httptest.Handler; +import com.launchdarkly.testhelpers.httptest.Handlers; +import com.launchdarkly.testhelpers.httptest.HttpServer; +import com.launchdarkly.testhelpers.httptest.RequestInfo; import org.junit.Test; @@ -13,31 +17,22 @@ import java.util.Date; import java.util.Map; import java.util.UUID; -import java.util.concurrent.TimeUnit; import static com.launchdarkly.sdk.server.TestComponents.clientContext; -import static com.launchdarkly.sdk.server.TestUtil.makeSocketFactorySingleHost; -import static com.launchdarkly.sdk.server.TestHttpUtil.httpsServerWithSelfSignedCert; -import static com.launchdarkly.sdk.server.TestHttpUtil.makeStartedServer; import static com.launchdarkly.sdk.server.interfaces.EventSender.EventDataKind.ANALYTICS; import static com.launchdarkly.sdk.server.interfaces.EventSender.EventDataKind.DIAGNOSTICS; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.equalToIgnoringCase; import static org.hamcrest.Matchers.isA; 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.HttpUrl; -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"; @@ -56,10 +51,6 @@ private static EventSender makeEventSender(LDConfig config) { ); } - private static URI getBaseUri(MockWebServer server) { - return server.url("/").uri(); - } - @Test public void factoryCreatesDefaultSenderWithDefaultRetryDelay() throws Exception { EventSenderFactory f = new DefaultEventSender.Factory(); @@ -80,35 +71,35 @@ public void constructorUsesDefaultRetryDelayIfNotSpecified() throws Exception { @Test public void analyticsDataIsDelivered() throws Exception { - try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { + try (HttpServer server = HttpServer.start(eventsSuccessResponse())) { try (EventSender es = makeEventSender()) { - EventSender.Result result = es.sendEventData(ANALYTICS, FAKE_DATA, 1, getBaseUri(server)); + EventSender.Result result = es.sendEventData(ANALYTICS, FAKE_DATA, 1, server.getUri()); assertTrue(result.isSuccess()); assertFalse(result.isMustShutDown()); } - RecordedRequest req = server.takeRequest(); + RequestInfo req = server.getRecorder().requireRequest(); assertEquals("/bulk", req.getPath()); - assertEquals("application/json; charset=utf-8", req.getHeader("content-type")); - assertEquals(FAKE_DATA, req.getBody().readUtf8()); + assertThat(req.getHeader("content-type"), equalToIgnoringCase("application/json; charset=utf-8")); + assertEquals(FAKE_DATA, req.getBody()); } } @Test public void diagnosticDataIsDelivered() throws Exception { - try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { + try (HttpServer server = HttpServer.start(eventsSuccessResponse())) { try (EventSender es = makeEventSender()) { - EventSender.Result result = es.sendEventData(DIAGNOSTICS, FAKE_DATA, 1, getBaseUri(server)); + EventSender.Result result = es.sendEventData(DIAGNOSTICS, FAKE_DATA, 1, server.getUri()); assertTrue(result.isSuccess()); assertFalse(result.isMustShutDown()); } - RecordedRequest req = server.takeRequest(); + RequestInfo req = server.getRecorder().requireRequest(); assertEquals("/diagnostic", req.getPath()); - assertEquals("application/json; charset=utf-8", req.getHeader("content-type")); - assertEquals(FAKE_DATA, req.getBody().readUtf8()); + assertThat(req.getHeader("content-type"), equalToIgnoringCase("application/json; charset=utf-8")); + assertEquals(FAKE_DATA, req.getBody()); } } @@ -116,12 +107,12 @@ public void diagnosticDataIsDelivered() throws Exception { public void defaultHeadersAreSentForAnalytics() throws Exception { HttpConfiguration httpConfig = clientContext(SDK_KEY, LDConfig.DEFAULT).getHttp(); - try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { + try (HttpServer server = HttpServer.start(eventsSuccessResponse())) { try (EventSender es = makeEventSender()) { - es.sendEventData(ANALYTICS, FAKE_DATA, 1, getBaseUri(server)); + es.sendEventData(ANALYTICS, FAKE_DATA, 1, server.getUri()); } - RecordedRequest req = server.takeRequest(); + RequestInfo req = server.getRecorder().requireRequest(); for (Map.Entry kv: httpConfig.getDefaultHeaders()) { assertThat(req.getHeader(kv.getKey()), equalTo(kv.getValue())); } @@ -132,12 +123,12 @@ public void defaultHeadersAreSentForAnalytics() throws Exception { public void defaultHeadersAreSentForDiagnostics() throws Exception { HttpConfiguration httpConfig = clientContext(SDK_KEY, LDConfig.DEFAULT).getHttp(); - try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { + try (HttpServer server = HttpServer.start(eventsSuccessResponse())) { try (EventSender es = makeEventSender()) { - es.sendEventData(DIAGNOSTICS, FAKE_DATA, 1, getBaseUri(server)); + es.sendEventData(DIAGNOSTICS, FAKE_DATA, 1, server.getUri()); } - RecordedRequest req = server.takeRequest(); + RequestInfo req = server.getRecorder().requireRequest(); for (Map.Entry kv: httpConfig.getDefaultHeaders()) { assertThat(req.getHeader(kv.getKey()), equalTo(kv.getValue())); } @@ -146,24 +137,24 @@ public void defaultHeadersAreSentForDiagnostics() throws Exception { @Test public void eventSchemaIsSentForAnalytics() throws Exception { - try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { + try (HttpServer server = HttpServer.start(eventsSuccessResponse())) { try (EventSender es = makeEventSender()) { - es.sendEventData(ANALYTICS, FAKE_DATA, 1, getBaseUri(server)); + es.sendEventData(ANALYTICS, FAKE_DATA, 1, server.getUri()); } - RecordedRequest req = server.takeRequest(); + RequestInfo req = server.getRecorder().requireRequest(); assertThat(req.getHeader("X-LaunchDarkly-Event-Schema"), equalTo("3")); } } @Test public void eventPayloadIdIsSentForAnalytics() throws Exception { - try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { + try (HttpServer server = HttpServer.start(eventsSuccessResponse())) { try (EventSender es = makeEventSender()) { - es.sendEventData(ANALYTICS, FAKE_DATA, 1, getBaseUri(server)); + es.sendEventData(ANALYTICS, FAKE_DATA, 1, server.getUri()); } - RecordedRequest req = server.takeRequest(); + RequestInfo req = server.getRecorder().requireRequest(); String payloadHeaderValue = req.getHeader("X-LaunchDarkly-Payload-ID"); assertThat(payloadHeaderValue, notNullValue(String.class)); assertThat(UUID.fromString(payloadHeaderValue), notNullValue(UUID.class)); @@ -172,23 +163,24 @@ public void eventPayloadIdIsSentForAnalytics() throws Exception { @Test public void eventPayloadIdReusedOnRetry() throws Exception { - MockResponse errorResponse = new MockResponse().setResponseCode(429); + Handler errorResponse = Handlers.status(429); + Handler errorThenSuccess = Handlers.sequential(errorResponse, eventsSuccessResponse(), eventsSuccessResponse()); - try (MockWebServer server = makeStartedServer(errorResponse, eventsSuccessResponse(), eventsSuccessResponse())) { + try (HttpServer server = HttpServer.start(errorThenSuccess)) { try (EventSender es = makeEventSender()) { - es.sendEventData(ANALYTICS, FAKE_DATA, 1, getBaseUri(server)); - es.sendEventData(ANALYTICS, FAKE_DATA, 1, getBaseUri(server)); + es.sendEventData(ANALYTICS, FAKE_DATA, 1, server.getUri()); + es.sendEventData(ANALYTICS, FAKE_DATA, 1, server.getUri()); } // Failed response request - RecordedRequest req = server.takeRequest(0, TimeUnit.SECONDS); + RequestInfo req = server.getRecorder().requireRequest(); String payloadId = req.getHeader("X-LaunchDarkly-Payload-ID"); // Retry request has same payload ID as failed request - req = server.takeRequest(0, TimeUnit.SECONDS); + req = server.getRecorder().requireRequest(); 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); + req = server.getRecorder().requireRequest(); payloadId = req.getHeader("X-LaunchDarkly-Payload-ID"); assertThat(retryId, not(equalTo(payloadId))); } @@ -196,12 +188,12 @@ public void eventPayloadIdReusedOnRetry() throws Exception { @Test public void eventSchemaNotSetOnDiagnosticEvents() throws Exception { - try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { + try (HttpServer server = HttpServer.start(eventsSuccessResponse())) { try (EventSender es = makeEventSender()) { - es.sendEventData(DIAGNOSTICS, FAKE_DATA, 1, getBaseUri(server)); + es.sendEventData(DIAGNOSTICS, FAKE_DATA, 1, server.getUri()); } - RecordedRequest req = server.takeRequest(); + RequestInfo req = server.getRecorder().requireRequest(); assertNull(req.getHeader("X-LaunchDarkly-Event-Schema")); } } @@ -241,11 +233,11 @@ public void http500ErrorIsRecoverable() throws Exception { @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)); + Handler resp = Handlers.all(eventsSuccessResponse(), addDateHeader(new Date(fakeTime))); - try (MockWebServer server = makeStartedServer(resp)) { + try (HttpServer server = HttpServer.start(resp)) { try (EventSender es = makeEventSender()) { - EventSender.Result result = es.sendEventData(DIAGNOSTICS, FAKE_DATA, 1, getBaseUri(server)); + EventSender.Result result = es.sendEventData(DIAGNOSTICS, FAKE_DATA, 1, server.getUri()); assertNotNull(result.getTimeFromServer()); assertEquals(fakeTime, result.getTimeFromServer().getTime()); @@ -255,183 +247,153 @@ public void serverDateIsParsed() throws Exception { @Test public void invalidServerDateIsIgnored() throws Exception { - MockResponse resp = eventsSuccessResponse().addHeader("Date", "not a date"); + Handler resp = Handlers.all(eventsSuccessResponse(), Handlers.header("Date", "not a date")); - try (MockWebServer server = makeStartedServer(resp)) { + try (HttpServer server = HttpServer.start(resp)) { try (EventSender es = makeEventSender()) { - EventSender.Result result = es.sendEventData(DIAGNOSTICS, FAKE_DATA, 1, getBaseUri(server)); + EventSender.Result result = es.sendEventData(DIAGNOSTICS, FAKE_DATA, 1, server.getUri()); assertTrue(result.isSuccess()); assertNull(result.getTimeFromServer()); } } } - - @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())) { - LDConfig config = new LDConfig.Builder() - .http(Components.httpConfiguration() - .sslSocketFactory(serverWithCert.socketFactory, serverWithCert.trustManager) - // allows us to trust the self-signed cert - ) - .build(); - - try (EventSender es = makeEventSender(config)) { - EventSender.Result result = es.sendEventData(ANALYTICS, FAKE_DATA, 1, serverWithCert.uri()); + public void testSpecialHttpConfigurations() throws Exception { + Handler handler = eventsSuccessResponse(); + + TestHttpUtil.testWithSpecialHttpConfigurations(handler, + (targetUri, goodHttpConfig) -> { + LDConfig config = new LDConfig.Builder().http(goodHttpConfig).build(); + + try (EventSender es = makeEventSender(config)) { + EventSender.Result result = es.sendEventData(ANALYTICS, FAKE_DATA, 1, targetUri); + + assertTrue(result.isSuccess()); + assertFalse(result.isMustShutDown()); + } + }, - assertTrue(result.isSuccess()); - assertFalse(result.isMustShutDown()); - } - - assertEquals(1, serverWithCert.server.getRequestCount()); - } - } - - @Test - public void httpClientCanUseCustomSocketFactory() throws Exception { - try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - HttpUrl serverUrl = server.url("/"); - LDConfig config = new LDConfig.Builder() - .http(Components.httpConfiguration().socketFactory(makeSocketFactorySingleHost(serverUrl.host(), serverUrl.port()))) - .build(); - - URI uriWithWrongPort = URI.create("http://localhost:1"); - try (EventSender es = makeEventSender(config)) { - EventSender.Result result = es.sendEventData(DIAGNOSTICS, FAKE_DATA, 1, uriWithWrongPort); + (targetUri, badHttpConfig) -> { + LDConfig config = new LDConfig.Builder().http(badHttpConfig).build(); + + try (EventSender es = makeEventSender(config)) { + EventSender.Result result = es.sendEventData(ANALYTICS, FAKE_DATA, 1, targetUri); - assertTrue(result.isSuccess()); - assertFalse(result.isMustShutDown()); + assertFalse(result.isSuccess()); + assertFalse(result.isMustShutDown()); + } } - - assertEquals(1, server.getRequestCount()); - } + ); } - + @Test public void baseUriDoesNotNeedTrailingSlash() throws Exception { - try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { + try (HttpServer server = HttpServer.start(eventsSuccessResponse())) { try (EventSender es = makeEventSender()) { - URI uriWithoutSlash = URI.create(server.url("/").toString().replaceAll("/$", "")); + URI uriWithoutSlash = URI.create(server.getUri().toString().replaceAll("/$", "")); EventSender.Result result = es.sendEventData(ANALYTICS, FAKE_DATA, 1, uriWithoutSlash); assertTrue(result.isSuccess()); assertFalse(result.isMustShutDown()); } - RecordedRequest req = server.takeRequest(); + RequestInfo req = server.getRecorder().requireRequest(); assertEquals("/bulk", req.getPath()); - assertEquals("application/json; charset=utf-8", req.getHeader("content-type")); - assertEquals(FAKE_DATA, req.getBody().readUtf8()); + assertThat(req.getHeader("content-type"), equalToIgnoringCase("application/json; charset=utf-8")); + assertEquals(FAKE_DATA, req.getBody()); } } @Test public void baseUriCanHaveContextPath() throws Exception { - try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { + try (HttpServer server = HttpServer.start(eventsSuccessResponse())) { try (EventSender es = makeEventSender()) { - URI baseUri = URI.create(server.url("/context/path").toString()); + URI baseUri = server.getUri().resolve("/context/path"); EventSender.Result result = es.sendEventData(ANALYTICS, FAKE_DATA, 1, baseUri); assertTrue(result.isSuccess()); assertFalse(result.isMustShutDown()); } - RecordedRequest req = server.takeRequest(); + RequestInfo req = server.getRecorder().requireRequest(); assertEquals("/context/path/bulk", req.getPath()); - assertEquals("application/json; charset=utf-8", req.getHeader("content-type")); - assertEquals(FAKE_DATA, req.getBody().readUtf8()); + assertThat(req.getHeader("content-type"), equalToIgnoringCase("application/json; charset=utf-8")); + assertEquals(FAKE_DATA, req.getBody()); } } @Test public void nothingIsSentForNullData() throws Exception { - try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { + try (HttpServer server = HttpServer.start(eventsSuccessResponse())) { try (EventSender es = makeEventSender()) { - EventSender.Result result1 = es.sendEventData(ANALYTICS, null, 0, getBaseUri(server)); - EventSender.Result result2 = es.sendEventData(DIAGNOSTICS, null, 0, getBaseUri(server)); + EventSender.Result result1 = es.sendEventData(ANALYTICS, null, 0, server.getUri()); + EventSender.Result result2 = es.sendEventData(DIAGNOSTICS, null, 0, server.getUri()); assertTrue(result1.isSuccess()); assertTrue(result2.isSuccess()); - assertEquals(0, server.getRequestCount()); + assertEquals(0, server.getRecorder().count()); } } } @Test public void nothingIsSentForEmptyData() throws Exception { - try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { + try (HttpServer server = HttpServer.start(eventsSuccessResponse())) { try (EventSender es = makeEventSender()) { - EventSender.Result result1 = es.sendEventData(ANALYTICS, "", 0, getBaseUri(server)); - EventSender.Result result2 = es.sendEventData(DIAGNOSTICS, "", 0, getBaseUri(server)); + EventSender.Result result1 = es.sendEventData(ANALYTICS, "", 0, server.getUri()); + EventSender.Result result2 = es.sendEventData(DIAGNOSTICS, "", 0, server.getUri()); assertTrue(result1.isSuccess()); assertTrue(result2.isSuccess()); - assertEquals(0, server.getRequestCount()); + assertEquals(0, server.getRecorder().count()); } } } private void testUnrecoverableHttpError(int status) throws Exception { - MockResponse errorResponse = new MockResponse().setResponseCode(status); + Handler errorResponse = Handlers.status(status); - try (MockWebServer server = makeStartedServer(errorResponse)) { + try (HttpServer server = HttpServer.start(errorResponse)) { try (EventSender es = makeEventSender()) { - EventSender.Result result = es.sendEventData(DIAGNOSTICS, FAKE_DATA, 1, getBaseUri(server)); + EventSender.Result result = es.sendEventData(DIAGNOSTICS, FAKE_DATA, 1, server.getUri()); 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 + server.getRecorder().requireRequest(); - // it does not retry after this type of error, so there are no more requests - assertThat(server.takeRequest(0, TimeUnit.SECONDS), nullValue(RecordedRequest.class)); + // it does not retry after this type of error, so there are no more requests + server.getRecorder().requireNoRequests(Duration.ofMillis(100)); } } private void testRecoverableHttpError(int status) throws Exception { - MockResponse errorResponse = new MockResponse().setResponseCode(status); - + Handler errorResponse = Handlers.status(status); + Handler errorsThenSuccess = Handlers.sequential(errorResponse, errorResponse, eventsSuccessResponse()); // send two errors in a row, because the flush will be retried one time - try (MockWebServer server = makeStartedServer(errorResponse, errorResponse, eventsSuccessResponse())) { + + try (HttpServer server = HttpServer.start(errorsThenSuccess)) { try (EventSender es = makeEventSender()) { - EventSender.Result result = es.sendEventData(DIAGNOSTICS, FAKE_DATA, 1, getBaseUri(server)); + EventSender.Result result = es.sendEventData(DIAGNOSTICS, FAKE_DATA, 1, server.getUri()); 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 + server.getRecorder().requireRequest(); + server.getRecorder().requireRequest(); + server.getRecorder().requireNoRequests(Duration.ofMillis(100)); // only 2 requests total } } - private MockResponse eventsSuccessResponse() { - return new MockResponse().setResponseCode(202); + private Handler eventsSuccessResponse() { + return Handlers.status(202); } - private MockResponse addDateHeader(MockResponse response, Date date) { - return response.addHeader("Date", httpDateFormat.format(date)); + private Handler addDateHeader(Date date) { + return Handlers.header("Date", httpDateFormat.format(date)); } - } diff --git a/src/test/java/com/launchdarkly/sdk/server/DefaultFeatureRequestorTest.java b/src/test/java/com/launchdarkly/sdk/server/DefaultFeatureRequestorTest.java index 4b254e556..2c3f52587 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DefaultFeatureRequestorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultFeatureRequestorTest.java @@ -2,20 +2,17 @@ import com.launchdarkly.sdk.server.interfaces.BasicConfiguration; import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; +import com.launchdarkly.testhelpers.httptest.Handler; +import com.launchdarkly.testhelpers.httptest.Handlers; +import com.launchdarkly.testhelpers.httptest.HttpServer; +import com.launchdarkly.testhelpers.httptest.RequestInfo; import org.junit.Test; import java.net.URI; import java.util.Map; -import javax.net.SocketFactory; -import javax.net.ssl.SSLHandshakeException; - import static com.launchdarkly.sdk.server.TestComponents.clientContext; -import static com.launchdarkly.sdk.server.TestUtil.makeSocketFactorySingleHost; -import static com.launchdarkly.sdk.server.TestHttpUtil.httpsServerWithSelfSignedCert; -import static com.launchdarkly.sdk.server.TestHttpUtil.jsonResponse; -import static com.launchdarkly.sdk.server.TestHttpUtil.makeStartedServer; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.junit.Assert.assertEquals; @@ -23,11 +20,6 @@ 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 DefaultFeatureRequestorTest { private static final String sdkKey = "sdk-key"; @@ -39,15 +31,14 @@ public class DefaultFeatureRequestorTest { private static final String segmentsJson = "{\"" + segment1Key + "\":" + segment1Json + "}"; private static final String allDataJson = "{\"flags\":" + flagsJson + ",\"segments\":" + segmentsJson + "}"; - private DefaultFeatureRequestor makeRequestor(MockWebServer server) { + private DefaultFeatureRequestor makeRequestor(HttpServer 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(makeHttpConfig(config), uri); + private DefaultFeatureRequestor makeRequestor(HttpServer server, LDConfig config) { + return new DefaultFeatureRequestor(makeHttpConfig(config), server.getUri()); } private HttpConfiguration makeHttpConfig(LDConfig config) { @@ -66,13 +57,13 @@ private void verifyExpectedData(FeatureRequestor.AllData data) { @Test public void requestAllData() throws Exception { - MockResponse resp = jsonResponse(allDataJson); + Handler resp = Handlers.bodyJson(allDataJson); - try (MockWebServer server = makeStartedServer(resp)) { + try (HttpServer server = HttpServer.start(resp)) { try (DefaultFeatureRequestor r = makeRequestor(server)) { FeatureRequestor.AllData data = r.getAllData(true); - RecordedRequest req = server.takeRequest(); + RequestInfo req = server.getRecorder().requireRequest(); assertEquals("/sdk/latest-all", req.getPath()); verifyHeaders(req); @@ -83,17 +74,20 @@ public void requestAllData() throws Exception { @Test public void responseIsCached() throws Exception { - MockResponse cacheableResp = jsonResponse(allDataJson) - .setHeader("ETag", "aaa") - .setHeader("Cache-Control", "max-age=0"); - MockResponse cachedResp = new MockResponse().setResponseCode(304); + Handler cacheableResp = Handlers.all( + Handlers.header("ETag", "aaa"), + Handlers.header("Cache-Control", "max-age=0"), + Handlers.bodyJson(allDataJson) + ); + Handler cachedResp = Handlers.status(304); + Handler cacheableThenCached = Handlers.sequential(cacheableResp, cachedResp); - try (MockWebServer server = makeStartedServer(cacheableResp, cachedResp)) { + try (HttpServer server = HttpServer.start(cacheableThenCached)) { try (DefaultFeatureRequestor r = makeRequestor(server)) { FeatureRequestor.AllData data1 = r.getAllData(true); verifyExpectedData(data1); - RecordedRequest req1 = server.takeRequest(); + RequestInfo req1 = server.getRecorder().requireRequest(); assertEquals("/sdk/latest-all", req1.getPath()); verifyHeaders(req1); assertNull(req1.getHeader("If-None-Match")); @@ -101,7 +95,7 @@ public void responseIsCached() throws Exception { FeatureRequestor.AllData data2 = r.getAllData(false); assertNull(data2); - RecordedRequest req2 = server.takeRequest(); + RequestInfo req2 = server.getRecorder().requireRequest(); assertEquals("/sdk/latest-all", req2.getPath()); verifyHeaders(req2); assertEquals("aaa", req2.getHeader("If-None-Match")); @@ -111,17 +105,20 @@ public void responseIsCached() throws Exception { @Test public void responseIsCachedButWeWantDataAnyway() throws Exception { - MockResponse cacheableResp = jsonResponse(allDataJson) - .setHeader("ETag", "aaa") - .setHeader("Cache-Control", "max-age=0"); - MockResponse cachedResp = new MockResponse().setResponseCode(304); + Handler cacheableResp = Handlers.all( + Handlers.header("ETag", "aaa"), + Handlers.header("Cache-Control", "max-age=0"), + Handlers.bodyJson(allDataJson) + ); + Handler cachedResp = Handlers.status(304); + Handler cacheableThenCached = Handlers.sequential(cacheableResp, cachedResp); - try (MockWebServer server = makeStartedServer(cacheableResp, cachedResp)) { + try (HttpServer server = HttpServer.start(cacheableThenCached)) { try (DefaultFeatureRequestor r = makeRequestor(server)) { FeatureRequestor.AllData data1 = r.getAllData(true); verifyExpectedData(data1); - RecordedRequest req1 = server.takeRequest(); + RequestInfo req1 = server.getRecorder().requireRequest(); assertEquals("/sdk/latest-all", req1.getPath()); verifyHeaders(req1); assertNull(req1.getHeader("If-None-Match")); @@ -129,94 +126,53 @@ public void responseIsCachedButWeWantDataAnyway() throws Exception { FeatureRequestor.AllData data2 = r.getAllData(true); verifyExpectedData(data2); - RecordedRequest req2 = server.takeRequest(); + RequestInfo req2 = server.getRecorder().requireRequest(); assertEquals("/sdk/latest-all", req2.getPath()); verifyHeaders(req2); assertEquals("aaa", req2.getHeader("If-None-Match")); } } } - - @Test - public void httpClientDoesNotAllowSelfSignedCertByDefault() throws Exception { - MockResponse resp = jsonResponse(allDataJson); - - try (TestHttpUtil.ServerWithCert serverWithCert = httpsServerWithSelfSignedCert(resp)) { - try (DefaultFeatureRequestor r = makeRequestor(serverWithCert.server)) { - try { - r.getAllData(false); - fail("expected exception"); - } catch (SSLHandshakeException e) { - } - - assertEquals(0, serverWithCert.server.getRequestCount()); - } - } - } - - @Test - public void httpClientCanUseCustomTlsConfig() throws Exception { - MockResponse resp = jsonResponse(allDataJson); - - try (TestHttpUtil.ServerWithCert serverWithCert = httpsServerWithSelfSignedCert(resp)) { - LDConfig config = new LDConfig.Builder() - .http(Components.httpConfiguration().sslSocketFactory(serverWithCert.socketFactory, serverWithCert.trustManager)) - // allows us to trust the self-signed cert - .build(); - - try (DefaultFeatureRequestor r = makeRequestor(serverWithCert.server, config)) { - FeatureRequestor.AllData data = r.getAllData(false); - verifyExpectedData(data); - } - } - } - - @Test - public void httpClientCanUseCustomSocketFactory() throws Exception { - try (MockWebServer server = makeStartedServer(jsonResponse(allDataJson))) { - HttpUrl serverUrl = server.url("/"); - LDConfig config = new LDConfig.Builder() - .http(Components.httpConfiguration().socketFactory(makeSocketFactorySingleHost(serverUrl.host(), serverUrl.port()))) - .build(); - URI uriWithWrongPort = URI.create("http://localhost:1"); - try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(makeHttpConfig(config), uriWithWrongPort)) { - FeatureRequestor.AllData data = r.getAllData(false); - verifyExpectedData(data); - - assertEquals(1, server.getRequestCount()); - } - } - } - @Test - public void httpClientCanUseProxyConfig() throws Exception { - URI fakeBaseUri = URI.create("http://not-a-real-host"); - try (MockWebServer server = makeStartedServer(jsonResponse(allDataJson))) { - HttpUrl serverUrl = server.url("/"); - LDConfig config = new LDConfig.Builder() - .http(Components.httpConfiguration().proxyHostAndPort(serverUrl.host(), serverUrl.port())) - .build(); - - try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(makeHttpConfig(config), fakeBaseUri)) { - FeatureRequestor.AllData data = r.getAllData(false); - verifyExpectedData(data); + public void testSpecialHttpConfigurations() throws Exception { + Handler handler = Handlers.bodyJson(allDataJson); + + TestHttpUtil.testWithSpecialHttpConfigurations(handler, + (targetUri, goodHttpConfig) -> { + LDConfig config = new LDConfig.Builder().http(goodHttpConfig).build(); + try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(makeHttpConfig(config), targetUri)) { + try { + FeatureRequestor.AllData data = r.getAllData(false); + verifyExpectedData(data); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + }, - assertEquals(1, server.getRequestCount()); - } - } + (targetUri, badHttpConfig) -> { + LDConfig config = new LDConfig.Builder().http(badHttpConfig).build(); + try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(makeHttpConfig(config), targetUri)) { + try { + r.getAllData(false); + fail("expected exception"); + } catch (Exception e) { + } + } + } + ); } @Test public void baseUriDoesNotNeedTrailingSlash() throws Exception { - MockResponse resp = jsonResponse(allDataJson); + Handler resp = Handlers.bodyJson(allDataJson); - try (MockWebServer server = makeStartedServer(resp)) { - URI uri = server.url("").uri(); - try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(makeHttpConfig(LDConfig.DEFAULT), uri)) { + try (HttpServer server = HttpServer.start(resp)) { + try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(makeHttpConfig(LDConfig.DEFAULT), server.getUri())) { FeatureRequestor.AllData data = r.getAllData(true); - RecordedRequest req = server.takeRequest(); + RequestInfo req = server.getRecorder().requireRequest(); assertEquals("/sdk/latest-all", req.getPath()); verifyHeaders(req); @@ -227,14 +183,15 @@ public void baseUriDoesNotNeedTrailingSlash() throws Exception { @Test public void baseUriCanHaveContextPath() throws Exception { - MockResponse resp = jsonResponse(allDataJson); + Handler resp = Handlers.bodyJson(allDataJson); - try (MockWebServer server = makeStartedServer(resp)) { - URI uri = server.url("/context/path").uri(); + try (HttpServer server = HttpServer.start(resp)) { + URI uri = server.getUri().resolve("/context/path"); + try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(makeHttpConfig(LDConfig.DEFAULT), uri)) { FeatureRequestor.AllData data = r.getAllData(true); - RecordedRequest req = server.takeRequest(); + RequestInfo req = server.getRecorder().requireRequest(); assertEquals("/context/path/sdk/latest-all", req.getPath()); verifyHeaders(req); @@ -243,7 +200,7 @@ public void baseUriCanHaveContextPath() throws Exception { } } - private void verifyHeaders(RecordedRequest req) { + private void verifyHeaders(RequestInfo req) { HttpConfiguration httpConfig = clientContext(sdkKey, LDConfig.DEFAULT).getHttp(); for (Map.Entry kv: httpConfig.getDefaultHeaders()) { assertThat(req.getHeader(kv.getKey()), equalTo(kv.getValue())); diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientEndToEndTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientEndToEndTest.java index cb1dd8aea..37faffa5d 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientEndToEndTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientEndToEndTest.java @@ -5,6 +5,11 @@ import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; +import com.launchdarkly.sdk.server.interfaces.HttpConfigurationFactory; +import com.launchdarkly.testhelpers.httptest.Handler; +import com.launchdarkly.testhelpers.httptest.Handlers; +import com.launchdarkly.testhelpers.httptest.HttpServer; +import com.launchdarkly.testhelpers.httptest.RequestInfo; import org.junit.Test; @@ -12,28 +17,18 @@ import java.time.Duration; import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; +import java.util.function.BiFunction; import static com.launchdarkly.sdk.server.Components.externalUpdatesOnly; import static com.launchdarkly.sdk.server.Components.noEvents; import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; -import static com.launchdarkly.sdk.server.TestHttpUtil.basePollingConfig; -import static com.launchdarkly.sdk.server.TestHttpUtil.baseStreamingConfig; -import static com.launchdarkly.sdk.server.TestHttpUtil.httpsServerWithSelfSignedCert; -import static com.launchdarkly.sdk.server.TestHttpUtil.jsonResponse; -import static com.launchdarkly.sdk.server.TestHttpUtil.makeStartedServer; +import static com.launchdarkly.testhelpers.httptest.Handlers.bodyJson; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.nullValue; -import static org.hamcrest.Matchers.startsWith; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; -import okhttp3.HttpUrl; -import okhttp3.mockwebserver.MockResponse; -import okhttp3.mockwebserver.MockWebServer; -import okhttp3.mockwebserver.RecordedRequest; - @SuppressWarnings("javadoc") public class LDClientEndToEndTest { private static final Gson gson = new Gson(); @@ -44,13 +39,30 @@ public class LDClientEndToEndTest { .build(); private static final LDUser user = new LDUser("user-key"); + private static Handler makePollingSuccessResponse() { + return bodyJson(makeAllDataJson()); + } + + private static Handler makeStreamingSuccessResponse() { + String streamData = "event: put\n" + + "data: {\"data\":" + makeAllDataJson() + "}"; + return Handlers.all(Handlers.SSE.start(), + Handlers.SSE.event(streamData), Handlers.SSE.leaveOpen()); + } + + private static Handler makeInvalidSdkKeyResponse() { + return Handlers.status(401); + } + + private static Handler makeServiceUnavailableResponse() { + return Handlers.status(503); + } + @Test public void clientStartsInPollingMode() throws Exception { - MockResponse resp = jsonResponse(makeAllDataJson()); - - try (MockWebServer server = makeStartedServer(resp)) { + try (HttpServer server = HttpServer.start(makePollingSuccessResponse())) { LDConfig config = new LDConfig.Builder() - .dataSource(basePollingConfig(server)) + .dataSource(Components.pollingDataSource().baseURI(server.getUri())) .events(noEvents()) .build(); @@ -62,50 +74,65 @@ public void clientStartsInPollingMode() throws Exception { } @Test - public void clientFailsInPollingModeWith401Error() throws Exception { - MockResponse resp = new MockResponse().setResponseCode(401); - - try (MockWebServer server = makeStartedServer(resp)) { + public void clientStartsInPollingModeAfterRecoverableError() throws Exception { + Handler errorThenSuccess = Handlers.sequential( + makeServiceUnavailableResponse(), + makePollingSuccessResponse() + ); + + try (HttpServer server = HttpServer.start(errorThenSuccess)) { LDConfig config = new LDConfig.Builder() - .dataSource(basePollingConfig(server)) + .dataSource(Components.pollingDataSourceInternal() + .pollIntervalWithNoMinimum(Duration.ofMillis(5)) // use small interval because we expect it to retry + .baseURI(server.getUri())) .events(noEvents()) .build(); try (LDClient client = new LDClient(sdkKey, config)) { - assertFalse(client.isInitialized()); - assertFalse(client.boolVariation(flagKey, user, false)); + assertTrue(client.isInitialized()); + assertTrue(client.boolVariation(flagKey, user, false)); } } } @Test - public void clientStartsInPollingModeWithSelfSignedCert() throws Exception { - MockResponse resp = jsonResponse(makeAllDataJson()); - - try (TestHttpUtil.ServerWithCert serverWithCert = httpsServerWithSelfSignedCert(resp)) { + public void clientFailsInPollingModeWith401Error() throws Exception { + try (HttpServer server = HttpServer.start(makeInvalidSdkKeyResponse())) { LDConfig config = new LDConfig.Builder() - .dataSource(basePollingConfig(serverWithCert.server)) + .dataSource(Components.pollingDataSourceInternal() + .pollIntervalWithNoMinimum(Duration.ofMillis(5)) // use small interval so we'll know if it does not stop permanently + .baseURI(server.getUri())) .events(noEvents()) - .http(Components.httpConfiguration().sslSocketFactory(serverWithCert.socketFactory, serverWithCert.trustManager)) - // allows us to trust the self-signed cert .build(); try (LDClient client = new LDClient(sdkKey, config)) { - assertTrue(client.isInitialized()); - assertTrue(client.boolVariation(flagKey, user, false)); + assertFalse(client.isInitialized()); + assertFalse(client.boolVariation(flagKey, user, false)); + + server.getRecorder().requireRequest(); + server.getRecorder().requireNoRequests(Duration.ofMillis(100)); } } } @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)) { + public void testPollingModeSpecialHttpConfigurations() throws Exception { + testWithSpecialHttpConfigurations( + makePollingSuccessResponse(), + (serverUri, httpConfig) -> + new LDConfig.Builder() + .dataSource(Components.pollingDataSource().baseURI(serverUri)) + .events(noEvents()) + .http(httpConfig) + .startWait(Duration.ofMillis(100)) + .build()); + } + + @Test + public void clientStartsInStreamingMode() throws Exception { + try (HttpServer server = HttpServer.start(makeStreamingSuccessResponse())) { LDConfig config = new LDConfig.Builder() - .dataSource(baseStreamingConfig(server)) + .dataSource(Components.streamingDataSource().baseURI(server.getUri())) .events(noEvents()) .build(); @@ -118,32 +145,35 @@ public void clientStartsInStreamingMode() throws Exception { @Test public void clientStartsInStreamingModeAfterRecoverableError() throws Exception { - MockResponse errorResp = new MockResponse().setResponseCode(503); - - String streamData = "event: put\n" + - "data: {\"data\":" + makeAllDataJson() + "}\n\n"; - MockResponse streamResp = TestHttpUtil.eventStreamResponse(streamData); + Handler errorThenStream = Handlers.sequential( + makeServiceUnavailableResponse(), + makeStreamingSuccessResponse() + ); - try (MockWebServer server = makeStartedServer(errorResp, streamResp)) { + try (HttpServer server = HttpServer.start(errorThenStream)) { LDConfig config = new LDConfig.Builder() - .dataSource(baseStreamingConfig(server)) + .dataSource(Components.streamingDataSource().baseURI(server.getUri()).initialReconnectDelay(Duration.ZERO)) + // use zero reconnect delay so we'll know if it does not stop permanently .events(noEvents()) .build(); try (LDClient client = new LDClient(sdkKey, config)) { assertTrue(client.isInitialized()); assertTrue(client.boolVariation(flagKey, user, false)); + + server.getRecorder().requireRequest(); + server.getRecorder().requireRequest(); + server.getRecorder().requireNoRequests(Duration.ofMillis(100)); } } } @Test public void clientFailsInStreamingModeWith401Error() throws Exception { - MockResponse resp = new MockResponse().setResponseCode(401); - - try (MockWebServer server = makeStartedServer(resp, resp, resp)) { + try (HttpServer server = HttpServer.start(makeInvalidSdkKeyResponse())) { LDConfig config = new LDConfig.Builder() - .dataSource(baseStreamingConfig(server).initialReconnectDelay(Duration.ZERO)) + .dataSource(Components.streamingDataSource().baseURI(server.getUri()).initialReconnectDelay(Duration.ZERO)) + // use zero reconnect delay so we'll know if it does not stop permanently .events(noEvents()) .build(); @@ -163,94 +193,34 @@ public void clientFailsInStreamingModeWith401Error() throws Exception { assertThat(statuses.take().getState(), equalTo(DataSourceStatusProvider.State.OFF)); } assertThat(statuses.isEmpty(), equalTo(true)); - assertThat(server.getRequestCount(), equalTo(1)); // no retries - } - } - } - - @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 = new LDConfig.Builder() - .dataSource(baseStreamingConfig(serverWithCert.server)) - .events(noEvents()) - .http(Components.httpConfiguration().sslSocketFactory(serverWithCert.socketFactory, serverWithCert.trustManager)) - // allows us to trust the self-signed cert - .build(); - - try (LDClient client = new LDClient(sdkKey, config)) { - assertTrue(client.isInitialized()); - assertTrue(client.boolVariation(flagKey, user, false)); - } - } - } - - @Test - public void clientUsesProxy() throws Exception { - URI fakeBaseUri = URI.create("http://not-a-real-host"); - MockResponse resp = jsonResponse(makeAllDataJson()); - - try (MockWebServer server = makeStartedServer(resp)) { - HttpUrl serverUrl = server.url("/"); - LDConfig config = new LDConfig.Builder() - .http(Components.httpConfiguration() - .proxyHostAndPort(serverUrl.host(), serverUrl.port())) - .dataSource(Components.pollingDataSource().baseURI(fakeBaseUri)) - .events(Components.noEvents()) - .build(); - - try (LDClient client = new LDClient(sdkKey, config)) { - assertTrue(client.isInitialized()); - RecordedRequest req = server.takeRequest(); - assertThat(req.getRequestLine(), startsWith("GET " + fakeBaseUri + "/sdk/latest-all")); - assertThat(req.getHeader("Proxy-Authorization"), nullValue()); + server.getRecorder().requireRequest(); + server.getRecorder().requireNoRequests(Duration.ofMillis(100)); } } } @Test - public void clientUsesProxyWithBasicAuth() throws Exception { - URI fakeBaseUri = URI.create("http://not-a-real-host"); - MockResponse challengeResp = new MockResponse().setResponseCode(407).setHeader("Proxy-Authenticate", "Basic realm=x"); - MockResponse resp = jsonResponse(makeAllDataJson()); - - try (MockWebServer server = makeStartedServer(challengeResp, resp)) { - HttpUrl serverUrl = server.url("/"); - LDConfig config = new LDConfig.Builder() - .http(Components.httpConfiguration() - .proxyHostAndPort(serverUrl.host(), serverUrl.port()) - .proxyAuth(Components.httpBasicAuthentication("user", "pass"))) - .dataSource(Components.pollingDataSource().baseURI(fakeBaseUri)) - .events(Components.noEvents()) - .build(); - - try (LDClient client = new LDClient(sdkKey, config)) { - assertTrue(client.isInitialized()); - - RecordedRequest req1 = server.takeRequest(); - assertThat(req1.getRequestLine(), startsWith("GET " + fakeBaseUri + "/sdk/latest-all")); - assertThat(req1.getHeader("Proxy-Authorization"), nullValue()); - - RecordedRequest req2 = server.takeRequest(); - assertThat(req2.getRequestLine(), equalTo(req1.getRequestLine())); - assertThat(req2.getHeader("Proxy-Authorization"), equalTo("Basic dXNlcjpwYXNz")); - } - } + public void testStreamingModeSpecialHttpConfigurations() throws Exception { + testWithSpecialHttpConfigurations( + makeStreamingSuccessResponse(), + (serverUri, httpConfig) -> + new LDConfig.Builder() + .dataSource(Components.streamingDataSource().baseURI(serverUri)) + .events(noEvents()) + .http(httpConfig) + .startWait(Duration.ofMillis(100)) + .build()); } @Test public void clientSendsAnalyticsEvent() throws Exception { - MockResponse resp = new MockResponse().setResponseCode(202); + Handler resp = Handlers.status(202); - try (MockWebServer server = makeStartedServer(resp)) { + try (HttpServer server = HttpServer.start(resp)) { LDConfig config = new LDConfig.Builder() .dataSource(externalUpdatesOnly()) - .events(Components.sendEvents().baseURI(server.url("/").uri())) + .events(Components.sendEvents().baseURI(server.getUri())) .diagnosticOptOut(true) .build(); @@ -259,31 +229,50 @@ public void clientSendsAnalyticsEvent() throws Exception { client.identify(new LDUser("userkey")); } - RecordedRequest req = server.takeRequest(); + RequestInfo req = server.getRecorder().requireRequest(); assertEquals("/bulk", req.getPath()); } } @Test public void clientSendsDiagnosticEvent() throws Exception { - MockResponse resp = new MockResponse().setResponseCode(202); + Handler resp = Handlers.status(202); - try (MockWebServer server = makeStartedServer(resp)) { + try (HttpServer server = HttpServer.start(resp)) { LDConfig config = new LDConfig.Builder() .dataSource(externalUpdatesOnly()) - .events(Components.sendEvents().baseURI(server.url("/").uri())) + .events(Components.sendEvents().baseURI(server.getUri())) .build(); try (LDClient client = new LDClient(sdkKey, config)) { assertTrue(client.isInitialized()); - RecordedRequest req = server.takeRequest(); + RequestInfo req = server.getRecorder().requireRequest(); assertEquals("/diagnostic", req.getPath()); } } } - public String makeAllDataJson() { + private static void testWithSpecialHttpConfigurations(Handler handler, + BiFunction makeConfig) throws Exception { + TestHttpUtil.testWithSpecialHttpConfigurations(handler, + (serverUri, httpConfig) -> { + LDConfig config = makeConfig.apply(serverUri, httpConfig); + try (LDClient client = new LDClient(sdkKey, config)) { + assertTrue(client.isInitialized()); + assertTrue(client.boolVariation(flagKey, user, false)); + } + }, + (serverUri, httpConfig) -> { + LDConfig config = makeConfig.apply(serverUri, httpConfig); + try (LDClient client = new LDClient(sdkKey, config)) { + assertFalse(client.isInitialized()); + } + } + ); + } + + private static String makeAllDataJson() { JsonObject flagsData = new JsonObject(); flagsData.add(flagKey, gson.toJsonTree(flag)); JsonObject allData = new JsonObject(); diff --git a/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java index 584f6bef4..cf8bba201 100644 --- a/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java @@ -1,6 +1,7 @@ package com.launchdarkly.sdk.server; import com.launchdarkly.sdk.server.DataModel.FeatureFlag; +import com.launchdarkly.sdk.server.DataStoreTestTypes.DataBuilder; import com.launchdarkly.sdk.server.TestComponents.MockDataSourceUpdates; import com.launchdarkly.sdk.server.TestComponents.MockDataStoreStatusProvider; import com.launchdarkly.sdk.server.TestUtil.ActionCanThrowAnyException; @@ -12,42 +13,36 @@ import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.Status; import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; -import com.launchdarkly.sdk.server.interfaces.SerializationException; +import com.launchdarkly.testhelpers.httptest.Handler; +import com.launchdarkly.testhelpers.httptest.Handlers; +import com.launchdarkly.testhelpers.httptest.HttpServer; +import com.launchdarkly.testhelpers.httptest.RequestContext; -import org.hamcrest.MatcherAssert; import org.junit.Before; import org.junit.Test; -import java.io.IOException; import java.net.URI; import java.time.Duration; -import java.util.Collections; -import java.util.HashMap; import java.util.concurrent.BlockingQueue; import java.util.concurrent.Future; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.Semaphore; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; import static com.launchdarkly.sdk.server.TestComponents.clientContext; import static com.launchdarkly.sdk.server.TestComponents.dataStoreThatThrowsException; +import static com.launchdarkly.sdk.server.TestComponents.defaultHttpConfiguration; import static com.launchdarkly.sdk.server.TestComponents.sharedExecutor; -import static com.launchdarkly.sdk.server.TestUtil.awaitValue; -import static com.launchdarkly.sdk.server.TestUtil.expectNoMoreValues; +import static com.launchdarkly.sdk.server.TestUtil.assertDataSetEquals; import static com.launchdarkly.sdk.server.TestUtil.requireDataSourceStatus; import static com.launchdarkly.sdk.server.TestUtil.requireDataSourceStatusEventually; import static com.launchdarkly.sdk.server.TestUtil.shouldNotTimeOut; -import static com.launchdarkly.sdk.server.TestUtil.shouldTimeOut; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.greaterThanOrEqualTo; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; @SuppressWarnings("javadoc") public class PollingProcessorTest { @@ -56,23 +51,45 @@ public class PollingProcessorTest { private static final Duration BRIEF_INTERVAL = Duration.ofMillis(20); private MockDataSourceUpdates dataSourceUpdates; - private MockFeatureRequestor requestor; @Before public void setup() { DataStore store = new InMemoryDataStore(); dataSourceUpdates = TestComponents.dataSourceUpdates(store, new MockDataStoreStatusProvider()); - requestor = new MockFeatureRequestor(); } - private PollingProcessor makeProcessor() { - return makeProcessor(LENGTHY_INTERVAL); - } - - private PollingProcessor makeProcessor(Duration pollInterval) { + private PollingProcessor makeProcessor(URI baseUri, Duration pollInterval) { + FeatureRequestor requestor = new DefaultFeatureRequestor(defaultHttpConfiguration(), baseUri); return new PollingProcessor(requestor, dataSourceUpdates, sharedExecutor, pollInterval); } + private static class TestPollHandler implements Handler { + private final String data; + private volatile int errorStatus; + + public TestPollHandler() { + this(DataBuilder.forStandardTypes()); + } + + public TestPollHandler(DataBuilder data) { + this.data = data.buildJson().toJsonString(); + } + + @Override + public void apply(RequestContext context) { + int err = errorStatus; + if (err == 0) { + Handlers.bodyJson(data).apply(context); + } else { + context.setStatus(err); + } + } + + public void setError(int status) { + this.errorStatus = status; + } + } + @Test public void builderHasDefaultConfiguration() throws Exception { DataSourceFactory f = Components.pollingDataSource(); @@ -98,59 +115,60 @@ public void builderCanSpecifyConfiguration() throws Exception { public void successfulPolls() throws Exception { FeatureFlag flagv1 = ModelBuilders.flagBuilder("flag").version(1).build(); FeatureFlag flagv2 = ModelBuilders.flagBuilder(flagv1.getKey()).version(2).build(); - FeatureRequestor.AllData datav1 = new FeatureRequestor.AllData(Collections.singletonMap(flagv1.getKey(), flagv1), - Collections.emptyMap()); - FeatureRequestor.AllData datav2 = new FeatureRequestor.AllData(Collections.singletonMap(flagv1.getKey(), flagv2), - Collections.emptyMap()); + DataBuilder datav1 = DataBuilder.forStandardTypes().addAny(DataModel.FEATURES, flagv1); + DataBuilder datav2 = DataBuilder.forStandardTypes().addAny(DataModel.FEATURES, flagv2); - requestor.gate = new Semaphore(0); - requestor.allData = datav1; - BlockingQueue statuses = new LinkedBlockingQueue<>(); dataSourceUpdates.statusBroadcaster.register(statuses::add); - try (PollingProcessor pollingProcessor = makeProcessor(Duration.ofMillis(100))) { - Future initFuture = pollingProcessor.start(); - - // allow first poll to complete - requestor.gate.release(); - - initFuture.get(1000, TimeUnit.MILLISECONDS); - - assertTrue(pollingProcessor.isInitialized()); - assertEquals(datav1.toFullDataSet(), dataSourceUpdates.awaitInit()); - - // allow second poll to complete - should return new data - requestor.allData = datav2; - requestor.gate.release(); - - requireDataSourceStatus(statuses, State.VALID); + Semaphore allowSecondPollToProceed = new Semaphore(0); + + Handler pollingHandler = Handlers.sequential( + new TestPollHandler(datav1), + Handlers.all( + Handlers.waitFor(allowSecondPollToProceed), + new TestPollHandler(datav2) + ), + Handlers.hang() // we don't want any more polls to complete after the second one + ); + + try (HttpServer server = HttpServer.start(pollingHandler)) { + try (PollingProcessor pollingProcessor = makeProcessor(server.getUri(), Duration.ofMillis(100))) { + Future initFuture = pollingProcessor.start(); + shouldNotTimeOut(initFuture, Duration.ofSeconds(1)); + + assertTrue(pollingProcessor.isInitialized()); + assertDataSetEquals(datav1.build(), dataSourceUpdates.awaitInit()); - assertEquals(datav2.toFullDataSet(), dataSourceUpdates.awaitInit()); + allowSecondPollToProceed.release(); + + assertDataSetEquals(datav2.build(), dataSourceUpdates.awaitInit()); + } } } @Test - public void testConnectionProblem() throws Exception { - requestor.ioException = new IOException("This exception is part of a test and yes you should be seeing it."); - + public void testTimeoutFromConnectionProblem() throws Exception { BlockingQueue statuses = new LinkedBlockingQueue<>(); dataSourceUpdates.statusBroadcaster.register(statuses::add); - try (PollingProcessor pollingProcessor = makeProcessor()) { - Future initFuture = pollingProcessor.start(); - try { - initFuture.get(200L, TimeUnit.MILLISECONDS); - fail("Expected Timeout, instead initFuture.get() returned."); - } catch (TimeoutException ignored) { + Handler errorThenSuccess = Handlers.sequential( + Handlers.malformedResponse(), // this will cause an IOException + new TestPollHandler() // it should time out before reaching this + ); + + try (HttpServer server = HttpServer.start(errorThenSuccess)) { + try (PollingProcessor pollingProcessor = makeProcessor(server.getUri(), LENGTHY_INTERVAL)) { + Future initFuture = pollingProcessor.start(); + TestUtil.shouldTimeOut(initFuture, Duration.ofMillis(200)); + assertFalse(initFuture.isDone()); + assertFalse(pollingProcessor.isInitialized()); + assertEquals(0, dataSourceUpdates.receivedInits.size()); + + Status status = requireDataSourceStatus(statuses, State.INITIALIZING); + assertNotNull(status.getLastError()); + assertEquals(ErrorKind.NETWORK_ERROR, status.getLastError().getKind()); } - assertFalse(initFuture.isDone()); - assertFalse(pollingProcessor.isInitialized()); - assertEquals(0, dataSourceUpdates.receivedInits.size()); - - Status status = requireDataSourceStatus(statuses, State.INITIALIZING); - assertNotNull(status.getLastError()); - assertEquals(ErrorKind.NETWORK_ERROR, status.getLastError().getKind()); } } @@ -160,76 +178,56 @@ public void testDataStoreFailure() throws Exception { DataStoreStatusProvider badStoreStatusProvider = new MockDataStoreStatusProvider(false); dataSourceUpdates = TestComponents.dataSourceUpdates(badStore, badStoreStatusProvider); - requestor.allData = new FeatureRequestor.AllData(new HashMap<>(), new HashMap<>()); - BlockingQueue statuses = new LinkedBlockingQueue<>(); dataSourceUpdates.statusBroadcaster.register(statuses::add); - try (PollingProcessor pollingProcessor = makeProcessor()) { - pollingProcessor.start(); - - assertEquals(requestor.allData.toFullDataSet(), dataSourceUpdates.awaitInit()); + try (HttpServer server = HttpServer.start(new TestPollHandler())) { + try (PollingProcessor pollingProcessor = makeProcessor(server.getUri(), LENGTHY_INTERVAL)) { + pollingProcessor.start(); + + assertDataSetEquals(DataBuilder.forStandardTypes().build(), dataSourceUpdates.awaitInit()); - assertFalse(pollingProcessor.isInitialized()); - - Status status = requireDataSourceStatus(statuses, State.INITIALIZING); - assertNotNull(status.getLastError()); - assertEquals(ErrorKind.STORE_ERROR, status.getLastError().getKind()); + assertFalse(pollingProcessor.isInitialized()); + + Status status = requireDataSourceStatus(statuses, State.INITIALIZING); + assertNotNull(status.getLastError()); + assertEquals(ErrorKind.STORE_ERROR, status.getLastError().getKind()); + } } } @Test public void testMalformedData() throws Exception { - requestor.runtimeException = new SerializationException(new Exception("the JSON was displeasing")); + Handler badDataHandler = Handlers.bodyJson("{bad"); BlockingQueue statuses = new LinkedBlockingQueue<>(); dataSourceUpdates.statusBroadcaster.register(statuses::add); - try (PollingProcessor pollingProcessor = makeProcessor()) { - pollingProcessor.start(); - - Status status = requireDataSourceStatus(statuses, State.INITIALIZING); - assertNotNull(status.getLastError()); - assertEquals(ErrorKind.INVALID_DATA, status.getLastError().getKind()); - assertEquals(requestor.runtimeException.toString(), status.getLastError().getMessage()); - - assertFalse(pollingProcessor.isInitialized()); + try (HttpServer server = HttpServer.start(badDataHandler)) { + try (PollingProcessor pollingProcessor = makeProcessor(server.getUri(), LENGTHY_INTERVAL)) { + pollingProcessor.start(); + + Status status = requireDataSourceStatus(statuses, State.INITIALIZING); + assertNotNull(status.getLastError()); + assertEquals(ErrorKind.INVALID_DATA, status.getLastError().getKind()); + + assertFalse(pollingProcessor.isInitialized()); + } } } - @Test - public void testUnknownException() throws Exception { - requestor.runtimeException = new RuntimeException("everything is displeasing"); - - BlockingQueue statuses = new LinkedBlockingQueue<>(); - dataSourceUpdates.statusBroadcaster.register(statuses::add); - - try (PollingProcessor pollingProcessor = makeProcessor()) { - pollingProcessor.start(); - - Status status = requireDataSourceStatus(statuses, State.INITIALIZING); - assertNotNull(status.getLastError()); - assertEquals(ErrorKind.UNKNOWN, status.getLastError().getKind()); - assertEquals(requestor.runtimeException.toString(), status.getLastError().getMessage()); - - assertFalse(pollingProcessor.isInitialized()); - } - } - @Test public void startingWhenAlreadyStartedDoesNothing() throws Exception { - requestor.allData = new FeatureRequestor.AllData(new HashMap<>(), new HashMap<>()); - - try (PollingProcessor pollingProcessor = makeProcessor(Duration.ofMillis(500))) { - Future initFuture1 = pollingProcessor.start(); - - awaitValue(requestor.queries, Duration.ofMillis(100)); // a poll request was made - - Future initFuture2 = pollingProcessor.start(); - assertSame(initFuture1, initFuture2); - - - expectNoMoreValues(requestor.queries, Duration.ofMillis(100)); // we did NOT start another polling task + try (HttpServer server = HttpServer.start(new TestPollHandler())) { + try (PollingProcessor pollingProcessor = makeProcessor(server.getUri(), LENGTHY_INTERVAL)) { + Future initFuture1 = pollingProcessor.start(); + shouldNotTimeOut(initFuture1, Duration.ofSeconds(1)); + server.getRecorder().requireRequest(); + + Future initFuture2 = pollingProcessor.start(); + assertSame(initFuture1, initFuture2); + server.getRecorder().requireNoRequests(Duration.ofMillis(100)); + } } } @@ -264,43 +262,51 @@ public void http500ErrorIsRecoverable() throws Exception { } private void testUnrecoverableHttpError(int statusCode) throws Exception { - HttpErrorException httpError = new HttpErrorException(statusCode); + TestPollHandler handler = new TestPollHandler(); // Test a scenario where the very first request gets this error + handler.setError(statusCode); withStatusQueue(statuses -> { - requestor.httpException = httpError; - - try (PollingProcessor pollingProcessor = makeProcessor()) { - long startTime = System.currentTimeMillis(); - Future initFuture = pollingProcessor.start(); - - shouldNotTimeOut(initFuture, Duration.ofSeconds(2)); - assertTrue((System.currentTimeMillis() - startTime) < 9000); - assertTrue(initFuture.isDone()); - assertFalse(pollingProcessor.isInitialized()); - - verifyHttpErrorCausedShutdown(statuses, statusCode); + try (HttpServer server = HttpServer.start(handler)) { + try (PollingProcessor pollingProcessor = makeProcessor(server.getUri(), BRIEF_INTERVAL)) { + long startTime = System.currentTimeMillis(); + Future initFuture = pollingProcessor.start(); + + shouldNotTimeOut(initFuture, Duration.ofSeconds(2)); + assertTrue((System.currentTimeMillis() - startTime) < 9000); + assertTrue(initFuture.isDone()); + assertFalse(pollingProcessor.isInitialized()); + + verifyHttpErrorCausedShutdown(statuses, statusCode); + + server.getRecorder().requireRequest(); + server.getRecorder().requireNoRequests(Duration.ofMillis(100)); + } } }); - // Now test a scenario where we have a successful startup, but the next poll gets the error + // Now test a scenario where we have a successful startup, but a subsequent poll gets the error + handler.setError(0); dataSourceUpdates = TestComponents.dataSourceUpdates(new InMemoryDataStore(), new MockDataStoreStatusProvider()); withStatusQueue(statuses -> { - requestor = new MockFeatureRequestor(); - requestor.allData = new FeatureRequestor.AllData(new HashMap<>(), new HashMap<>()); - - try (PollingProcessor pollingProcessor = makeProcessor(BRIEF_INTERVAL)) { - Future initFuture = pollingProcessor.start(); - - shouldNotTimeOut(initFuture, Duration.ofSeconds(20000)); - assertTrue(initFuture.isDone()); - assertTrue(pollingProcessor.isInitialized()); - requireDataSourceStatusEventually(statuses, State.VALID, State.INITIALIZING); - - // cause the next poll to get an error - requestor.httpException = httpError; - - verifyHttpErrorCausedShutdown(statuses, statusCode); + try (HttpServer server = HttpServer.start(handler)) { + try (PollingProcessor pollingProcessor = makeProcessor(server.getUri(), BRIEF_INTERVAL)) { + Future initFuture = pollingProcessor.start(); + + shouldNotTimeOut(initFuture, Duration.ofSeconds(20000)); + assertTrue(initFuture.isDone()); + assertTrue(pollingProcessor.isInitialized()); + requireDataSourceStatus(statuses, State.VALID); + + // now make it so polls fail + handler.setError(statusCode); + + verifyHttpErrorCausedShutdown(statuses, statusCode); + while (server.getRecorder().count() > 0) { + server.getRecorder().requireRequest(); + } + server.getRecorder().requireNoRequests(Duration.ofMillis(100)); + } } }); } @@ -313,87 +319,65 @@ private void verifyHttpErrorCausedShutdown(BlockingQueue { - requestor.httpException = httpError; - - try (PollingProcessor pollingProcessor = makeProcessor(BRIEF_INTERVAL)) { - Future initFuture = pollingProcessor.start(); - - // first poll gets an error - shouldTimeOut(initFuture, Duration.ofMillis(200)); - assertFalse(initFuture.isDone()); - assertFalse(pollingProcessor.isInitialized()); - - Status status0 = requireDataSourceStatus(statuses, State.INITIALIZING); - assertNotNull(status0.getLastError()); - assertEquals(ErrorKind.ERROR_RESPONSE, status0.getLastError().getKind()); - assertEquals(statusCode, status0.getLastError().getStatusCode()); - - verifyHttpErrorWasRecoverable(statuses, statusCode, false); - - shouldNotTimeOut(initFuture, Duration.ofSeconds(2)); - assertTrue(initFuture.isDone()); - assertTrue(pollingProcessor.isInitialized()); + try (HttpServer server = HttpServer.start(handler)) { + try (PollingProcessor pollingProcessor = makeProcessor(server.getUri(), BRIEF_INTERVAL)) { + Future initFuture = pollingProcessor.start(); + + // make sure it's done a couple of polls (which will have failed) + server.getRecorder().requireRequest(); + server.getRecorder().requireRequest(); + + // now make it so polls will succeed + handler.setError(0); + + shouldNotTimeOut(initFuture, Duration.ofSeconds(1)); + + // verify that it got the error + Status status0 = requireDataSourceStatus(statuses, State.INITIALIZING); + assertNotNull(status0.getLastError()); + assertEquals(ErrorKind.ERROR_RESPONSE, status0.getLastError().getKind()); + assertEquals(statusCode, status0.getLastError().getStatusCode()); + + // and then that it succeeded + requireDataSourceStatusEventually(statuses, State.VALID, State.INITIALIZING); + } } }); - // Now test a scenario where we have a successful startup, but the next poll gets the error + // Now test a scenario where we have a successful startup, but then it gets the error. + // The result is a bit different because it will report an INTERRUPTED state. + handler.setError(0); dataSourceUpdates = TestComponents.dataSourceUpdates(new InMemoryDataStore(), new MockDataStoreStatusProvider()); withStatusQueue(statuses -> { - requestor = new MockFeatureRequestor(); - requestor.allData = new FeatureRequestor.AllData(new HashMap<>(), new HashMap<>()); - - try (PollingProcessor pollingProcessor = makeProcessor(BRIEF_INTERVAL)) { - Future initFuture = pollingProcessor.start(); - - shouldNotTimeOut(initFuture, Duration.ofSeconds(20000)); - assertTrue(initFuture.isDone()); - assertTrue(pollingProcessor.isInitialized()); - requireDataSourceStatusEventually(statuses, State.VALID, State.INITIALIZING); - - // cause the next poll to get an error - requestor.httpException = httpError; - - Status status0 = requireDataSourceStatusEventually(statuses, State.INTERRUPTED, State.VALID); - assertEquals(ErrorKind.ERROR_RESPONSE, status0.getLastError().getKind()); - assertEquals(statusCode, status0.getLastError().getStatusCode()); - - verifyHttpErrorWasRecoverable(statuses, statusCode, true); + try (HttpServer server = HttpServer.start(handler)) { + try (PollingProcessor pollingProcessor = makeProcessor(server.getUri(), BRIEF_INTERVAL)) { + Future initFuture = pollingProcessor.start(); + shouldNotTimeOut(initFuture, Duration.ofSeconds(1)); + assertTrue(pollingProcessor.isInitialized()); + + // first poll succeeded + requireDataSourceStatus(statuses, State.VALID); + + // now make it so polls will fail + handler.setError(statusCode); + + Status status1 = requireDataSourceStatus(statuses, State.INTERRUPTED); + assertEquals(ErrorKind.ERROR_RESPONSE, status1.getLastError().getKind()); + assertEquals(statusCode, status1.getLastError().getStatusCode()); + + // and then succeed again + handler.setError(0); + requireDataSourceStatusEventually(statuses, State.VALID, State.INTERRUPTED); + } } }); } - private void verifyHttpErrorWasRecoverable( - BlockingQueue statuses, - int statusCode, - boolean didAlreadyConnect - ) throws Exception { - long startTime = System.currentTimeMillis(); - - // first make it so the requestor will succeed after the previous error - requestor.allData = new FeatureRequestor.AllData(new HashMap<>(), new HashMap<>()); - requestor.httpException = null; - - // status should now be VALID (although there might have been more failed polls before that) - Status status1 = requireDataSourceStatusEventually(statuses, State.VALID, - didAlreadyConnect ? State.INTERRUPTED : State.INITIALIZING); - assertNotNull(status1.getLastError()); - assertEquals(ErrorKind.ERROR_RESPONSE, status1.getLastError().getKind()); - assertEquals(statusCode, status1.getLastError().getStatusCode()); - - // simulate another error of the same kind - the state will be INTERRUPTED - requestor.httpException = new HttpErrorException(statusCode); - - Status status2 = requireDataSourceStatusEventually(statuses, State.INTERRUPTED, State.VALID); - assertNotNull(status2.getLastError()); - assertEquals(ErrorKind.ERROR_RESPONSE, status2.getLastError().getKind()); - assertEquals(statusCode, status2.getLastError().getStatusCode()); - MatcherAssert.assertThat(status2.getLastError().getTime().toEpochMilli(), greaterThanOrEqualTo(startTime)); - } - private void withStatusQueue(ActionCanThrowAnyException> action) throws Exception { BlockingQueue statuses = new LinkedBlockingQueue<>(); DataSourceStatusProvider.StatusListener addStatus = statuses::add; @@ -404,34 +388,4 @@ private void withStatusQueue(ActionCanThrowAnyException queries = new LinkedBlockingQueue<>(); - - public void close() throws IOException {} - - public AllData getAllData(boolean returnDataEvenIfCached) throws IOException, HttpErrorException { - queries.add(true); - if (gate != null) { - try { - gate.acquire(); - } catch (InterruptedException e) {} - } - if (httpException != null) { - throw httpException; - } - if (ioException != null) { - throw ioException; - } - if (runtimeException != null) { - throw runtimeException; - } - return allData; - } - } } diff --git a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java index 8a66319a5..fa096b1c7 100644 --- a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java @@ -1,103 +1,136 @@ package com.launchdarkly.sdk.server; import com.launchdarkly.eventsource.ConnectionErrorHandler; -import com.launchdarkly.eventsource.EventHandler; -import com.launchdarkly.eventsource.EventSource; import com.launchdarkly.eventsource.MessageEvent; -import com.launchdarkly.eventsource.UnsuccessfulResponseException; +import com.launchdarkly.sdk.server.DataModel.FeatureFlag; +import com.launchdarkly.sdk.server.DataModel.Segment; import com.launchdarkly.sdk.server.DataModel.VersionedData; -import com.launchdarkly.sdk.server.StreamProcessor.EventSourceParams; +import com.launchdarkly.sdk.server.DataStoreTestTypes.DataBuilder; +import com.launchdarkly.sdk.server.TestComponents.DelegatingDataStore; import com.launchdarkly.sdk.server.TestComponents.MockDataSourceUpdates; +import com.launchdarkly.sdk.server.TestComponents.MockDataSourceUpdates.UpsertParams; import com.launchdarkly.sdk.server.TestComponents.MockDataStoreStatusProvider; -import com.launchdarkly.sdk.server.TestComponents.MockEventSourceCreator; import com.launchdarkly.sdk.server.integrations.StreamingDataSourceBuilder; import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorKind; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.State; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.Status; -import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; -import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; +import com.launchdarkly.testhelpers.httptest.Handler; +import com.launchdarkly.testhelpers.httptest.Handlers; +import com.launchdarkly.testhelpers.httptest.HttpServer; +import com.launchdarkly.testhelpers.httptest.RequestInfo; -import org.easymock.EasyMockSupport; +import org.hamcrest.MatcherAssert; import org.junit.Before; import org.junit.Test; import java.io.EOFException; -import java.io.IOException; import java.net.URI; import java.time.Duration; import java.util.Map; import java.util.concurrent.BlockingQueue; -import java.util.concurrent.CompletableFuture; import java.util.concurrent.Future; import java.util.concurrent.LinkedBlockingQueue; - -import javax.net.ssl.SSLHandshakeException; +import java.util.concurrent.Semaphore; +import java.util.concurrent.atomic.AtomicInteger; import static com.launchdarkly.sdk.server.DataModel.FEATURES; import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; -import static com.launchdarkly.sdk.server.JsonHelpers.gsonInstance; import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; import static com.launchdarkly.sdk.server.ModelBuilders.segmentBuilder; import static com.launchdarkly.sdk.server.TestComponents.clientContext; import static com.launchdarkly.sdk.server.TestComponents.dataSourceUpdates; -import static com.launchdarkly.sdk.server.TestComponents.dataStoreThatThrowsException; -import static com.launchdarkly.sdk.server.TestHttpUtil.eventStreamResponse; -import static com.launchdarkly.sdk.server.TestHttpUtil.makeStartedServer; -import static com.launchdarkly.sdk.server.TestUtil.makeSocketFactorySingleHost; import static com.launchdarkly.sdk.server.TestUtil.requireDataSourceStatus; import static com.launchdarkly.sdk.server.TestUtil.shouldNotTimeOut; -import static com.launchdarkly.sdk.server.TestUtil.shouldTimeOut; -import static com.launchdarkly.sdk.server.integrations.StreamingDataSourceBuilder.DEFAULT_INITIAL_RECONNECT_DELAY; -import static org.easymock.EasyMock.expectLastCall; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.endsWith; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.greaterThanOrEqualTo; import static org.hamcrest.Matchers.lessThanOrEqualTo; +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.assertNotSame; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; -import okhttp3.HttpUrl; -import okhttp3.mockwebserver.MockWebServer; - @SuppressWarnings("javadoc") -public class StreamProcessorTest extends EasyMockSupport { +public class StreamProcessorTest { private static final String SDK_KEY = "sdk_key"; - private static final URI STREAM_URI = URI.create("http://stream.test.com/"); - private static final URI STREAM_URI_WITHOUT_SLASH = URI.create("http://stream.test.com"); + private static final Duration BRIEF_RECONNECT_DELAY = Duration.ofMillis(10); private static final String FEATURE1_KEY = "feature1"; private static final int FEATURE1_VERSION = 11; private static final DataModel.FeatureFlag FEATURE = flagBuilder(FEATURE1_KEY).version(FEATURE1_VERSION).build(); private static final String SEGMENT1_KEY = "segment1"; private static final int SEGMENT1_VERSION = 22; private static final DataModel.Segment SEGMENT = segmentBuilder(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 static final String EMPTY_DATA_EVENT = makePutEvent(new DataBuilder().addAny(FEATURES).addAny(SEGMENTS)); private InMemoryDataStore dataStore; private MockDataSourceUpdates dataSourceUpdates; private MockDataStoreStatusProvider dataStoreStatusProvider; - private EventSource mockEventSource; - private MockEventSourceCreator mockEventSourceCreator; + private static Handler streamResponse(String data) { + return Handlers.all( + Handlers.SSE.start(), + Handlers.SSE.event(data), + Handlers.SSE.leaveOpen() + ); + } + + private static Handler closableStreamResponse(String data, Semaphore closeSignal) { + return Handlers.all( + Handlers.SSE.start(), + Handlers.SSE.event(data), + Handlers.waitFor(closeSignal) + ); + } + + private static Handler streamResponseFromQueue(BlockingQueue events) { + return Handlers.all( + Handlers.SSE.start(), + ctx -> { + while (true) { + try { + String event = events.take(); + Handlers.SSE.event(event).apply(ctx); + } catch (InterruptedException e) { + break; + } + } + } + ); + } + + private static String makeEvent(String type, String data) { + return "event: " + type + "\ndata: " + data; + } + + private static String makePutEvent(DataBuilder data) { + return makeEvent("put", "{\"data\":" + data.buildJson().toJsonString() + "}"); + } + + private static String makePatchEvent(String path, DataKind kind, VersionedData item) { + String json = kind.serialize(new ItemDescriptor(item.getVersion(), item)); + return makeEvent("patch", "{\"path\":\"" + path + "\",\"data\":" + json + "}"); + } + + private static String makeDeleteEvent(String path, int version) { + return makeEvent("delete", "{\"path\":\"" + path + "\",\"version\":" + version + "}"); + } + @Before public void setup() { dataStore = new InMemoryDataStore(); dataStoreStatusProvider = new MockDataStoreStatusProvider(); dataSourceUpdates = TestComponents.dataSourceUpdates(dataStore, dataStoreStatusProvider); - mockEventSource = createMock(EventSource.class); - mockEventSourceCreator = new MockEventSourceCreator(mockEventSource); } @Test @@ -124,118 +157,128 @@ public void builderCanSpecifyConfiguration() throws Exception { } @Test - public void streamUriHasCorrectEndpoint() { - createStreamProcessor(STREAM_URI).start(); - assertEquals(URI.create(STREAM_URI.toString() + "all"), - mockEventSourceCreator.getNextReceivedParams().streamUri); + public void verifyStreamRequestProperties() throws Exception { + HttpConfiguration httpConfig = clientContext(SDK_KEY, LDConfig.DEFAULT).getHttp(); + + try (HttpServer server = HttpServer.start(streamResponse(EMPTY_DATA_EVENT))) { + try (StreamProcessor sp = createStreamProcessor(null, server.getUri())) { + sp.start(); + + RequestInfo req = server.getRecorder().requireRequest(); + assertThat(req.getMethod(), equalTo("GET")); + assertThat(req.getPath(), equalTo("/all")); + + for (Map.Entry kv: httpConfig.getDefaultHeaders()) { + assertThat(req.getHeader(kv.getKey()), equalTo(kv.getValue())); + } + assertThat(req.getHeader("Accept"), equalTo("text/event-stream")); + } + } } @Test - public void streamBaseUriDoesNotNeedTrailingSlash() { - createStreamProcessor(STREAM_URI_WITHOUT_SLASH).start(); - assertEquals(URI.create(STREAM_URI_WITHOUT_SLASH.toString() + "/all"), - mockEventSourceCreator.getNextReceivedParams().streamUri); + public void streamBaseUriDoesNotNeedTrailingSlash() throws Exception { + try (HttpServer server = HttpServer.start(streamResponse(EMPTY_DATA_EVENT))) { + URI baseUri = server.getUri(); + MatcherAssert.assertThat(baseUri.toString(), endsWith("/")); + URI trimmedUri = URI.create(server.getUri().toString().substring(0, server.getUri().toString().length() - 1)); + try (StreamProcessor sp = createStreamProcessor(null, trimmedUri)) { + sp.start(); + + RequestInfo req = server.getRecorder().requireRequest(); + assertThat(req.getPath(), equalTo("/all")); + } + } } @Test - public void streamBaseUriCanHaveContextPath() { - createStreamProcessor(URI.create(STREAM_URI.toString() + "/context/path")).start(); - assertEquals(URI.create(STREAM_URI.toString() + "/context/path/all"), - mockEventSourceCreator.getNextReceivedParams().streamUri); - } - - @Test - public void basicHeadersAreSent() { - HttpConfiguration httpConfig = clientContext(SDK_KEY, LDConfig.DEFAULT).getHttp(); - - createStreamProcessor(STREAM_URI).start(); - EventSourceParams params = mockEventSourceCreator.getNextReceivedParams(); - - for (Map.Entry kv: httpConfig.getDefaultHeaders()) { - assertThat(params.headers.get(kv.getKey()), equalTo(kv.getValue())); + public void streamBaseUriCanHaveContextPath() throws Exception { + try (HttpServer server = HttpServer.start(streamResponse(EMPTY_DATA_EVENT))) { + URI baseUri = server.getUri().resolve("/context/path"); + try (StreamProcessor sp = createStreamProcessor(null, baseUri)) { + sp.start(); + + RequestInfo req = server.getRecorder().requireRequest(); + assertThat(req.getPath(), equalTo("/context/path/all")); + } } } - @Test - public void headersHaveAccept() { - createStreamProcessor(STREAM_URI).start(); - assertEquals("text/event-stream", - mockEventSourceCreator.getNextReceivedParams().headers.get("Accept")); - } - @Test public void putCausesFeatureToBeStored() throws Exception { - expectNoStreamRestart(); - - createStreamProcessor(STREAM_URI).start(); - EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; - - MessageEvent event = new MessageEvent("{\"data\":{\"flags\":{\"" + - FEATURE1_KEY + "\":" + featureJson(FEATURE1_KEY, FEATURE1_VERSION) + "}," + - "\"segments\":{}}}"); - handler.onMessage("put", event); + FeatureFlag flag = flagBuilder(FEATURE1_KEY).version(FEATURE1_VERSION).build(); + DataBuilder data = new DataBuilder().addAny(FEATURES, flag).addAny(SEGMENTS); + Handler streamHandler = streamResponse(makePutEvent(data)); - assertFeatureInStore(FEATURE); + try (HttpServer server = HttpServer.start(streamHandler)) { + try (StreamProcessor sp = createStreamProcessor(null, server.getUri())) { + sp.start(); + + dataSourceUpdates.awaitInit(); + assertFeatureInStore(flag); + } + } } @Test public void putCausesSegmentToBeStored() throws Exception { - expectNoStreamRestart(); - - createStreamProcessor(STREAM_URI).start(); - EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; - - MessageEvent event = new MessageEvent("{\"data\":{\"flags\":{},\"segments\":{\"" + - SEGMENT1_KEY + "\":" + segmentJson(SEGMENT1_KEY, SEGMENT1_VERSION) + "}}}"); - handler.onMessage("put", event); - - assertSegmentInStore(SEGMENT); + Segment segment = ModelBuilders.segmentBuilder(SEGMENT1_KEY).version(SEGMENT1_VERSION).build(); + DataBuilder data = new DataBuilder().addAny(FEATURES).addAny(SEGMENTS, segment); + Handler streamHandler = streamResponse(makePutEvent(data)); + + try (HttpServer server = HttpServer.start(streamHandler)) { + try (StreamProcessor sp = createStreamProcessor(null, server.getUri())) { + sp.start(); + + dataSourceUpdates.awaitInit(); + assertSegmentInStore(SEGMENT); + } + } } @Test public void storeNotInitializedByDefault() throws Exception { - createStreamProcessor(STREAM_URI).start(); - assertFalse(dataStore.isInitialized()); - } - - @Test - public void putCausesStoreToBeInitialized() throws Exception { - createStreamProcessor(STREAM_URI).start(); - EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; - handler.onMessage("put", emptyPutEvent()); - assertTrue(dataStore.isInitialized()); + try (HttpServer server = HttpServer.start(streamResponse(""))) { + try (StreamProcessor sp = createStreamProcessor(null, server.getUri())) { + sp.start(); + assertFalse(dataStore.isInitialized()); + } + } } @Test public void processorNotInitializedByDefault() throws Exception { - StreamProcessor sp = createStreamProcessor(STREAM_URI); - sp.start(); - assertFalse(sp.isInitialized()); - } - - @Test - public void putCausesProcessorToBeInitialized() throws Exception { - StreamProcessor sp = createStreamProcessor(STREAM_URI); - sp.start(); - EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; - handler.onMessage("put", emptyPutEvent()); - assertTrue(sp.isInitialized()); + try (HttpServer server = HttpServer.start(streamResponse(""))) { + try (StreamProcessor sp = createStreamProcessor(null, server.getUri())) { + sp.start(); + assertFalse(sp.isInitialized()); + } + } } @Test public void futureIsNotSetByDefault() throws Exception { - StreamProcessor sp = createStreamProcessor(STREAM_URI); - Future future = sp.start(); - assertFalse(future.isDone()); + try (HttpServer server = HttpServer.start(streamResponse(""))) { + try (StreamProcessor sp = createStreamProcessor(server.getUri())) { + Future future = sp.start(); + assertFalse(future.isDone()); + } + } } @Test - public void putCausesFutureToBeSet() throws Exception { - StreamProcessor sp = createStreamProcessor(STREAM_URI); - Future future = sp.start(); - EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; - handler.onMessage("put", emptyPutEvent()); - assertTrue(future.isDone()); + public void putCausesStoreAndProcessorToBeInitialized() throws Exception { + try (HttpServer server = HttpServer.start(streamResponse(EMPTY_DATA_EVENT))) { + try (StreamProcessor sp = createStreamProcessor(null, server.getUri())) { + Future future = sp.start(); + + dataSourceUpdates.awaitInit(); + shouldNotTimeOut(future, Duration.ofSeconds(1)); + assertTrue(dataStore.isInitialized()); + assertTrue(sp.isInitialized()); + assertTrue(future.isDone()); + } + } } @Test @@ -249,19 +292,26 @@ public void patchUpdatesSegment() throws Exception { } private void doPatchSuccessTest(DataKind kind, VersionedData item, String path) throws Exception { - expectNoStreamRestart(); - - createStreamProcessor(STREAM_URI).start(); - EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; - handler.onMessage("put", emptyPutEvent()); - - String json = kind.serialize(new ItemDescriptor(item.getVersion(), item)); - MessageEvent event = new MessageEvent("{\"path\":\"" + path + "\",\"data\":" + json + "}"); - handler.onMessage("patch", event); + BlockingQueue events = new LinkedBlockingQueue<>(); + events.add(EMPTY_DATA_EVENT); - ItemDescriptor result = dataStore.get(kind, item.getKey()); - assertNotNull(result.getItem()); - assertEquals(item.getVersion(), result.getVersion()); + try (HttpServer server = HttpServer.start(streamResponseFromQueue(events))) { + try (StreamProcessor sp = createStreamProcessor(null, server.getUri())) { + sp.start(); + dataSourceUpdates.awaitInit(); + + events.add(makePatchEvent(path, kind, item)); + UpsertParams gotUpsert = dataSourceUpdates.awaitUpsert(); + + assertThat(gotUpsert.kind, equalTo(kind)); + assertThat(gotUpsert.key, equalTo(item.getKey())); + assertThat(gotUpsert.item.getVersion(), equalTo(item.getVersion())); + + ItemDescriptor result = dataStore.get(kind, item.getKey()); + assertNotNull(result.getItem()); + assertEquals(item.getVersion(), result.getVersion()); + } + } } @Test @@ -275,106 +325,100 @@ public void deleteDeletesSegment() throws Exception { } private void doDeleteSuccessTest(DataKind kind, VersionedData item, String path) throws Exception { - expectNoStreamRestart(); - - createStreamProcessor(STREAM_URI).start(); - EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; - handler.onMessage("put", emptyPutEvent()); - dataStore.upsert(kind, item.getKey(), new ItemDescriptor(item.getVersion(), item)); + BlockingQueue events = new LinkedBlockingQueue<>(); + events.add(EMPTY_DATA_EVENT); - MessageEvent event = new MessageEvent("{\"path\":\"" + path + "\",\"version\":" + - (item.getVersion() + 1) + "}"); - handler.onMessage("delete", event); - - assertEquals(ItemDescriptor.deletedItem(item.getVersion() + 1), dataStore.get(kind, item.getKey())); + try (HttpServer server = HttpServer.start(streamResponseFromQueue(events))) { + try (StreamProcessor sp = createStreamProcessor(null, server.getUri())) { + sp.start(); + dataSourceUpdates.awaitInit(); + + dataStore.upsert(kind, item.getKey(), new ItemDescriptor(item.getVersion(), item)); + + events.add(makeDeleteEvent(path, item.getVersion() + 1)); + UpsertParams gotUpsert = dataSourceUpdates.awaitUpsert(); + + assertThat(gotUpsert.kind, equalTo(kind)); + assertThat(gotUpsert.key, equalTo(item.getKey())); + assertThat(gotUpsert.item.getVersion(), equalTo(item.getVersion() + 1)); + + assertEquals(ItemDescriptor.deletedItem(item.getVersion() + 1), dataStore.get(kind, item.getKey())); + } + } } @Test - public void unknownEventTypeDoesNotThrowException() throws Exception { - createStreamProcessor(STREAM_URI).start(); - EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; - handler.onMessage("what", new MessageEvent("")); + public void unknownEventTypeDoesNotCauseError() throws Exception { + verifyEventCausesNoStreamRestart("what", ""); } @Test public void streamWillReconnectAfterGeneralIOException() throws Exception { - createStreamProcessor(STREAM_URI).start(); - ConnectionErrorHandler errorHandler = mockEventSourceCreator.getNextReceivedParams().errorHandler; - ConnectionErrorHandler.Action action = errorHandler.onConnectionError(new IOException()); - assertEquals(ConnectionErrorHandler.Action.PROCEED, action); + Handler errorHandler = Handlers.malformedResponse(); + Handler streamHandler = streamResponse(EMPTY_DATA_EVENT); + Handler errorThenSuccess = Handlers.sequential(errorHandler, streamHandler); - assertNotNull(dataSourceUpdates.getLastStatus().getLastError()); - assertEquals(ErrorKind.NETWORK_ERROR, dataSourceUpdates.getLastStatus().getLastError().getKind()); - } - - @Test - public void streamWillReconnectAfterHttpError() throws Exception { - createStreamProcessor(STREAM_URI).start(); - ConnectionErrorHandler errorHandler = mockEventSourceCreator.getNextReceivedParams().errorHandler; - ConnectionErrorHandler.Action action = errorHandler.onConnectionError(new UnsuccessfulResponseException(500)); - assertEquals(ConnectionErrorHandler.Action.PROCEED, action); - - assertNotNull(dataSourceUpdates.getLastStatus().getLastError()); - assertEquals(ErrorKind.ERROR_RESPONSE, dataSourceUpdates.getLastStatus().getLastError().getKind()); - assertEquals(500, dataSourceUpdates.getLastStatus().getLastError().getStatusCode()); - } + try (HttpServer server = HttpServer.start(errorThenSuccess)) { + try (StreamProcessor sp = createStreamProcessor(null, server.getUri())) { + startAndWait(sp); - @Test - public void streamWillReconnectAfterUnknownError() throws Exception { - createStreamProcessor(STREAM_URI).start(); - ConnectionErrorHandler errorHandler = mockEventSourceCreator.getNextReceivedParams().errorHandler; - ConnectionErrorHandler.Action action = errorHandler.onConnectionError(new RuntimeException("what?")); - assertEquals(ConnectionErrorHandler.Action.PROCEED, action); - - assertNotNull(dataSourceUpdates.getLastStatus().getLastError()); - assertEquals(ErrorKind.UNKNOWN, dataSourceUpdates.getLastStatus().getLastError().getKind()); + assertThat(server.getRecorder().count(), equalTo(2)); + assertThat(dataSourceUpdates.getLastStatus().getLastError(), notNullValue()); + assertThat(dataSourceUpdates.getLastStatus().getLastError().getKind(), equalTo(ErrorKind.NETWORK_ERROR)); + } + } } @Test public void streamInitDiagnosticRecordedOnOpen() throws Exception { DiagnosticAccumulator acc = new DiagnosticAccumulator(new DiagnosticId(SDK_KEY)); long startTime = System.currentTimeMillis(); - createStreamProcessor(LDConfig.DEFAULT, STREAM_URI, acc).start(); - EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; - handler.onMessage("put", emptyPutEvent()); - long timeAfterOpen = System.currentTimeMillis(); - DiagnosticEvent.Statistics event = acc.createEventAndReset(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)); + + try (HttpServer server = HttpServer.start(streamResponse(EMPTY_DATA_EVENT))) { + try (StreamProcessor sp = createStreamProcessor(null, server.getUri(), acc)) { + startAndWait(sp); + + long timeAfterOpen = System.currentTimeMillis(); + DiagnosticEvent.Statistics event = acc.createEventAndReset(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 { DiagnosticAccumulator acc = new DiagnosticAccumulator(new DiagnosticId(SDK_KEY)); long startTime = System.currentTimeMillis(); - createStreamProcessor(LDConfig.DEFAULT, STREAM_URI, acc).start(); - ConnectionErrorHandler errorHandler = mockEventSourceCreator.getNextReceivedParams().errorHandler; - errorHandler.onConnectionError(new IOException()); - long timeAfterOpen = System.currentTimeMillis(); - DiagnosticEvent.Statistics event = acc.createEventAndReset(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 { - DiagnosticAccumulator acc = new DiagnosticAccumulator(new DiagnosticId(SDK_KEY)); - createStreamProcessor(LDConfig.DEFAULT, STREAM_URI, acc).start(); - StreamProcessor.EventSourceParams params = mockEventSourceCreator.getNextReceivedParams(); - params.handler.onMessage("put", emptyPutEvent()); - // Drop first stream init from stream open - acc.createEventAndReset(0, 0); - params.errorHandler.onConnectionError(new IOException()); - DiagnosticEvent.Statistics event = acc.createEventAndReset(0, 0); - assertEquals(0, event.streamInits.size()); + + Handler errorHandler = Handlers.status(503); + Handler streamHandler = streamResponse(EMPTY_DATA_EVENT); + Handler errorThenSuccess = Handlers.sequential(errorHandler, streamHandler); + + try (HttpServer server = HttpServer.start(errorThenSuccess)) { + try (StreamProcessor sp = createStreamProcessor(null, server.getUri(), acc)) { + startAndWait(sp); + + long timeAfterOpen = System.currentTimeMillis(); + DiagnosticEvent.Statistics event = acc.createEventAndReset(0, 0); + + assertEquals(2, event.streamInits.size()); + DiagnosticEvent.StreamInit init0 = event.streamInits.get(0); + assertTrue(init0.failed); + assertThat(init0.timestamp, greaterThanOrEqualTo(startTime)); + assertThat(init0.timestamp, lessThanOrEqualTo(timeAfterOpen)); + assertThat(init0.durationMillis, lessThanOrEqualTo(timeAfterOpen - startTime)); + + DiagnosticEvent.StreamInit init1 = event.streamInits.get(1); + assertFalse(init1.failed); + assertThat(init1.timestamp, greaterThanOrEqualTo(init0.timestamp)); + assertThat(init1.timestamp, lessThanOrEqualTo(timeAfterOpen)); + } + } } @Test @@ -409,22 +453,22 @@ public void http500ErrorIsRecoverable() throws Exception { @Test public void putEventWithInvalidJsonCausesStreamRestart() throws Exception { - verifyInvalidDataEvent("put", "{sorry"); + verifyEventCausesStreamRestart("put", "{sorry", ErrorKind.INVALID_DATA); } @Test public void putEventWithWellFormedJsonButInvalidDataCausesStreamRestart() throws Exception { - verifyInvalidDataEvent("put", "{\"data\":{\"flags\":3}}"); + verifyEventCausesStreamRestart("put", "{\"data\":{\"flags\":3}}", ErrorKind.INVALID_DATA); } @Test public void patchEventWithInvalidJsonCausesStreamRestart() throws Exception { - verifyInvalidDataEvent("patch", "{sorry"); + verifyEventCausesStreamRestart("patch", "{sorry", ErrorKind.INVALID_DATA); } @Test public void patchEventWithWellFormedJsonButInvalidDataCausesStreamRestart() throws Exception { - verifyInvalidDataEvent("patch", "{\"path\":\"/flags/flagkey\", \"data\":{\"rules\":3}}"); + verifyEventCausesStreamRestart("patch", "{\"path\":\"/flags/flagkey\", \"data\":{\"rules\":3}}", ErrorKind.INVALID_DATA); } @Test @@ -434,12 +478,12 @@ public void patchEventWithInvalidPathCausesNoStreamRestart() throws Exception { @Test public void patchEventWithNullPathCausesStreamRestart() throws Exception { - verifyInvalidDataEvent("patch", "{\"path\":null, \"data\":{\"key\":\"flagkey\"}}"); + verifyEventCausesStreamRestart("patch", "{\"path\":null, \"data\":{\"key\":\"flagkey\"}}", ErrorKind.INVALID_DATA); } @Test public void deleteEventWithInvalidJsonCausesStreamRestart() throws Exception { - verifyInvalidDataEvent("delete", "{sorry"); + verifyEventCausesStreamRestart("delete", "{sorry", ErrorKind.INVALID_DATA); } @Test @@ -454,263 +498,168 @@ public void indirectPatchEventWithInvalidPathDoesNotCauseStreamRestart() throws @Test public void restartsStreamIfStoreNeedsRefresh() throws Exception { - CompletableFuture restarted = new CompletableFuture<>(); - mockEventSource.start(); - expectLastCall(); - mockEventSource.restart(); - expectLastCall().andAnswer(() -> { - restarted.complete(null); - return null; - }); - mockEventSource.close(); - expectLastCall(); - - replayAll(); - - try (StreamProcessor sp = createStreamProcessor(STREAM_URI)) { - sp.start(); - - dataStoreStatusProvider.updateStatus(new DataStoreStatusProvider.Status(false, false)); - dataStoreStatusProvider.updateStatus(new DataStoreStatusProvider.Status(true, true)); + try (HttpServer server = HttpServer.start(streamResponse(EMPTY_DATA_EVENT))) { + try (StreamProcessor sp = createStreamProcessor(null, server.getUri())) { + startAndWait(sp); + dataSourceUpdates.awaitInit(); + server.getRecorder().requireRequest(); + + dataStoreStatusProvider.updateStatus(new DataStoreStatusProvider.Status(false, false)); + dataStoreStatusProvider.updateStatus(new DataStoreStatusProvider.Status(true, true)); - restarted.get(); + dataSourceUpdates.awaitInit(); + server.getRecorder().requireRequest(); + server.getRecorder().requireNoRequests(Duration.ofMillis(100)); + } } } @Test public void doesNotRestartStreamIfStoreHadOutageButDoesNotNeedRefresh() throws Exception { - CompletableFuture restarted = new CompletableFuture<>(); - mockEventSource.start(); - expectLastCall(); - mockEventSource.restart(); - expectLastCall().andAnswer(() -> { - restarted.complete(null); - return null; - }); - mockEventSource.close(); - expectLastCall(); - - replayAll(); - - try (StreamProcessor sp = createStreamProcessor(STREAM_URI)) { - sp.start(); - - dataStoreStatusProvider.updateStatus(new DataStoreStatusProvider.Status(false, false)); - dataStoreStatusProvider.updateStatus(new DataStoreStatusProvider.Status(true, false)); - - Thread.sleep(500); - assertFalse(restarted.isDone()); + try (HttpServer server = HttpServer.start(streamResponse(EMPTY_DATA_EVENT))) { + try (StreamProcessor sp = createStreamProcessor(null, server.getUri())) { + startAndWait(sp); + dataSourceUpdates.awaitInit(); + server.getRecorder().requireRequest(); + + dataStoreStatusProvider.updateStatus(new DataStoreStatusProvider.Status(false, false)); + dataStoreStatusProvider.updateStatus(new DataStoreStatusProvider.Status(true, false)); + + server.getRecorder().requireNoRequests(Duration.ofMillis(100)); + } } } + private void verifyStoreErrorCausesStreamRestart(String eventName, String eventData) throws Exception { + AtomicInteger updateCount = new AtomicInteger(0); + Runnable preUpdateHook = () -> { + int count = updateCount.incrementAndGet(); + if (count == 2) { + // only fail on the 2nd update - the first is the one caused by the initial "put" in the test setup + throw new RuntimeException("sorry"); + } + }; + DelegatingDataStore delegatingStore = new DelegatingDataStore(dataStore, preUpdateHook); + dataStoreStatusProvider = new MockDataStoreStatusProvider(false); // false = the store does not provide status monitoring + dataSourceUpdates = TestComponents.dataSourceUpdates(delegatingStore, dataStoreStatusProvider); + + verifyEventCausesStreamRestart(eventName, eventData, ErrorKind.STORE_ERROR); + } + @Test public void storeFailureOnPutCausesStreamRestart() throws Exception { - MockDataSourceUpdates badUpdates = dataSourceUpdatesThatMakesUpdatesFailAndDoesNotSupportStatusMonitoring(); - expectStreamRestart(); - replayAll(); - - try (StreamProcessor sp = createStreamProcessorWithStoreUpdates(badUpdates)) { - sp.start(); - EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; - handler.onMessage("put", emptyPutEvent()); - - assertNotNull(badUpdates.getLastStatus().getLastError()); - assertEquals(ErrorKind.STORE_ERROR, badUpdates.getLastStatus().getLastError().getKind()); - } - verifyAll(); + verifyStoreErrorCausesStreamRestart("put", emptyPutEvent().getData()); } @Test public void storeFailureOnPatchCausesStreamRestart() throws Exception { - MockDataSourceUpdates badUpdates = dataSourceUpdatesThatMakesUpdatesFailAndDoesNotSupportStatusMonitoring(); - expectStreamRestart(); - replayAll(); - - try (StreamProcessor sp = createStreamProcessorWithStoreUpdates(badUpdates)) { - sp.start(); - EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; - handler.onMessage("patch", - new MessageEvent("{\"path\":\"/flags/flagkey\",\"data\":{\"key\":\"flagkey\",\"version\":1}}")); - } - verifyAll(); + String patchData = "{\"path\":\"/flags/flagkey\",\"data\":{\"key\":\"flagkey\",\"version\":1}}"; + verifyStoreErrorCausesStreamRestart("patch", patchData); } @Test public void storeFailureOnDeleteCausesStreamRestart() throws Exception { - MockDataSourceUpdates badUpdates = dataSourceUpdatesThatMakesUpdatesFailAndDoesNotSupportStatusMonitoring(); - expectStreamRestart(); - replayAll(); - - try (StreamProcessor sp = createStreamProcessorWithStoreUpdates(badUpdates)) { - sp.start(); - EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; - handler.onMessage("delete", - new MessageEvent("{\"path\":\"/flags/flagkey\",\"version\":1}")); - } - verifyAll(); - } - - @Test - public void onCommentIsIgnored() throws Exception { - // This just verifies that we are not doing anything with comment data, by passing a null instead of a string - try (StreamProcessor sp = createStreamProcessor(STREAM_URI)) { - sp.start(); - EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; - handler.onComment(null); - } + String deleteData = "{\"path\":\"/flags/flagkey\",\"version\":1}"; + verifyStoreErrorCausesStreamRestart("delete", deleteData); } @Test - public void onErrorIsIgnored() throws Exception { - expectNoStreamRestart(); - replayAll(); + public void sseCommentIsIgnored() throws Exception { + BlockingQueue events = new LinkedBlockingQueue<>(); + events.add(EMPTY_DATA_EVENT); - // EventSource won't call our onError() method because we are using a ConnectionErrorHandler instead. - try (StreamProcessor sp = createStreamProcessor(STREAM_URI)) { - sp.start(); - EventHandler handler = mockEventSourceCreator.getNextReceivedParams().handler; - handler.onError(new Exception("sorry")); + try (HttpServer server = HttpServer.start(streamResponseFromQueue(events))) { + try (StreamProcessor sp = createStreamProcessor(null, server.getUri())) { + startAndWait(sp); + + events.add(": this is a comment"); + + // Do something after the comment, just to verify that the stream is still working + events.add(makePatchEvent("/flags/" + FEATURE.getKey(), FEATURES, FEATURE)); + dataSourceUpdates.awaitUpsert(); + } + assertThat(server.getRecorder().count(), equalTo(1)); // did not restart + assertThat(dataSourceUpdates.getLastStatus().getLastError(), nullValue()); } } - - private MockDataSourceUpdates dataSourceUpdatesThatMakesUpdatesFailAndDoesNotSupportStatusMonitoring() { - DataStore badStore = dataStoreThatThrowsException(new RuntimeException("sorry")); - DataStoreStatusProvider badStoreStatusProvider = new MockDataStoreStatusProvider(false); - return TestComponents.dataSourceUpdates(badStore, badStoreStatusProvider); - } private void verifyEventCausesNoStreamRestart(String eventName, String eventData) throws Exception { - expectNoStreamRestart(); - verifyEventBehavior(eventName, eventData); - } - - private void verifyEventCausesStreamRestartWithInMemoryStore(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 handler = mockEventSourceCreator.getNextReceivedParams().handler; - handler.onMessage(eventName, new MessageEvent(eventData)); - } - verifyAll(); - } - - private void verifyInvalidDataEvent(String eventName, String eventData) throws Exception { - BlockingQueue statuses = new LinkedBlockingQueue<>(); - dataSourceUpdates.statusBroadcaster.register(statuses::add); + BlockingQueue events = new LinkedBlockingQueue<>(); + events.add(EMPTY_DATA_EVENT); - verifyEventCausesStreamRestartWithInMemoryStore(eventName, eventData); - - // We did not allow the stream to successfully process an event before causing the error, so the - // state will still be INITIALIZING, but we should be able to see that an error happened. - Status status = requireDataSourceStatus(statuses, State.INITIALIZING); - assertNotNull(status.getLastError()); - assertEquals(ErrorKind.INVALID_DATA, status.getLastError().getKind()); - } - - private void expectNoStreamRestart() throws Exception { - mockEventSource.start(); - expectLastCall().times(1); - mockEventSource.close(); - expectLastCall().times(1); - } - - private void expectStreamRestart() throws Exception { - mockEventSource.start(); - expectLastCall().times(1); - mockEventSource.restart(); - expectLastCall().times(1); - mockEventSource.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. - - @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)); - - try (StreamProcessor sp = createStreamProcessorWithRealHttp(LDConfig.DEFAULT, server.uri())) { - sp.connectionErrorHandler = errorSink; - Future ready = sp.start(); - ready.get(); + try (HttpServer server = HttpServer.start(streamResponseFromQueue(events))) { + try (StreamProcessor sp = createStreamProcessor(null, server.getUri())) { + startAndWait(sp); + + events.add(makeEvent(eventName, eventData)); - Throwable error = errorSink.errors.peek(); - assertNotNull(error); - assertEquals(SSLHandshakeException.class, error.getClass()); + // Do something after the test event, just to verify that the stream is still working + events.add(makePatchEvent("/flags/" + FEATURE.getKey(), FEATURES, FEATURE)); + dataSourceUpdates.awaitUpsert(); } + assertThat(server.getRecorder().count(), equalTo(1)); // did not restart + assertThat(dataSourceUpdates.getLastStatus().getLastError(), nullValue()); } } - - @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() - .http(Components.httpConfiguration().sslSocketFactory(server.socketFactory, server.trustManager)) - // allows us to trust the self-signed cert - .build(); - - try (StreamProcessor sp = createStreamProcessorWithRealHttp(config, server.uri())) { - sp.connectionErrorHandler = errorSink; - Future ready = sp.start(); - ready.get(); + + private void verifyEventCausesStreamRestart(String eventName, String eventData, ErrorKind expectedError) throws Exception { + BlockingQueue statuses = new LinkedBlockingQueue<>(); + dataSourceUpdates.statusBroadcaster.register(statuses::add); + + BlockingQueue events = new LinkedBlockingQueue<>(); + events.add(EMPTY_DATA_EVENT); + + try (HttpServer server = HttpServer.start(streamResponseFromQueue(events))) { + try (StreamProcessor sp = createStreamProcessor(null, server.getUri())) { + sp.start(); + dataSourceUpdates.awaitInit(); + server.getRecorder().requireRequest(); + + requireDataSourceStatus(statuses, State.VALID); + + events.add(makeEvent(eventName, eventData)); + events.add(EMPTY_DATA_EVENT); - assertNull(errorSink.errors.peek()); + server.getRecorder().requireRequest(); + dataSourceUpdates.awaitInit(); + + Status status = requireDataSourceStatus(statuses, State.INTERRUPTED); + assertThat(status.getLastError(), notNullValue()); + assertThat(status.getLastError().getKind(), equalTo(expectedError)); + + requireDataSourceStatus(statuses, State.VALID); } } } @Test - public void httpClientCanUseCustomSocketFactory() throws Exception { - final ConnectionErrorSink errorSink = new ConnectionErrorSink(); - try (MockWebServer server = makeStartedServer(eventStreamResponse(STREAM_RESPONSE_WITH_EMPTY_DATA))) { - HttpUrl serverUrl = server.url("/"); - LDConfig config = new LDConfig.Builder() - .http(Components.httpConfiguration().socketFactory(makeSocketFactorySingleHost(serverUrl.host(), serverUrl.port()))) - .build(); - - URI uriWithWrongPort = URI.create("http://localhost:1"); - try (StreamProcessor sp = createStreamProcessorWithRealHttp(config, uriWithWrongPort)) { - sp.connectionErrorHandler = errorSink; - Future ready = sp.start(); - ready.get(); + public void testSpecialHttpConfigurations() throws Exception { + Handler handler = streamResponse(EMPTY_DATA_EVENT); + + TestHttpUtil.testWithSpecialHttpConfigurations(handler, + (targetUri, goodHttpConfig) -> { + LDConfig config = new LDConfig.Builder().http(goodHttpConfig).build(); + ConnectionErrorSink errorSink = new ConnectionErrorSink(); - assertNull(errorSink.errors.peek()); - assertEquals(1, server.getRequestCount()); - } - } - } - - @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() - .http(Components.httpConfiguration().proxyHostAndPort(serverUrl.host(), serverUrl.port())) - .build(); - - try (StreamProcessor sp = createStreamProcessorWithRealHttp(config, fakeStreamUri)) { - sp.connectionErrorHandler = errorSink; - Future ready = sp.start(); - ready.get(); - - assertNull(errorSink.errors.peek()); - assertEquals(1, server.getRequestCount()); - } - } + try (StreamProcessor sp = createStreamProcessor(config, targetUri)) { + sp.connectionErrorHandler = errorSink; + startAndWait(sp); + assertNull(errorSink.errors.peek()); + } + }, + (targetUri, badHttpConfig) -> { + LDConfig config = new LDConfig.Builder().http(badHttpConfig).build(); + ConnectionErrorSink errorSink = new ConnectionErrorSink(); + + try (StreamProcessor sp = createStreamProcessor(config, targetUri)) { + sp.connectionErrorHandler = errorSink; + startAndWait(sp); + + Throwable error = errorSink.errors.peek(); + assertNotNull(error); + } + } + ); } static class ConnectionErrorSink implements ConnectionErrorHandler { @@ -725,118 +674,100 @@ public Action onConnectionError(Throwable t) { } private void testUnrecoverableHttpError(int statusCode) throws Exception { - UnsuccessfulResponseException e = new UnsuccessfulResponseException(statusCode); - long startTime = System.currentTimeMillis(); - StreamProcessor sp = createStreamProcessor(STREAM_URI); - Future initFuture = sp.start(); + Handler errorResp = Handlers.status(statusCode); BlockingQueue statuses = new LinkedBlockingQueue<>(); dataSourceUpdates.statusBroadcaster.register(statuses::add); - ConnectionErrorHandler errorHandler = mockEventSourceCreator.getNextReceivedParams().errorHandler; - ConnectionErrorHandler.Action action = errorHandler.onConnectionError(e); - assertEquals(ConnectionErrorHandler.Action.SHUTDOWN, action); - - shouldNotTimeOut(initFuture, Duration.ofSeconds(2)); - assertTrue((System.currentTimeMillis() - startTime) < 9000); - assertTrue(initFuture.isDone()); - assertFalse(sp.isInitialized()); - - Status newStatus = requireDataSourceStatus(statuses, State.OFF); - assertEquals(ErrorKind.ERROR_RESPONSE, newStatus.getLastError().getKind()); - assertEquals(statusCode, newStatus.getLastError().getStatusCode()); + try (HttpServer server = HttpServer.start(errorResp)) { + try (StreamProcessor sp = createStreamProcessor(null, server.getUri())) { + Future initFuture = sp.start(); + shouldNotTimeOut(initFuture, Duration.ofSeconds(2)); + + assertFalse(sp.isInitialized()); + + Status newStatus = requireDataSourceStatus(statuses, State.OFF); + assertEquals(ErrorKind.ERROR_RESPONSE, newStatus.getLastError().getKind()); + assertEquals(statusCode, newStatus.getLastError().getStatusCode()); + + server.getRecorder().requireRequest(); + server.getRecorder().requireNoRequests(Duration.ofMillis(50)); + } + } } private void testRecoverableHttpError(int statusCode) throws Exception { - UnsuccessfulResponseException e = new UnsuccessfulResponseException(statusCode); - long startTime = System.currentTimeMillis(); - StreamProcessor sp = createStreamProcessor(STREAM_URI); - Future initFuture = sp.start(); + Semaphore closeFirstStreamSignal = new Semaphore(0); + Handler errorResp = Handlers.status(statusCode); + Handler stream1Resp = closableStreamResponse(EMPTY_DATA_EVENT, closeFirstStreamSignal); + Handler stream2Resp = streamResponse(EMPTY_DATA_EVENT); + + // Set up the sequence of responses that we'll receive below. + Handler seriesOfResponses = Handlers.sequential(errorResp, stream1Resp, errorResp, stream2Resp); BlockingQueue statuses = new LinkedBlockingQueue<>(); dataSourceUpdates.statusBroadcaster.register(statuses::add); - // simulate error - EventSourceParams eventSourceParams = mockEventSourceCreator.getNextReceivedParams(); - ConnectionErrorHandler errorHandler = eventSourceParams.errorHandler; - ConnectionErrorHandler.Action action = errorHandler.onConnectionError(e); - assertEquals(ConnectionErrorHandler.Action.PROCEED, action); - - shouldTimeOut(initFuture, Duration.ofMillis(200)); - assertTrue((System.currentTimeMillis() - startTime) >= 200); - assertFalse(initFuture.isDone()); - assertFalse(sp.isInitialized()); - - Status failureStatus1 = requireDataSourceStatus(statuses, State.INITIALIZING); - assertEquals(ErrorKind.ERROR_RESPONSE, failureStatus1.getLastError().getKind()); - assertEquals(statusCode, failureStatus1.getLastError().getStatusCode()); - - // simulate successful retry - eventSourceParams.handler.onMessage("put", emptyPutEvent()); - - shouldNotTimeOut(initFuture, Duration.ofSeconds(2)); - assertTrue(initFuture.isDone()); - assertTrue(sp.isInitialized()); - - Status successStatus = requireDataSourceStatus(statuses, State.VALID); - assertSame(failureStatus1.getLastError(), successStatus.getLastError()); - - // simulate another error of the same kind - the difference is now the state will be INTERRUPTED - action = errorHandler.onConnectionError(e); - assertEquals(ConnectionErrorHandler.Action.PROCEED, action); - - Status failureStatus2 = requireDataSourceStatus(statuses, State.INTERRUPTED); - assertEquals(ErrorKind.ERROR_RESPONSE, failureStatus2.getLastError().getKind()); - assertEquals(statusCode, failureStatus2.getLastError().getStatusCode()); - assertNotSame(failureStatus2.getLastError(), failureStatus1.getLastError()); // a new instance of the same kind of error + try (HttpServer server = HttpServer.start(seriesOfResponses)) { + try (StreamProcessor sp = createStreamProcessor(null, server.getUri())) { + Future initFuture = sp.start(); + shouldNotTimeOut(initFuture, Duration.ofSeconds(2)); + + assertTrue(sp.isInitialized()); + + // The first stream request receives an error response (errorResp). + Status failureStatus1 = requireDataSourceStatus(statuses, State.INITIALIZING); + assertEquals(ErrorKind.ERROR_RESPONSE, failureStatus1.getLastError().getKind()); + assertEquals(statusCode, failureStatus1.getLastError().getStatusCode()); + + // It tries to reconnect, and gets a valid response (stream1Resp). Now the stream is active. + Status successStatus1 = requireDataSourceStatus(statuses, State.VALID); + assertSame(failureStatus1.getLastError(), successStatus1.getLastError()); + + // Now we'll trigger a disconnection of the stream. The SDK detects that as a + // NETWORK_ERROR. The state changes to INTERRUPTED because it was previously connected. + closeFirstStreamSignal.release(); + Status failureStatus2 = requireDataSourceStatus(statuses, State.INTERRUPTED); + assertEquals(ErrorKind.NETWORK_ERROR, failureStatus2.getLastError().getKind()); + + // It tries to reconnect, and gets another errorResp. The state is still INTERRUPTED. + Status failureStatus3 = requireDataSourceStatus(statuses, State.INTERRUPTED); + assertEquals(ErrorKind.ERROR_RESPONSE, failureStatus3.getLastError().getKind()); + assertEquals(statusCode, failureStatus3.getLastError().getStatusCode()); + + // It tries again, and finally gets a valid response (stream2Resp). + Status successStatus2 = requireDataSourceStatus(statuses, State.VALID); + assertSame(failureStatus3.getLastError(), successStatus2.getLastError()); + } + } } private StreamProcessor createStreamProcessor(URI streamUri) { return createStreamProcessor(LDConfig.DEFAULT, streamUri, null); } - private StreamProcessor createStreamProcessor(LDConfig config, URI streamUri, DiagnosticAccumulator diagnosticAccumulator) { + private StreamProcessor createStreamProcessor(LDConfig config, URI streamUri, DiagnosticAccumulator acc) { return new StreamProcessor( - clientContext(SDK_KEY, config).getHttp(), + clientContext(SDK_KEY, config == null ? LDConfig.DEFAULT : config).getHttp(), dataSourceUpdates, - mockEventSourceCreator, Thread.MIN_PRIORITY, - diagnosticAccumulator, + acc, streamUri, - DEFAULT_INITIAL_RECONNECT_DELAY + BRIEF_RECONNECT_DELAY ); } - private StreamProcessor createStreamProcessorWithRealHttp(LDConfig config, URI streamUri) { - return new StreamProcessor( - clientContext(SDK_KEY, config).getHttp(), - dataSourceUpdates, - null, - Thread.MIN_PRIORITY, - null, - streamUri, - DEFAULT_INITIAL_RECONNECT_DELAY - ); - } - - private StreamProcessor createStreamProcessorWithStoreUpdates(DataSourceUpdates storeUpdates) { - return new StreamProcessor( - clientContext(SDK_KEY, LDConfig.DEFAULT).getHttp(), - storeUpdates, - mockEventSourceCreator, - Thread.MIN_PRIORITY, - null, - STREAM_URI, - DEFAULT_INITIAL_RECONNECT_DELAY - ); + private StreamProcessor createStreamProcessor(LDConfig config, URI streamUri) { + return createStreamProcessor(config, streamUri, null); } - private String featureJson(String key, int version) { - return gsonInstance().toJson(flagBuilder(key).version(version).build()); - } - - private String segmentJson(String key, int version) { - return gsonInstance().toJson(ModelBuilders.segmentBuilder(key).version(version).build()); + private static void startAndWait(StreamProcessor sp) { + Future ready = sp.start(); + try { + ready.get(); + } catch (Exception e) { + throw new RuntimeException(e); + } } private MessageEvent emptyPutEvent() { diff --git a/src/test/java/com/launchdarkly/sdk/server/TestComponents.java b/src/test/java/com/launchdarkly/sdk/server/TestComponents.java index 342500bac..2c2519d9d 100644 --- a/src/test/java/com/launchdarkly/sdk/server/TestComponents.java +++ b/src/test/java/com/launchdarkly/sdk/server/TestComponents.java @@ -1,7 +1,6 @@ package com.launchdarkly.sdk.server; import com.google.common.util.concurrent.ThreadFactoryBuilder; -import com.launchdarkly.eventsource.EventSource; import com.launchdarkly.sdk.UserAttribute; import com.launchdarkly.sdk.server.integrations.EventProcessorBuilder; import com.launchdarkly.sdk.server.interfaces.ClientContext; @@ -25,6 +24,7 @@ import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; +import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; import com.launchdarkly.sdk.server.interfaces.PersistentDataStore; import com.launchdarkly.sdk.server.interfaces.PersistentDataStoreFactory; @@ -55,6 +55,10 @@ public static ClientContext clientContext(final String sdkKey, final LDConfig co return new ClientContextImpl(sdkKey, config, sharedExecutor, diagnosticAccumulator); } + public static HttpConfiguration defaultHttpConfiguration() { + return clientContext("", LDConfig.DEFAULT).getHttp(); + } + public static DataStore dataStoreThatThrowsException(RuntimeException e) { return new DataStoreThatThrowsException(e); } @@ -149,12 +153,26 @@ public void close() throws IOException { }; public static class MockDataSourceUpdates implements DataSourceUpdates { + public static class UpsertParams { + public final DataKind kind; + public final String key; + public final ItemDescriptor item; + + UpsertParams(DataKind kind, String key, ItemDescriptor item) { + super(); + this.kind = kind; + this.key = key; + this.item = item; + } + } + private final DataSourceUpdatesImpl wrappedInstance; private final DataStoreStatusProvider dataStoreStatusProvider; public final EventBroadcasterImpl flagChangeEventBroadcaster; public final EventBroadcasterImpl statusBroadcaster; public final BlockingQueue> receivedInits = new LinkedBlockingQueue<>(); + public final BlockingQueue receivedUpserts = new LinkedBlockingQueue<>(); public MockDataSourceUpdates(DataStore store, DataStoreStatusProvider dataStoreStatusProvider) { this.dataStoreStatusProvider = dataStoreStatusProvider; @@ -172,13 +190,16 @@ public MockDataSourceUpdates(DataStore store, DataStoreStatusProvider dataStoreS @Override public boolean init(FullDataSet allData) { + boolean result = wrappedInstance.init(allData); receivedInits.add(allData); - return wrappedInstance.init(allData); + return result; } @Override public boolean upsert(DataKind kind, String key, ItemDescriptor item) { - return wrappedInstance.upsert(kind, key, item); + boolean result = wrappedInstance.upsert(kind, key, item); + receivedUpserts.add(new UpsertParams(kind, key, item)); + return result; } @Override @@ -209,6 +230,16 @@ public FullDataSet awaitInit() { } catch (InterruptedException e) {} throw new RuntimeException("did not receive expected init call"); } + + public UpsertParams awaitUpsert() { + try { + UpsertParams value = receivedUpserts.poll(5, TimeUnit.SECONDS); + if (value != null) { + return value; + } + } catch (InterruptedException e) {} + throw new RuntimeException("did not receive expected upsert call"); + } } public static class DataStoreFactoryThatExposesUpdater implements DataStoreFactory { @@ -264,6 +295,62 @@ public CacheStats getCacheStats() { } } + public static class DelegatingDataStore implements DataStore { + private final DataStore store; + private final Runnable preUpdateHook; + + public DelegatingDataStore(DataStore store, Runnable preUpdateHook) { + this.store = store; + this.preUpdateHook = preUpdateHook; + } + + @Override + public void close() throws IOException { + store.close(); + } + + @Override + public void init(FullDataSet allData) { + if (preUpdateHook != null) { + preUpdateHook.run(); + } + store.init(allData); + } + + @Override + public ItemDescriptor get(DataKind kind, String key) { + return store.get(kind, key); + } + + @Override + public KeyedItems getAll(DataKind kind) { + return store.getAll(kind); + } + + @Override + public boolean upsert(DataKind kind, String key, ItemDescriptor item) { + if (preUpdateHook != null) { + preUpdateHook.run(); + } + return store.upsert(kind, key, item); + } + + @Override + public boolean isInitialized() { + return store.isInitialized(); + } + + @Override + public boolean isStatusMonitoringEnabled() { + return store.isStatusMonitoringEnabled(); + } + + @Override + public CacheStats getCacheStats() { + return store.getCacheStats(); + } + } + public static class MockDataStoreStatusProvider implements DataStoreStatusProvider { public final EventBroadcasterImpl statusBroadcaster; private final AtomicReference lastStatus; @@ -314,22 +401,4 @@ public CacheStats getCacheStats() { return null; } } - - public static class MockEventSourceCreator implements StreamProcessor.EventSourceCreator { - private final EventSource eventSource; - private final BlockingQueue receivedParams = new LinkedBlockingQueue<>(); - - MockEventSourceCreator(EventSource eventSource) { - this.eventSource = eventSource; - } - - public EventSource createEventSource(StreamProcessor.EventSourceParams params) { - receivedParams.add(params); - return eventSource; - } - - public StreamProcessor.EventSourceParams getNextReceivedParams() { - return receivedParams.poll(); - } - } } diff --git a/src/test/java/com/launchdarkly/sdk/server/TestHttpUtil.java b/src/test/java/com/launchdarkly/sdk/server/TestHttpUtil.java index fa45ea59d..a8d23b2e9 100644 --- a/src/test/java/com/launchdarkly/sdk/server/TestHttpUtil.java +++ b/src/test/java/com/launchdarkly/sdk/server/TestHttpUtil.java @@ -1,94 +1,157 @@ package com.launchdarkly.sdk.server; -import com.launchdarkly.sdk.server.Components; -import com.launchdarkly.sdk.server.integrations.PollingDataSourceBuilder; -import com.launchdarkly.sdk.server.integrations.StreamingDataSourceBuilder; +import com.launchdarkly.sdk.server.interfaces.HttpConfigurationFactory; +import com.launchdarkly.testhelpers.httptest.Handler; +import com.launchdarkly.testhelpers.httptest.HttpServer; +import com.launchdarkly.testhelpers.httptest.RequestInfo; +import com.launchdarkly.testhelpers.httptest.ServerTLSConfiguration; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; -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.tls.HandshakeCertificates; -import okhttp3.tls.HeldCertificate; -import okhttp3.tls.internal.TlsUtil; +import static com.launchdarkly.sdk.server.TestUtil.makeSocketFactorySingleHost; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.nullValue; 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 Logger logger = LoggerFactory.getLogger(TestHttpUtil.class); - 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 StreamingDataSourceBuilder baseStreamingConfig(MockWebServer server) { - return Components.streamingDataSource().baseURI(server.url("").uri()); + // Used for testWithSpecialHttpConfigurations + static interface HttpConfigurationTestAction { + void accept(URI targetUri, HttpConfigurationFactory httpConfig) throws IOException; } - static PollingDataSourceBuilder basePollingConfig(MockWebServer server) { - return Components.pollingDataSource().baseURI(server.url("").uri()); + /** + * A test suite for all SDK components that support our standard HTTP configuration options. + *

    + * Although all of our supported HTTP behaviors are implemented in shared code, there is no + * guarantee that all of our components are using that code, or using it correctly. So we + * should run this test suite on each component that can be affected by HttpConfigurationBuilder + * properties. It works as follows: + *

      + *
    • For each HTTP configuration variant that is expected to work (trusted certificate; + * proxy server; etc.), set up a server that will produce whatever expected response was + * specified in {@code handler}. Then run {@code testActionShouldSucceed}, which should create + * its component with the given configuration and base URI and verify that the component + * behaves correctly. + *
    • Do the same for each HTTP configuration variant that is expected to fail, but run + * {@code testActionShouldFail} instead. + *
    + * + * @param handler the response that the server should provide for all requests + * @param testActionShouldSucceed an action that asserts that the component works + * @param testActionShouldFail an action that asserts that the component does not work + * @throws IOException + */ + static void testWithSpecialHttpConfigurations( + Handler handler, + HttpConfigurationTestAction testActionShouldSucceed, + HttpConfigurationTestAction testActionShouldFail + ) throws IOException { + + testHttpClientDoesNotAllowSelfSignedCertByDefault(handler, testActionShouldFail); + testHttpClientCanBeConfiguredToAllowSelfSignedCert(handler, testActionShouldSucceed); + testHttpClientCanUseCustomSocketFactory(handler, testActionShouldSucceed); + testHttpClientCanUseProxy(handler, testActionShouldSucceed); + testHttpClientCanUseProxyWithBasicAuth(handler, testActionShouldSucceed); } - static MockResponse jsonResponse(String body) { - return new MockResponse() - .setHeader("Content-Type", "application/json") - .setBody(body); + static void testHttpClientDoesNotAllowSelfSignedCertByDefault(Handler handler, + HttpConfigurationTestAction testActionShouldFail) { + logger.warn("testHttpClientDoesNotAllowSelfSignedCertByDefault"); + try { + ServerTLSConfiguration tlsConfig = ServerTLSConfiguration.makeSelfSignedCertificate(); + try (HttpServer secureServer = HttpServer.startSecure(tlsConfig, handler)) { + testActionShouldFail.accept(secureServer.getUri(), Components.httpConfiguration()); + assertThat(secureServer.getRecorder().count(), equalTo(0)); + } + } catch (IOException e) { + throw new RuntimeException(e); + } } - - static MockResponse eventStreamResponse(String data) { - return new MockResponse() - .setHeader("Content-Type", "text/event-stream") - .setChunkedBody(data, 1000); + + static void testHttpClientCanBeConfiguredToAllowSelfSignedCert(Handler handler, + HttpConfigurationTestAction testActionShouldSucceed) { + logger.warn("testHttpClientCanBeConfiguredToAllowSelfSignedCert"); + try { + ServerTLSConfiguration tlsConfig = ServerTLSConfiguration.makeSelfSignedCertificate(); + HttpConfigurationFactory httpConfig = Components.httpConfiguration() + .sslSocketFactory(tlsConfig.getSocketFactory(), tlsConfig.getTrustManager()); + try (HttpServer secureServer = HttpServer.startSecure(tlsConfig, handler)) { + testActionShouldSucceed.accept(secureServer.getUri(), httpConfig); + assertThat(secureServer.getRecorder().count(), equalTo(1)); + } + } catch (IOException e) { + throw new RuntimeException(e); + } } - - static class ServerWithCert implements Closeable { - final MockWebServer server; - final HeldCertificate cert; - final SSLSocketFactory socketFactory; - final X509TrustManager trustManager; - - public ServerWithCert() throws IOException, GeneralSecurityException { - String hostname = InetAddress.getByName("localhost").getCanonicalHostName(); - - cert = new HeldCertificate.Builder() - .serialNumber(BigInteger.ONE) - .certificateAuthority(1) - .commonName(hostname) - .addSubjectAlternativeName(hostname) - .build(); - HandshakeCertificates hc = TlsUtil.localhost(); - socketFactory = hc.sslSocketFactory(); - trustManager = hc.trustManager(); - - server = new MockWebServer(); - server.useHttps(socketFactory, false); + static void testHttpClientCanUseCustomSocketFactory(Handler handler, + HttpConfigurationTestAction testActionShouldSucceed) { + logger.warn("testHttpClientCanUseCustomSocketFactory"); + try { + try (HttpServer server = HttpServer.start(handler)) { + HttpConfigurationFactory httpConfig = Components.httpConfiguration() + .socketFactory(makeSocketFactorySingleHost(server.getUri().getHost(), server.getPort())); + + URI uriWithWrongPort = URI.create("http://localhost:1"); + testActionShouldSucceed.accept(uriWithWrongPort, httpConfig); + assertThat(server.getRecorder().count(), equalTo(1)); + } + } catch (IOException e) { + throw new RuntimeException(e); } - - public URI uri() { - return server.url("/").uri(); + } + + static void testHttpClientCanUseProxy(Handler handler, + HttpConfigurationTestAction testActionShouldSucceed) { + logger.warn("testHttpClientCanUseProxy"); + try { + try (HttpServer server = HttpServer.start(handler)) { + HttpConfigurationFactory httpConfig = Components.httpConfiguration() + .proxyHostAndPort(server.getUri().getHost(), server.getPort()); + + URI fakeBaseUri = URI.create("http://not-a-real-host"); + testActionShouldSucceed.accept(fakeBaseUri, httpConfig); + assertThat(server.getRecorder().count(), equalTo(1)); + } + } catch (IOException e) { + throw new RuntimeException(e); } - - public void close() throws IOException { - server.close(); + } + + static void testHttpClientCanUseProxyWithBasicAuth(Handler handler, + HttpConfigurationTestAction testActionShouldSucceed) { + logger.warn("testHttpClientCanUseProxyWithBasicAuth"); + Handler proxyHandler = ctx -> { + if (ctx.getRequest().getHeader("Proxy-Authorization") == null) { + ctx.setStatus(407); + ctx.setHeader("Proxy-Authenticate", "Basic realm=x"); + } else { + handler.apply(ctx); + } + }; + try { + try (HttpServer server = HttpServer.start(proxyHandler)) { + HttpConfigurationFactory httpConfig = Components.httpConfiguration() + .proxyHostAndPort(server.getUri().getHost(), server.getPort()) + .proxyAuth(Components.httpBasicAuthentication("user", "pass")); + + URI fakeBaseUri = URI.create("http://not-a-real-host"); + testActionShouldSucceed.accept(fakeBaseUri, httpConfig); + + assertThat(server.getRecorder().count(), equalTo(2)); + RequestInfo req1 = server.getRecorder().requireRequest(); + assertThat(req1.getHeader("Proxy-Authorization"), nullValue()); + RequestInfo req2 = server.getRecorder().requireRequest(); + assertThat(req2.getHeader("Proxy-Authorization"), equalTo("Basic dXNlcjpwYXNz")); + } + } catch (IOException e) { + throw new RuntimeException(e); } } } diff --git a/src/test/java/com/launchdarkly/sdk/server/TestUtil.java b/src/test/java/com/launchdarkly/sdk/server/TestUtil.java index 9c647926a..d63ffe8f6 100644 --- a/src/test/java/com/launchdarkly/sdk/server/TestUtil.java +++ b/src/test/java/com/launchdarkly/sdk/server/TestUtil.java @@ -1,7 +1,10 @@ package com.launchdarkly.sdk.server; +import com.google.common.base.Joiner; import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Iterables; import com.google.gson.Gson; +import com.google.gson.JsonElement; import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.LDValueType; @@ -9,6 +12,8 @@ import com.launchdarkly.sdk.server.DataModel.Segment; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; @@ -37,6 +42,7 @@ import static com.launchdarkly.sdk.server.DataModel.FEATURES; import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; +import static com.launchdarkly.sdk.server.DataStoreTestTypes.toDataMap; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.not; @@ -161,6 +167,26 @@ public static DataSourceStatusProvider.Status requireDataSourceStatusEventually( }); } + public static void assertDataSetEquals(FullDataSet expected, FullDataSet actual) { + JsonElement expectedJson = TEST_GSON_INSTANCE.toJsonTree(toDataMap(expected)); + JsonElement actualJson = TEST_GSON_INSTANCE.toJsonTree(toDataMap(actual)); + assertEquals(expectedJson, actualJson); + } + + public static String describeDataSet(FullDataSet data) { + return Joiner.on(", ").join( + Iterables.transform(data.getData(), entry -> { + DataKind kind = entry.getKey(); + return "{" + kind + ": [" + + Joiner.on(", ").join( + Iterables.transform(entry.getValue().getItems(), item -> + kind.serialize(item.getValue()) + ) + ) + + "]}"; + })); + } + public static interface ActionCanThrowAnyException { void apply(T param) throws Exception; } diff --git a/src/test/resources/logback.xml b/src/test/resources/logback.xml index 6be0de84e..f27284e9d 100644 --- a/src/test/resources/logback.xml +++ b/src/test/resources/logback.xml @@ -10,4 +10,7 @@ + + + From eac6da8908161405240b4b08786ede8ca7e2d81d Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 7 Jul 2021 17:51:25 -0700 Subject: [PATCH 598/673] re-fix metadata to remove Jackson dependencies, also remove Class-Path from manifest (#295) --- build.gradle | 118 +++++++++++++++++++++++++++++++--------- packaging-test/Makefile | 64 ++++++++++++++-------- 2 files changed, 131 insertions(+), 51 deletions(-) diff --git a/build.gradle b/build.gradle index 541c54980..abb039d2b 100644 --- a/build.gradle +++ b/build.gradle @@ -80,9 +80,23 @@ ext.versions = [ "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. +// Add dependencies to "libraries.internal" that we use internally but do not expose in +// our public API. Putting dependencies here has the following effects: +// +// 1. Those classes will be embedded in the default uberjar +// (launchdarkly-java-server-sdk-n.n.n.jar), and also in the "all" jar +// (launchdarkly-java-server-sdk-n.n.n.jar). +// +// 2. The classes are renamed (shaded) within those jars, and all references to them are +// updated to use the shaded names. +// +// 3. The "thin" jar does not contain those classes, and references to them from the code +// in the "thin" jar are *not* renamed. If an application is using the "thin" jar, it is +// expected to provide those classes on its classpath. +// +// 4. They do not appear as dependences in pom.xml. +// +// 5. They are not declared as package imports or package exports in OSGI manifests. // // Note that Gson is included here but Jackson is not, even though there is some Jackson // helper code in java-sdk-common. The reason is that the SDK always needs to use Gson for @@ -104,7 +118,18 @@ libraries.internal = [ ] // 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. +// global state that must be shared between the SDK and the caller. Putting dependencies +// here has the following effects: +// +// 1. They are embedded only in the "all" jar. +// +// 2. They are not renamed/shaded, and references to them (in any jar) are not modified. +// +// 3. They *do* appear as dependencies in pom.xml. +// +// 4. In OSGi manifests, they are declared as package imports-- and, in the "all" jar, +// also as package exports (i.e. it provides them if a newer version is not available +// from an import). libraries.external = [ "org.slf4j:slf4j-api:${versions.slf4j}" ] @@ -114,6 +139,15 @@ libraries.external = [ // they are already in the application classpath; we do not want show them as a dependency // because that would cause them to be pulled in automatically in all builds. The reason // we need to even mention them here at all is for the sake of OSGi optional import headers. +// Putting dependencies here has the following effects: +// +// 1. They are not embedded in any of our jars. +// +// 2. References to them (in any jar) are not modified. +// +// 3. They do not appear as dependencies in pom.xml. +// +// 4. In OSGi manifests, they are declared as optional package imports. libraries.optional = [ "com.fasterxml.jackson.core:jackson-core:${versions.jackson}", "com.fasterxml.jackson.core:jackson-databind:${versions.jackson}" @@ -135,6 +169,7 @@ configurations { // "implementation", because "implementation" has special behavior in Gradle that prevents us // from referencing it the way we do in shadeDependencies(). internal.extendsFrom implementation + external.extendsFrom api optional imports } @@ -145,12 +180,16 @@ dependencies { testImplementation libraries.test, libraries.internal, libraries.external optional libraries.optional + internal libraries.internal + external libraries.external + commonClasses "com.launchdarkly:launchdarkly-java-sdk-common:${versions.launchdarklyJavaSdkCommon}" commonDoc "com.launchdarkly:launchdarkly-java-sdk-common:${versions.launchdarklyJavaSdkCommon}:sources" - // 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, libraries.optional + // We are *not* using the special "shadow" configuration that the Shadow plugin defines. + // It's meant to provide similar behavior to what we've defined for "external", but it + // also has other behaviors that we don't want (it would cause dependencies to appear in + // pom.xml and also in a Class-Path attribute). imports libraries.external } @@ -167,6 +206,11 @@ task generateJava(type: Copy) { filter(org.apache.tools.ant.filters.ReplaceTokens, tokens: [VERSION: version.toString()]) } +tasks.compileJava { + // See note in build-shared.gradle on the purpose of "privateImplementation" + classpath = configurations.internal + configurations.external +} + compileJava.dependsOn 'generateJava' jar { @@ -190,6 +234,8 @@ shadowJar { // No classifier means that the shaded jar becomes the default artifact classifier = '' + configurations = [ project.configurations.internal ] + dependencies { exclude(dependency('org.slf4j:.*:.*')) } @@ -220,7 +266,8 @@ task shadowJarAll(type: com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJ group = "shadow" description = "Builds a Shaded fat jar including SLF4J" from(project.convention.getPlugin(JavaPluginConvention).sourceSets.main.output) - configurations = [project.configurations.runtimeClasspath] + + configurations = [ project.configurations.internal, project.configurations.external ] exclude('META-INF/INDEX.LIST', 'META-INF/*.SF', 'META-INF/*.DSA', 'META-INF/*.RSA') exclude '**/*.kotlin_metadata' @@ -228,7 +275,8 @@ task shadowJarAll(type: com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJ exclude '**/*.kotlin_builtins' dependencies { - // Currently we don't need to exclude anything - SLF4J will be embedded, unshaded + // We don't need to exclude anything here, because we want everything to be + // embedded in the "all" jar. } // doFirst causes the following steps to be run during Gradle's execution phase rather than the @@ -313,15 +361,26 @@ 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. +// Used by shadowJar and shadowJarAll to specify which packages should be renamed. +// +// The SDK's own packages should not be renamed (even though code in those packages will be +// modified to update any references to classes that are being renamed). +// +// Dependencies that are specified in the "external" configuration should not be renamed. +// These are things that (in some distributions) might be included in our uberjar, but need +// to retain their original names so they can be seen by application code. +// +// Dependencies that are specified in the "optional" configuration should not be renamed. +// These are things that we will not be including in our uberjar anyway, but we want to make +// sure we can reference them by their original names if they are in the application +// classpath (which they may or may not be, since they are optional). // // 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) } + configurations.external.collectMany { getPackagesInDependencyJar(it) } + + configurations.optional.collectMany { getPackagesInDependencyJar(it) } def topLevelPackages = configurations.internal.collectMany { getPackagesInDependencyJar(it).collect { it.contains(".") ? it.substring(0, it.indexOf(".")) : it } @@ -558,9 +617,8 @@ def pomConfig = { developers { developer { - id 'jkodumal' - name 'John Kodumal' - email 'john@launchdarkly.com' + name 'LaunchDarkly SDK Team' + email 'sdks@launchdarkly.com' } } @@ -586,17 +644,22 @@ publishing { def root = asNode() root.appendNode('description', 'Official LaunchDarkly SDK for Java') - // 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. + // Here we need to add dependencies explicitly to the pom. The mechanism + // that the Shadow plugin provides-- adding dependencies to a configuration + // called "shadow"-- is not quite what we want, for the reasons described + // in the comments for "libraries.external" etc. (and also because of + // the known issue https://github.com/johnrengelman/shadow/issues/321). + // So we aren't using that. + if (root.getAt('dependencies') == null) { + root.appendNode('dependencies') + } def dependenciesNode = root.getAt('dependencies').get(0) - dependenciesNode.each { dependencyNode -> - def scopeNode = dependencyNode.getAt('scope').get(0) - scopeNode.setValue('compile') + configurations.external.getAllDependencies().each { dep -> + def dependencyNode = dependenciesNode.appendNode('dependency') + dependencyNode.appendNode('groupId', dep.group) + dependencyNode.appendNode('artifactId', dep.name) + dependencyNode.appendNode('version', dep.version) + dependencyNode.appendNode('scope', 'compile') } root.children().last() + pomConfig @@ -635,7 +698,8 @@ def shouldSkipSigning() { // 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.runtimeClasspath.resolvedConfiguration.resolvedArtifacts.collect { it.file } + from (configurations.internal.resolvedConfiguration.resolvedArtifacts.collect { it.file } + + configurations.external.resolvedConfiguration.resolvedArtifacts.collect { it.file }) } gitPublish { diff --git a/packaging-test/Makefile b/packaging-test/Makefile index 9ecf5a62f..e1f3b18eb 100644 --- a/packaging-test/Makefile +++ b/packaging-test/Makefile @@ -13,12 +13,16 @@ BASE_DIR:=$(shell pwd) 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-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 - export TEMP_DIR=$(BASE_DIR)/temp + +LOCAL_VERSION=99.99.99-SNAPSHOT +MAVEN_LOCAL_REPO=$(HOME)/.m2/repository +TEMP_MAVEN_OUTPUT_DIR=$(MAVEN_LOCAL_REPO)/com/launchdarkly/launchdarkly-java-server-sdk/$(LOCAL_VERSION) +SDK_DEFAULT_JAR=$(TEMP_MAVEN_OUTPUT_DIR)/launchdarkly-java-server-sdk-$(LOCAL_VERSION).jar +SDK_ALL_JAR=$(TEMP_MAVEN_OUTPUT_DIR)/launchdarkly-java-server-sdk-$(LOCAL_VERSION)-all.jar +SDK_THIN_JAR=$(TEMP_MAVEN_OUTPUT_DIR)/launchdarkly-java-server-sdk-$(LOCAL_VERSION)-thin.jar +POM_XML=$(TEMP_MAVEN_OUTPUT_DIR)/launchdarkly-java-server-sdk-$(LOCAL_VERSION).pom + 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 @@ -56,9 +60,13 @@ verify_sdk_classes= \ sdk_subpackage_names= \ $(shell cd $(PROJECT_DIR)/src/main/java/com/launchdarkly/sdk && find . ! -path . -type d | sed -e 's@^\./@@') +manifest_should_not_have_classpath= \ + echo " should not have Class-Path in manifest" && \ + ! (unzip -q -c $(1) META-INF/MANIFEST.MF | grep 'Class-Path') + caption=echo "" && echo "$(1)" -all: test-all-jar test-default-jar test-thin-jar +all: test-all-jar test-default-jar test-thin-jar test-pom clean: rm -rf $(TEMP_DIR)/* @@ -83,6 +91,7 @@ test-all-jar-classes: $(SDK_ALL_JAR) $(TEMP_DIR) @$(call classes_should_not_contain,com/fasterxml/jackson,unshaded Jackson) @$(call classes_should_not_contain,com/launchdarkly/shaded/com/fasterxml/jackson,shaded Jackson) @$(call classes_should_not_contain,com/launchdarkly/shaded/org/slf4j,shaded SLF4j) + @$(call manifest_should_not_have_classpath,$<) test-default-jar-classes: $(SDK_DEFAULT_JAR) $(TEMP_DIR) @$(call caption,$@) @@ -95,6 +104,7 @@ test-default-jar-classes: $(SDK_DEFAULT_JAR) $(TEMP_DIR) @$(call classes_should_not_contain,com/fasterxml/jackson,unshaded Jackson) @$(call classes_should_not_contain,com/launchdarkly/shaded/com/fasterxml/jackson,shaded Jackson) @$(call classes_should_not_contain,org/slf4j,unshaded SLF4j) + @$(call manifest_should_not_have_classpath,$<) test-thin-jar-classes: $(SDK_THIN_JAR) $(TEMP_DIR) @$(call caption,$@) @@ -102,33 +112,39 @@ test-thin-jar-classes: $(SDK_THIN_JAR) $(TEMP_DIR) @$(call verify_sdk_classes) @echo " should not contain anything other than SDK classes" @! grep -v "^com/launchdarkly/sdk" $(TEMP_OUTPUT) + @$(call manifest_should_not_have_classpath,$<) -$(SDK_DEFAULT_JAR): - cd .. && ./gradlew shadowJar - -$(SDK_ALL_JAR): - cd .. && ./gradlew shadowJarAll - -$(SDK_THIN_JAR): - cd .. && ./gradlew jar +test-pom: $(POM_XML) + @$(call caption,$@) + @echo "=== contents of $<" + @cat $< + @echo "===" + @echo " should have SLF4J dependency" + @grep 'slf4j-api' >/dev/null $< || (echo " FAILED" && exit 1) + @echo " should not have any dependencies other than SLF4J" + @! grep '' $< | grep -v slf4j | grep -v launchdarkly || (echo " FAILED" && exit 1) + +$(SDK_DEFAULT_JAR) $(SDK_ALL_JAR) $(SDK_THIN_JAR) $(POM_XML): + cd .. && ./gradlew publishToMavenLocal -P version=$(LOCAL_VERSION) -P LD_SKIP_SIGNING=1 + @# publishToMavenLocal creates not only the jars but also the pom $(TEST_APP_JAR): $(SDK_THIN_JAR) $(TEST_APP_SOURCES) | $(TEMP_DIR) - mkdir -p $(TEMP_DIR)/dependencies-app - cd test-app && ../../gradlew jar - cp $(BASE_DIR)/test-app/build/libs/test-app-*.jar $@ + @mkdir -p $(TEMP_DIR)/dependencies-app + @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 - cp $(TEMP_DIR)/dependencies-app/*.jar $@ + @[ -d $@ ] || mkdir -p $@ + @cd .. && ./gradlew exportDependencies + @cp $(TEMP_DIR)/dependencies-app/*.jar $@ $(TEMP_DIR)/dependencies-external: $(TEMP_DIR)/dependencies-all - [ -d $@ ] || mkdir -p $@ - cp $(TEMP_DIR)/dependencies-all/slf4j*.jar $@ - cp $(TEMP_DIR)/dependencies-all/gson*.jar $@ - cp $(TEMP_DIR)/dependencies-all/jackson*.jar $@ + @[ -d $@ ] || mkdir -p $@ + @cp $(TEMP_DIR)/dependencies-all/slf4j*.jar $@ + @cp $(TEMP_DIR)/dependencies-all/gson*.jar $@ + @cp $(TEMP_DIR)/dependencies-all/jackson*.jar $@ $(TEMP_DIR)/dependencies-internal: $(TEMP_DIR)/dependencies-all [ -d $@ ] || mkdir -p $@ From 8db8a2d56cea9d1c6b759d9f8c894a2bd3606f3d Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 9 Aug 2021 10:34:54 -0700 Subject: [PATCH 599/673] make FeatureFlagsState.Builder.build() public (#297) --- .../sdk/server/FeatureFlagsState.java | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java b/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java index b7befe5f6..ba3cb8ff4 100644 --- a/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java +++ b/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java @@ -189,6 +189,23 @@ public Builder valid(boolean valid) { return this; } + /** + * Adds data to the builder representing the result of a feature flag evaluation. + *

    + * The {@code flagVersion}, {@code trackEvents}, and {@code debugEventsUntilDate} parameters are + * normally generated internally by the SDK; they are used if the {@link FeatureFlagsState} data + * has been passed to front-end code, to control how analytics events are generated by the front + * end. If you are using this builder in back-end test code, those values are unimportant. + * + * @param flagKey the feature flag key + * @param value the evaluated value + * @param variationIndex the evaluated variation index + * @param reason the evaluation reason + * @param flagVersion the current flag version + * @param trackEvents true if full event tracking is turned on for this flag + * @param debugEventsUntilDate if set, event debugging is turned until this time (millisecond timestamp) + * @return the builder + */ public Builder add( String flagKey, LDValue value, @@ -225,7 +242,12 @@ Builder addFlag(DataModel.FeatureFlag flag, Evaluator.EvalResult eval) { ); } - FeatureFlagsState build() { + /** + * Returns an object created from the builder state. + * + * @return an immutable {@link FeatureFlagsState} + */ + public FeatureFlagsState build() { return new FeatureFlagsState(flagMetadata.build(), valid); } } From 69aef505adc9b16cd7570d55cdad1baf4f3f2edf Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 13 Aug 2021 10:01:31 -0700 Subject: [PATCH 600/673] clean up tests using java-test-helpers 1.1.0 (#296) --- build.gradle | 2 +- .../server/DataModelSerializationTest.java | 5 +- .../DataSourceStatusProviderImplTest.java | 14 +- .../sdk/server/DataSourceUpdatesImplTest.java | 17 +- .../DataStoreStatusProviderImplTest.java | 10 +- .../sdk/server/DataStoreUpdatesImplTest.java | 13 +- .../sdk/server/DefaultEventProcessorTest.java | 5 +- .../server/DefaultEventProcessorTestBase.java | 137 +++++++------ .../sdk/server/EvaluatorClauseTest.java | 11 +- .../server/EventUserSerializationTest.java | 10 +- .../sdk/server/FeatureFlagsStateTest.java | 12 +- .../sdk/server/FlagTrackerImplTest.java | 38 ++-- .../sdk/server/LDClientEvaluationTest.java | 11 +- .../sdk/server/LDClientListenersTest.java | 38 ++-- .../PersistentDataStoreWrapperOtherTest.java | 7 +- .../sdk/server/PollingProcessorTest.java | 18 +- .../sdk/server/StreamProcessorTest.java | 9 +- .../sdk/server/TestComponents.java | 17 +- .../com/launchdarkly/sdk/server/TestUtil.java | 186 ++---------------- .../server/integrations/DataLoaderTest.java | 30 +-- .../FileDataSourceAutoUpdateTest.java | 18 +- .../integrations/FileDataSourceTestData.java | 52 ----- .../sdk/server/integrations/TestDataTest.java | 20 +- .../DataSourceStatusProviderTypesTest.java | 11 +- .../DataStoreStatusProviderTypesTest.java | 11 +- .../server/interfaces/DataStoreTypesTest.java | 19 +- 26 files changed, 255 insertions(+), 466 deletions(-) diff --git a/build.gradle b/build.gradle index abb039d2b..8ab85094d 100644 --- a/build.gradle +++ b/build.gradle @@ -161,7 +161,7 @@ libraries.test = [ "ch.qos.logback:logback-classic:1.1.7", "com.fasterxml.jackson.core:jackson-core:${versions.jackson}", "com.fasterxml.jackson.core:jackson-databind:${versions.jackson}", - "com.launchdarkly:test-helpers:1.0.0" + "com.launchdarkly:test-helpers:1.1.0" ] configurations { diff --git a/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java b/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java index b03b6e11b..f9df59679 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java @@ -24,6 +24,7 @@ import static com.launchdarkly.sdk.server.DataModel.FEATURES; import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; +import static com.launchdarkly.testhelpers.JsonAssertions.assertJsonEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; @@ -126,7 +127,7 @@ public void deletedFlagIsConvertedToAndFromJsonPlaceholder() { assertEquals(99, item.getVersion()); String json1 = FEATURES.serialize(item); - assertEquals(LDValue.parse(json0), LDValue.parse(json1)); + assertJsonEquals(json0, json1); } @Test @@ -164,7 +165,7 @@ public void deletedSegmentIsConvertedToAndFromJsonPlaceholder() { assertEquals(99, item.getVersion()); String json1 = SEGMENTS.serialize(item); - assertEquals(LDValue.parse(json0), LDValue.parse(json1)); + assertJsonEquals(json0, json1); } @Test diff --git a/src/test/java/com/launchdarkly/sdk/server/DataSourceStatusProviderImplTest.java b/src/test/java/com/launchdarkly/sdk/server/DataSourceStatusProviderImplTest.java index 91022b9f5..f229b7424 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataSourceStatusProviderImplTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataSourceStatusProviderImplTest.java @@ -4,6 +4,7 @@ import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorInfo; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.State; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.Status; +import com.launchdarkly.testhelpers.ConcurrentHelpers; import org.junit.Test; @@ -11,10 +12,11 @@ import java.time.Instant; import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; import static com.launchdarkly.sdk.server.TestComponents.sharedExecutor; -import static com.launchdarkly.sdk.server.TestUtil.awaitValue; -import static com.launchdarkly.sdk.server.TestUtil.expectNoMoreValues; +import static com.launchdarkly.testhelpers.ConcurrentHelpers.assertNoMoreValues; +import static com.launchdarkly.testhelpers.ConcurrentHelpers.trySleep; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.greaterThanOrEqualTo; @@ -62,10 +64,10 @@ public void statusListeners() throws Exception { updates.updateStatus(State.VALID, null); - Status newStatus = awaitValue(statuses, Duration.ofMillis(500)); + Status newStatus = ConcurrentHelpers.awaitValue(statuses, 500, TimeUnit.MILLISECONDS); assertThat(newStatus.getState(), equalTo(State.VALID)); - expectNoMoreValues(unwantedStatuses, Duration.ofMillis(100)); + assertNoMoreValues(unwantedStatuses, 100, TimeUnit.MILLISECONDS); } @Test @@ -79,9 +81,7 @@ public void waitForStatusWithStatusAlreadyCorrect() throws Exception { @Test public void waitForStatusSucceeds() throws Exception { new Thread(() -> { - try { - Thread.sleep(100); - } catch (InterruptedException e) {} + trySleep(100, TimeUnit.MILLISECONDS); updates.updateStatus(State.VALID, null); }).start(); diff --git a/src/test/java/com/launchdarkly/sdk/server/DataSourceUpdatesImplTest.java b/src/test/java/com/launchdarkly/sdk/server/DataSourceUpdatesImplTest.java index 226026463..319dd8648 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataSourceUpdatesImplTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataSourceUpdatesImplTest.java @@ -24,6 +24,7 @@ import java.time.Instant; import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; import static com.launchdarkly.sdk.server.DataModel.FEATURES; import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; @@ -33,9 +34,9 @@ import static com.launchdarkly.sdk.server.ModelBuilders.segmentBuilder; import static com.launchdarkly.sdk.server.TestComponents.inMemoryDataStore; import static com.launchdarkly.sdk.server.TestComponents.sharedExecutor; -import static com.launchdarkly.sdk.server.TestUtil.awaitValue; import static com.launchdarkly.sdk.server.TestUtil.expectEvents; -import static com.launchdarkly.sdk.server.TestUtil.expectNoMoreValues; +import static com.launchdarkly.testhelpers.ConcurrentHelpers.assertNoMoreValues; +import static com.launchdarkly.testhelpers.ConcurrentHelpers.awaitValue; import static org.easymock.EasyMock.replay; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; @@ -171,7 +172,7 @@ public void doesNotSendsEventOnUpdateIfItemWasNotReallyUpdated() throws Exceptio storeUpdates.upsert(FEATURES, flag2.getKey(), new ItemDescriptor(flag2.getVersion(), flag2)); - expectNoMoreValues(eventSink, Duration.ofMillis(100)); + assertNoMoreValues(eventSink, 100, TimeUnit.MILLISECONDS); } @Test @@ -360,7 +361,7 @@ public void updateStatusBroadcastsNewStatus() { ErrorInfo errorInfo = ErrorInfo.fromHttpError(401); updates.updateStatus(State.OFF, errorInfo); - Status status = awaitValue(statuses, Duration.ofMillis(500)); + Status status = awaitValue(statuses, 500, TimeUnit.MILLISECONDS); assertThat(status.getState(), is(State.OFF)); assertThat(status.getStateSince(), greaterThanOrEqualTo(timeBeforeUpdate)); @@ -382,7 +383,7 @@ public void updateStatusKeepsStateUnchangedIfStateWasInitializingAndNewStateIsIn ErrorInfo errorInfo = ErrorInfo.fromHttpError(401); updates.updateStatus(State.INTERRUPTED, errorInfo); - Status status = awaitValue(statuses, Duration.ofMillis(500)); + Status status = awaitValue(statuses, 500, TimeUnit.MILLISECONDS); assertThat(status.getState(), is(State.INITIALIZING)); assertThat(status.getStateSince(), is(originalTime)); @@ -401,7 +402,7 @@ public void updateStatusDoesNothingIfParametersHaveNoNewData() { updates.updateStatus(null, null); updates.updateStatus(State.INITIALIZING, null); - TestUtil.expectNoMoreValues(statuses, Duration.ofMillis(100)); + assertNoMoreValues(statuses, 100, TimeUnit.MILLISECONDS); } @Test @@ -426,7 +427,7 @@ public void outageTimeoutLogging() throws Exception { updates.updateStatus(State.VALID, null); // wait till the timeout would have elapsed - no special message should be logged - expectNoMoreValues(outageErrors, outageTimeout.plus(Duration.ofMillis(20))); + assertNoMoreValues(outageErrors, outageTimeout.plus(Duration.ofMillis(20)).toMillis(), TimeUnit.MILLISECONDS); // simulate another outage updates.updateStatus(State.INTERRUPTED, ErrorInfo.fromHttpError(501)); @@ -434,7 +435,7 @@ public void outageTimeoutLogging() throws Exception { updates.updateStatus(State.INTERRUPTED, ErrorInfo.fromException(ErrorKind.NETWORK_ERROR, new IOException("x"))); updates.updateStatus(State.INTERRUPTED, ErrorInfo.fromHttpError(501)); - String errorsDesc = awaitValue(outageErrors, Duration.ofMillis(250)); // timing is approximate + String errorsDesc = awaitValue(outageErrors, 250, TimeUnit.MILLISECONDS); // timing is approximate assertThat(errorsDesc, containsString("NETWORK_ERROR (1 time)")); assertThat(errorsDesc, containsString("ERROR_RESPONSE(501) (2 times)")); assertThat(errorsDesc, containsString("ERROR_RESPONSE(502) (1 time)")); diff --git a/src/test/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImplTest.java b/src/test/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImplTest.java index 8b395fe7c..bfcd7878c 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImplTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImplTest.java @@ -12,13 +12,13 @@ import org.junit.Test; import java.io.IOException; -import java.time.Duration; import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; import static com.launchdarkly.sdk.server.TestComponents.sharedExecutor; -import static com.launchdarkly.sdk.server.TestUtil.awaitValue; -import static com.launchdarkly.sdk.server.TestUtil.expectNoMoreValues; +import static com.launchdarkly.testhelpers.ConcurrentHelpers.assertNoMoreValues; +import static com.launchdarkly.testhelpers.ConcurrentHelpers.awaitValue; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.nullValue; @@ -56,10 +56,10 @@ public void statusListeners() throws Exception { updates.updateStatus(new Status(false, false)); - Status newStatus = awaitValue(statuses, Duration.ofMillis(500)); + Status newStatus = awaitValue(statuses, 500, TimeUnit.MILLISECONDS); assertThat(newStatus, equalTo(new Status(false, false))); - expectNoMoreValues(unwantedStatuses, Duration.ofMillis(100)); + assertNoMoreValues(unwantedStatuses, 100, TimeUnit.MILLISECONDS); } @Test diff --git a/src/test/java/com/launchdarkly/sdk/server/DataStoreUpdatesImplTest.java b/src/test/java/com/launchdarkly/sdk/server/DataStoreUpdatesImplTest.java index 6afee009b..1ed1691ff 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataStoreUpdatesImplTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataStoreUpdatesImplTest.java @@ -5,12 +5,13 @@ import org.junit.Test; -import java.time.Duration; import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; import static com.launchdarkly.sdk.server.TestComponents.sharedExecutor; -import static com.launchdarkly.sdk.server.TestUtil.expectNoMoreValues; +import static com.launchdarkly.testhelpers.ConcurrentHelpers.assertNoMoreValues; +import static com.launchdarkly.testhelpers.ConcurrentHelpers.awaitValue; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; @@ -27,10 +28,10 @@ public void updateStatusBroadcastsNewStatus() { updates.updateStatus(new Status(false, false)); - Status newStatus = TestUtil.awaitValue(statuses, Duration.ofMillis(200)); + Status newStatus = awaitValue(statuses, 200, TimeUnit.MILLISECONDS); assertThat(newStatus, equalTo(new Status(false, false))); - expectNoMoreValues(statuses, Duration.ofMillis(100)); + assertNoMoreValues(statuses, 100, TimeUnit.MILLISECONDS); } @Test @@ -40,7 +41,7 @@ public void updateStatusDoesNothingIfNewStatusIsSame() { updates.updateStatus(new Status(true, false)); - expectNoMoreValues(statuses, Duration.ofMillis(100)); + assertNoMoreValues(statuses, 100, TimeUnit.MILLISECONDS); } @Test @@ -50,6 +51,6 @@ public void updateStatusDoesNothingIfNewStatusIsNull() { updates.updateStatus(null); - expectNoMoreValues(statuses, Duration.ofMillis(100)); + assertNoMoreValues(statuses, 100, TimeUnit.MILLISECONDS); } } diff --git a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java index b9fc32585..6946627ec 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java @@ -9,6 +9,7 @@ import com.launchdarkly.sdk.server.interfaces.Event; import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; import com.launchdarkly.sdk.server.interfaces.EventSender; +import com.launchdarkly.testhelpers.JsonTestValue; import org.hamcrest.Matchers; import org.junit.Test; @@ -118,7 +119,7 @@ public void eventsAreFlushedAutomatically() throws Exception { // getEventsFromLastRequest will block until the MockEventSender receives a payload - we expect // both events to be in one payload, but if some unusual delay happened in between the two // sendEvent calls, they might be in two - Iterable payload1 = es.getEventsFromLastRequest(); + Iterable payload1 = es.getEventsFromLastRequest(); if (Iterables.size(payload1) == 1) { assertThat(payload1, contains(isCustomEvent(event1, userJson))); assertThat(es.getEventsFromLastRequest(), contains(isCustomEvent(event2, userJson))); @@ -297,7 +298,7 @@ public void eventCapacityDoesNotPreventSummaryEventFromBeingSent() throws Except } ep.flush(); - Iterable eventsReceived = es.getEventsFromLastRequest(); + Iterable eventsReceived = es.getEventsFromLastRequest(); assertThat(eventsReceived, Matchers.iterableWithSize(capacity + 1)); assertThat(Iterables.get(eventsReceived, capacity), isSummaryEvent()); diff --git a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTestBase.java b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTestBase.java index 649b9472b..532f1f24d 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTestBase.java +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTestBase.java @@ -1,5 +1,6 @@ package com.launchdarkly.sdk.server; +import com.google.common.collect.ImmutableList; import com.google.gson.Gson; import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDUser; @@ -10,6 +11,7 @@ import com.launchdarkly.sdk.server.interfaces.EventSender; import com.launchdarkly.sdk.server.interfaces.EventSenderFactory; import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; +import com.launchdarkly.testhelpers.JsonTestValue; import org.hamcrest.Matcher; @@ -23,12 +25,16 @@ import static com.launchdarkly.sdk.server.Components.sendEvents; import static com.launchdarkly.sdk.server.TestComponents.clientContext; -import static com.launchdarkly.sdk.server.TestUtil.hasJsonProperty; -import static com.launchdarkly.sdk.server.TestUtil.isJsonArray; +import static com.launchdarkly.testhelpers.ConcurrentHelpers.assertNoMoreValues; +import static com.launchdarkly.testhelpers.ConcurrentHelpers.awaitValue; +import static com.launchdarkly.testhelpers.JsonAssertions.isJsonArray; +import static com.launchdarkly.testhelpers.JsonAssertions.jsonEqualsValue; +import static com.launchdarkly.testhelpers.JsonAssertions.jsonProperty; +import static com.launchdarkly.testhelpers.JsonAssertions.jsonUndefined; +import static com.launchdarkly.testhelpers.JsonTestValue.jsonFromValue; import static org.hamcrest.Matchers.allOf; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.fail; @SuppressWarnings("javadoc") public abstract class DefaultEventProcessorTestBase { @@ -124,108 +130,115 @@ public Result sendEventData(EventDataKind kind, String data, int eventCount, URI return result; } - Params awaitRequest() throws InterruptedException { - Params p = receivedParams.poll(5, TimeUnit.SECONDS); - if (p == null) { - fail("did not receive event post within 5 seconds"); - } - return p; + Params awaitRequest() { + return awaitValue(receivedParams, 5, TimeUnit.SECONDS); } - void expectNoRequests(Duration timeout) throws InterruptedException { - Params p = receivedParams.poll(timeout.toMillis(), TimeUnit.MILLISECONDS); - if (p != null) { - fail("received unexpected event payload"); - } + void expectNoRequests(Duration timeout) { + assertNoMoreValues(receivedParams, timeout.toMillis(), TimeUnit.MILLISECONDS); } - Iterable getEventsFromLastRequest() throws InterruptedException { + Iterable getEventsFromLastRequest() { Params p = awaitRequest(); LDValue a = LDValue.parse(p.data); assertEquals(p.eventCount, a.size()); - return a.values(); + ImmutableList.Builder ret = ImmutableList.builder(); + for (LDValue v: a.values()) { + ret.add(jsonFromValue(v)); + } + return ret.build(); } } - public static Matcher isIdentifyEvent(Event sourceEvent, LDValue user) { + public static Matcher isIdentifyEvent(Event sourceEvent, LDValue user) { return allOf( - hasJsonProperty("kind", "identify"), - hasJsonProperty("creationDate", (double)sourceEvent.getCreationDate()), - hasJsonProperty("user", user) + jsonProperty("kind", "identify"), + jsonProperty("creationDate", (double)sourceEvent.getCreationDate()), + jsonProperty("user", (user == null || user.isNull()) ? jsonUndefined() : jsonEqualsValue(user)) ); } - public static Matcher isIndexEvent(Event sourceEvent, LDValue user) { + public static Matcher isIndexEvent(Event sourceEvent, LDValue user) { return allOf( - hasJsonProperty("kind", "index"), - hasJsonProperty("creationDate", (double)sourceEvent.getCreationDate()), - hasJsonProperty("user", user) + jsonProperty("kind", "index"), + jsonProperty("creationDate", (double)sourceEvent.getCreationDate()), + jsonProperty("user", jsonFromValue(user)) ); } - public static Matcher isFeatureEvent(Event.FeatureRequest sourceEvent, DataModel.FeatureFlag flag, boolean debug, LDValue inlineUser) { + public static Matcher isFeatureEvent(Event.FeatureRequest sourceEvent, DataModel.FeatureFlag flag, boolean debug, LDValue inlineUser) { return isFeatureEvent(sourceEvent, flag, debug, inlineUser, null); } @SuppressWarnings("unchecked") - public static Matcher isFeatureEvent(Event.FeatureRequest sourceEvent, DataModel.FeatureFlag flag, boolean debug, LDValue inlineUser, + public static Matcher isFeatureEvent(Event.FeatureRequest sourceEvent, DataModel.FeatureFlag flag, boolean debug, LDValue inlineUser, EvaluationReason reason) { return allOf( - hasJsonProperty("kind", debug ? "debug" : "feature"), - hasJsonProperty("creationDate", (double)sourceEvent.getCreationDate()), - hasJsonProperty("key", flag.getKey()), - hasJsonProperty("version", (double)flag.getVersion()), - hasJsonProperty("variation", sourceEvent.getVariation()), - hasJsonProperty("value", sourceEvent.getValue()), - hasJsonProperty("userKey", inlineUser == null ? LDValue.of(sourceEvent.getUser().getKey()) : LDValue.ofNull()), - hasJsonProperty("user", inlineUser == null ? LDValue.ofNull() : inlineUser), - hasJsonProperty("reason", reason == null ? LDValue.ofNull() : LDValue.parse(gson.toJson(reason))) + jsonProperty("kind", debug ? "debug" : "feature"), + jsonProperty("creationDate", (double)sourceEvent.getCreationDate()), + jsonProperty("key", flag.getKey()), + jsonProperty("version", (double)flag.getVersion()), + jsonProperty("variation", sourceEvent.getVariation()), + jsonProperty("value", jsonFromValue(sourceEvent.getValue())), + hasUserOrUserKey(sourceEvent, inlineUser), + jsonProperty("reason", reason == null ? jsonUndefined() : jsonEqualsValue(reason)) ); } - public static Matcher isPrerequisiteOf(String parentFlagKey) { - return hasJsonProperty("prereqOf", parentFlagKey); + public static Matcher isPrerequisiteOf(String parentFlagKey) { + return jsonProperty("prereqOf", parentFlagKey); } - @SuppressWarnings("unchecked") - public static Matcher isCustomEvent(Event.Custom sourceEvent, LDValue inlineUser) { + public static Matcher isCustomEvent(Event.Custom sourceEvent, LDValue inlineUser) { + boolean hasData = sourceEvent.getData() != null && !sourceEvent.getData().isNull(); return allOf( - hasJsonProperty("kind", "custom"), - hasJsonProperty("creationDate", (double)sourceEvent.getCreationDate()), - hasJsonProperty("key", sourceEvent.getKey()), - hasJsonProperty("userKey", inlineUser == null ? LDValue.of(sourceEvent.getUser().getKey()) : LDValue.ofNull()), - hasJsonProperty("user", inlineUser == null ? LDValue.ofNull() : inlineUser), - hasJsonProperty("data", sourceEvent.getData()), - hasJsonProperty("metricValue", sourceEvent.getMetricValue() == null ? LDValue.ofNull() : LDValue.of(sourceEvent.getMetricValue())) + jsonProperty("kind", "custom"), + jsonProperty("creationDate", (double)sourceEvent.getCreationDate()), + jsonProperty("key", sourceEvent.getKey()), + hasUserOrUserKey(sourceEvent, inlineUser), + jsonProperty("data", hasData ? jsonEqualsValue(sourceEvent.getData()) : jsonUndefined()), + jsonProperty("metricValue", sourceEvent.getMetricValue() == null ? jsonUndefined() : jsonEqualsValue(sourceEvent.getMetricValue())) ); } - public static Matcher isSummaryEvent() { - return hasJsonProperty("kind", "summary"); + public static Matcher hasUserOrUserKey(Event sourceEvent, LDValue inlineUser) { + if (inlineUser != null && !inlineUser.isNull()) { + return allOf( + jsonProperty("user", jsonEqualsValue(inlineUser)), + jsonProperty("userKey", jsonUndefined())); + } + return allOf( + jsonProperty("user", jsonUndefined()), + jsonProperty("userKey", sourceEvent.getUser() == null ? jsonUndefined() : + jsonEqualsValue(sourceEvent.getUser().getKey()))); + } + + public static Matcher isSummaryEvent() { + return jsonProperty("kind", "summary"); } - public static Matcher isSummaryEvent(long startDate, long endDate) { + public static Matcher isSummaryEvent(long startDate, long endDate) { return allOf( - hasJsonProperty("kind", "summary"), - hasJsonProperty("startDate", (double)startDate), - hasJsonProperty("endDate", (double)endDate) + jsonProperty("kind", "summary"), + jsonProperty("startDate", (double)startDate), + jsonProperty("endDate", (double)endDate) ); } - public static Matcher hasSummaryFlag(String key, LDValue defaultVal, Matcher> counters) { - return hasJsonProperty("features", - hasJsonProperty(key, allOf( - hasJsonProperty("default", defaultVal), - hasJsonProperty("counters", isJsonArray(counters)) + public static Matcher hasSummaryFlag(String key, LDValue defaultVal, Matcher> counters) { + return jsonProperty("features", + jsonProperty(key, allOf( + jsonProperty("default", jsonFromValue(defaultVal)), + jsonProperty("counters", isJsonArray(counters)) ))); } - public static Matcher isSummaryEventCounter(DataModel.FeatureFlag flag, Integer variation, LDValue value, int count) { + public static Matcher isSummaryEventCounter(DataModel.FeatureFlag flag, Integer variation, LDValue value, int count) { return allOf( - hasJsonProperty("variation", variation), - hasJsonProperty("version", (double)flag.getVersion()), - hasJsonProperty("value", value), - hasJsonProperty("count", (double)count) + jsonProperty("variation", variation), + jsonProperty("version", (double)flag.getVersion()), + jsonProperty("value", jsonFromValue(value)), + jsonProperty("count", (double)count) ); } } diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorClauseTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorClauseTest.java index c5731de24..01e24eb22 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EvaluatorClauseTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorClauseTest.java @@ -1,7 +1,5 @@ package com.launchdarkly.sdk.server; -import com.google.gson.Gson; -import com.google.gson.JsonElement; import com.launchdarkly.sdk.EvaluationDetail; import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDUser; @@ -19,6 +17,8 @@ import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; import static com.launchdarkly.sdk.server.ModelBuilders.ruleBuilder; import static com.launchdarkly.sdk.server.ModelBuilders.segmentBuilder; +import static com.launchdarkly.sdk.server.TestUtil.TEST_GSON_INSTANCE; +import static com.launchdarkly.testhelpers.JsonAssertions.assertJsonEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; @@ -163,13 +163,12 @@ public void clauseWithUnsupportedOperatorStringIsUnmarshalledWithNullOperator() // so we fail as gracefully as possible if a new operator type has been added in the application // and the SDK hasn't been upgraded yet. String badClauseJson = "{\"attribute\":\"name\",\"operator\":\"doesSomethingUnsupported\",\"values\":[\"x\"]}"; - Gson gson = new Gson(); - DataModel.Clause clause = gson.fromJson(badClauseJson, DataModel.Clause.class); + DataModel.Clause clause = TEST_GSON_INSTANCE.fromJson(badClauseJson, DataModel.Clause.class); assertNotNull(clause); - JsonElement json = gson.toJsonTree(clause); + String json = TEST_GSON_INSTANCE.toJson(clause); String expectedJson = "{\"attribute\":\"name\",\"values\":[\"x\"],\"negate\":false}"; - assertEquals(gson.fromJson(expectedJson, JsonElement.class), json); + assertJsonEquals(expectedJson, json); } @Test diff --git a/src/test/java/com/launchdarkly/sdk/server/EventUserSerializationTest.java b/src/test/java/com/launchdarkly/sdk/server/EventUserSerializationTest.java index 49a8fd242..bb83f97da 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EventUserSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EventUserSerializationTest.java @@ -2,7 +2,6 @@ import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; -import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.reflect.TypeToken; import com.launchdarkly.sdk.LDUser; @@ -19,6 +18,7 @@ import static com.launchdarkly.sdk.server.TestComponents.defaultEventsConfig; import static com.launchdarkly.sdk.server.TestComponents.makeEventsConfig; import static com.launchdarkly.sdk.server.TestUtil.TEST_GSON_INSTANCE; +import static com.launchdarkly.testhelpers.JsonAssertions.assertJsonEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; @@ -28,9 +28,9 @@ public class EventUserSerializationTest { @Test public void testAllPropertiesInPrivateAttributeEncoding() { for (Map.Entry e: getUserPropertiesJsonMap().entrySet()) { - JsonElement expected = TEST_GSON_INSTANCE.fromJson(e.getValue(), JsonElement.class); - JsonElement actual = TEST_GSON_INSTANCE.toJsonTree(e.getKey()); - assertEquals(expected, actual); + String expected = e.getValue(); + String actual = TEST_GSON_INSTANCE.toJson(e.getKey()); + assertJsonEquals(expected, actual); } } @@ -64,7 +64,7 @@ private Map getUserPropertiesJsonMap() { public void defaultJsonEncodingHasPrivateAttributeNames() { LDUser user = new LDUser.Builder("userkey").privateName("x").build(); String expected = "{\"key\":\"userkey\",\"name\":\"x\",\"privateAttributeNames\":[\"name\"]}"; - assertEquals(TEST_GSON_INSTANCE.fromJson(expected, JsonElement.class), TEST_GSON_INSTANCE.toJsonTree(user)); + assertJsonEquals(expected, TEST_GSON_INSTANCE.toJson(user)); } @Test diff --git a/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java b/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java index 2a0828884..539d160b8 100644 --- a/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java @@ -7,19 +7,19 @@ import com.launchdarkly.sdk.json.JsonSerialization; import com.launchdarkly.sdk.json.LDJackson; import com.launchdarkly.sdk.json.SerializationException; +import com.launchdarkly.testhelpers.TypeBehavior; import org.junit.Test; import java.util.ArrayList; import java.util.List; -import java.util.function.Supplier; import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; import static com.launchdarkly.sdk.EvaluationReason.ErrorKind.MALFORMED_FLAG; import static com.launchdarkly.sdk.server.FlagsStateOption.DETAILS_ONLY_FOR_TRACKED_FLAGS; import static com.launchdarkly.sdk.server.FlagsStateOption.WITH_REASONS; import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; -import static com.launchdarkly.sdk.server.TestUtil.verifyEqualityForType; +import static com.launchdarkly.testhelpers.JsonAssertions.assertJsonEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNotNull; @@ -139,7 +139,7 @@ public void equalInstancesAreEqual() { public void equalMetadataInstancesAreEqual() { // Testing this various cases is easier at a low level - equalInstancesAreEqual() above already // verifies that we test for metadata equality in general - List> allPermutations = new ArrayList<>(); + List> allPermutations = new ArrayList<>(); for (LDValue value: new LDValue[] { LDValue.of(1), LDValue.of(2) }) { for (Integer variation: new Integer[] { null, 0, 1 }) { for (EvaluationReason reason: new EvaluationReason[] { null, EvaluationReason.off(), EvaluationReason.fallthrough() }) { @@ -154,7 +154,7 @@ public void equalMetadataInstancesAreEqual() { } } } - verifyEqualityForType(allPermutations); + TypeBehavior.checkEqualsAndHashCode(allPermutations); } @Test @@ -167,7 +167,7 @@ public void optionsHaveHumanReadableNames() { @Test public void canConvertToJson() { String actualJsonString = JsonSerialization.serialize(makeInstanceForSerialization()); - assertEquals(LDValue.parse(makeExpectedJsonSerialization()), LDValue.parse(actualJsonString)); + assertJsonEquals(makeExpectedJsonSerialization(), actualJsonString); } @Test @@ -215,7 +215,7 @@ public void canSerializeAndDeserializeWithJackson() throws Exception { jacksonMapper.registerModule(LDJackson.module()); String actualJsonString = jacksonMapper.writeValueAsString(makeInstanceForSerialization()); - assertEquals(LDValue.parse(makeExpectedJsonSerialization()), LDValue.parse(actualJsonString)); + assertJsonEquals(makeExpectedJsonSerialization(), actualJsonString); FeatureFlagsState state = jacksonMapper.readValue(makeExpectedJsonSerialization(), FeatureFlagsState.class); assertEquals(makeInstanceForSerialization(), state); diff --git a/src/test/java/com/launchdarkly/sdk/server/FlagTrackerImplTest.java b/src/test/java/com/launchdarkly/sdk/server/FlagTrackerImplTest.java index dc77232bd..0b2d775c1 100644 --- a/src/test/java/com/launchdarkly/sdk/server/FlagTrackerImplTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/FlagTrackerImplTest.java @@ -8,15 +8,15 @@ import org.junit.Test; -import java.time.Duration; import java.util.AbstractMap; import java.util.HashMap; import java.util.Map; import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; -import static com.launchdarkly.sdk.server.TestUtil.awaitValue; -import static com.launchdarkly.sdk.server.TestUtil.expectNoMoreValues; +import static com.launchdarkly.testhelpers.ConcurrentHelpers.assertNoMoreValues; +import static com.launchdarkly.testhelpers.ConcurrentHelpers.awaitValue; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; @@ -38,26 +38,26 @@ public void flagChangeListeners() throws Exception { tracker.addFlagChangeListener(listener1); tracker.addFlagChangeListener(listener2); - expectNoMoreValues(eventSink1, Duration.ofMillis(100)); - expectNoMoreValues(eventSink2, Duration.ofMillis(100)); + assertNoMoreValues(eventSink1, 100, TimeUnit.MILLISECONDS); + assertNoMoreValues(eventSink2, 100, TimeUnit.MILLISECONDS); broadcaster.broadcast(new FlagChangeEvent(flagKey)); - FlagChangeEvent event1 = awaitValue(eventSink1, Duration.ofSeconds(1)); - FlagChangeEvent event2 = awaitValue(eventSink2, Duration.ofSeconds(1)); + FlagChangeEvent event1 = awaitValue(eventSink1, 1, TimeUnit.SECONDS); + FlagChangeEvent event2 = awaitValue(eventSink2, 1, TimeUnit.SECONDS); assertThat(event1.getKey(), equalTo("flagkey")); assertThat(event2.getKey(), equalTo("flagkey")); - expectNoMoreValues(eventSink1, Duration.ofMillis(100)); - expectNoMoreValues(eventSink2, Duration.ofMillis(100)); + assertNoMoreValues(eventSink1, 100, TimeUnit.MILLISECONDS); + assertNoMoreValues(eventSink2, 100, TimeUnit.MILLISECONDS); tracker.removeFlagChangeListener(listener1); broadcaster.broadcast(new FlagChangeEvent(flagKey)); - FlagChangeEvent event3 = awaitValue(eventSink2, Duration.ofSeconds(1)); + FlagChangeEvent event3 = awaitValue(eventSink2, 1, TimeUnit.SECONDS); assertThat(event3.getKey(), equalTo(flagKey)); - expectNoMoreValues(eventSink1, Duration.ofMillis(100)); - expectNoMoreValues(eventSink2, Duration.ofMillis(100)); + assertNoMoreValues(eventSink1, 100, TimeUnit.MILLISECONDS); + assertNoMoreValues(eventSink2, 100, TimeUnit.MILLISECONDS); } @Test @@ -83,25 +83,25 @@ public void flagValueChangeListener() throws Exception { tracker.removeFlagChangeListener(listener2); // just verifying that the remove method works tracker.addFlagValueChangeListener(flagKey, otherUser, eventSink3::add); - expectNoMoreValues(eventSink1, Duration.ofMillis(100)); - expectNoMoreValues(eventSink2, Duration.ofMillis(100)); - expectNoMoreValues(eventSink3, Duration.ofMillis(100)); + assertNoMoreValues(eventSink1, 100, TimeUnit.MILLISECONDS); + assertNoMoreValues(eventSink2, 100, TimeUnit.MILLISECONDS); + assertNoMoreValues(eventSink3, 100, TimeUnit.MILLISECONDS); // make the flag true for the first user only, and broadcast a flag change event resultMap.put(new AbstractMap.SimpleEntry<>(flagKey, user), LDValue.of(true)); broadcaster.broadcast(new FlagChangeEvent(flagKey)); // eventSink1 receives a value change event - FlagValueChangeEvent event1 = awaitValue(eventSink1, Duration.ofSeconds(1)); + FlagValueChangeEvent event1 = awaitValue(eventSink1, 1, TimeUnit.SECONDS); assertThat(event1.getKey(), equalTo(flagKey)); assertThat(event1.getOldValue(), equalTo(LDValue.of(false))); assertThat(event1.getNewValue(), equalTo(LDValue.of(true))); - expectNoMoreValues(eventSink1, Duration.ofMillis(100)); + assertNoMoreValues(eventSink1, 100, TimeUnit.MILLISECONDS); // eventSink2 doesn't receive one, because it was unregistered - expectNoMoreValues(eventSink2, Duration.ofMillis(100)); + assertNoMoreValues(eventSink2, 100, TimeUnit.MILLISECONDS); // eventSink3 doesn't receive one, because the flag's value hasn't changed for otherUser - expectNoMoreValues(eventSink2, Duration.ofMillis(100)); + assertNoMoreValues(eventSink2, 100, TimeUnit.MILLISECONDS); } } diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java index 00d553f2c..62f07123a 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java @@ -2,7 +2,6 @@ import com.google.common.collect.ImmutableMap; import com.google.gson.Gson; -import com.google.gson.JsonElement; import com.launchdarkly.sdk.EvaluationDetail; import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDUser; @@ -34,6 +33,7 @@ import static com.launchdarkly.sdk.server.TestComponents.specificDataStore; import static com.launchdarkly.sdk.server.TestUtil.upsertFlag; import static com.launchdarkly.sdk.server.TestUtil.upsertSegment; +import static com.launchdarkly.testhelpers.JsonAssertions.assertJsonEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; @@ -401,8 +401,7 @@ public void allFlagsStateReturnsState() throws Exception { "}," + "\"$valid\":true" + "}"; - JsonElement expected = gson.fromJson(json, JsonElement.class); - assertEquals(expected, gson.toJsonTree(state)); + assertJsonEquals(json, gson.toJson(state)); } @Test @@ -458,8 +457,7 @@ public void allFlagsStateReturnsStateWithReasons() { "}," + "\"$valid\":true" + "}"; - JsonElement expected = gson.fromJson(json, JsonElement.class); - assertEquals(expected, gson.toJsonTree(state)); + assertJsonEquals(json, gson.toJson(state)); } @Test @@ -506,8 +504,7 @@ public void allFlagsStateCanOmitDetailsForUntrackedFlags() { "}," + "\"$valid\":true" + "}"; - JsonElement expected = gson.fromJson(json, JsonElement.class); - assertEquals(expected, gson.toJsonTree(state)); + assertJsonEquals(json, gson.toJson(state)); } @Test diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java index 64331c0d2..811c74342 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java @@ -15,14 +15,14 @@ import org.easymock.EasyMockSupport; import org.junit.Test; -import java.time.Duration; import java.time.Instant; import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; import static com.launchdarkly.sdk.server.TestComponents.specificPersistentDataStore; -import static com.launchdarkly.sdk.server.TestUtil.awaitValue; -import static com.launchdarkly.sdk.server.TestUtil.expectNoMoreValues; +import static com.launchdarkly.testhelpers.ConcurrentHelpers.assertNoMoreValues; +import static com.launchdarkly.testhelpers.ConcurrentHelpers.awaitValue; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; @@ -62,26 +62,26 @@ public void clientSendsFlagChangeEvents() throws Exception { client.getFlagTracker().addFlagChangeListener(listener1); client.getFlagTracker().addFlagChangeListener(listener2); - expectNoMoreValues(eventSink1, Duration.ofMillis(100)); - expectNoMoreValues(eventSink2, Duration.ofMillis(100)); + assertNoMoreValues(eventSink1, 100, TimeUnit.MILLISECONDS); + assertNoMoreValues(eventSink2, 100, TimeUnit.MILLISECONDS); testData.update(testData.flag(flagKey).on(false)); - FlagChangeEvent event1 = awaitValue(eventSink1, Duration.ofSeconds(1)); - FlagChangeEvent event2 = awaitValue(eventSink2, Duration.ofSeconds(1)); + FlagChangeEvent event1 = awaitValue(eventSink1, 1, TimeUnit.SECONDS); + FlagChangeEvent event2 = awaitValue(eventSink2, 1, TimeUnit.SECONDS); assertThat(event1.getKey(), equalTo(flagKey)); assertThat(event2.getKey(), equalTo(flagKey)); - expectNoMoreValues(eventSink1, Duration.ofMillis(100)); - expectNoMoreValues(eventSink2, Duration.ofMillis(100)); + assertNoMoreValues(eventSink1, 100, TimeUnit.MILLISECONDS); + assertNoMoreValues(eventSink2, 100, TimeUnit.MILLISECONDS); client.getFlagTracker().removeFlagChangeListener(listener1); testData.update(testData.flag(flagKey).on(true)); - FlagChangeEvent event3 = awaitValue(eventSink2, Duration.ofSeconds(1)); + FlagChangeEvent event3 = awaitValue(eventSink2, 1, TimeUnit.SECONDS); assertThat(event3.getKey(), equalTo(flagKey)); - expectNoMoreValues(eventSink1, Duration.ofMillis(100)); - expectNoMoreValues(eventSink2, Duration.ofMillis(100)); + assertNoMoreValues(eventSink1, 100, TimeUnit.MILLISECONDS); + assertNoMoreValues(eventSink2, 100, TimeUnit.MILLISECONDS); } } @@ -108,9 +108,9 @@ public void clientSendsFlagValueChangeEvents() throws Exception { client.getFlagTracker().removeFlagChangeListener(listener2); // just verifying that the remove method works client.getFlagTracker().addFlagValueChangeListener(flagKey, otherUser, eventSink3::add); - expectNoMoreValues(eventSink1, Duration.ofMillis(100)); - expectNoMoreValues(eventSink2, Duration.ofMillis(100)); - expectNoMoreValues(eventSink3, Duration.ofMillis(100)); + assertNoMoreValues(eventSink1, 100, TimeUnit.MILLISECONDS); + assertNoMoreValues(eventSink2, 100, TimeUnit.MILLISECONDS); + assertNoMoreValues(eventSink3, 100, TimeUnit.MILLISECONDS); // make the flag true for the first user only, and broadcast a flag change event testData.update(testData.flag(flagKey) @@ -119,17 +119,17 @@ public void clientSendsFlagValueChangeEvents() throws Exception { .fallthroughVariation(false)); // eventSink1 receives a value change event - FlagValueChangeEvent event1 = awaitValue(eventSink1, Duration.ofSeconds(1)); + FlagValueChangeEvent event1 = awaitValue(eventSink1, 1, TimeUnit.SECONDS); assertThat(event1.getKey(), equalTo(flagKey)); assertThat(event1.getOldValue(), equalTo(LDValue.of(false))); assertThat(event1.getNewValue(), equalTo(LDValue.of(true))); - expectNoMoreValues(eventSink1, Duration.ofMillis(100)); + assertNoMoreValues(eventSink1, 100, TimeUnit.MILLISECONDS); // eventSink2 doesn't receive one, because it was unregistered - expectNoMoreValues(eventSink2, Duration.ofMillis(100)); + assertNoMoreValues(eventSink2, 100, TimeUnit.MILLISECONDS); // eventSink3 doesn't receive one, because the flag's value hasn't changed for otherUser - expectNoMoreValues(eventSink2, Duration.ofMillis(100)); + assertNoMoreValues(eventSink2, 100, TimeUnit.MILLISECONDS); } } diff --git a/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperOtherTest.java b/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperOtherTest.java index d69a4208d..52296c824 100644 --- a/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperOtherTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperOtherTest.java @@ -5,17 +5,16 @@ import com.launchdarkly.sdk.server.integrations.PersistentDataStoreBuilder.StaleValuesPolicy; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.testhelpers.TypeBehavior; import org.junit.Test; import java.time.Duration; import java.util.ArrayList; import java.util.List; -import java.util.function.Supplier; import static com.launchdarkly.sdk.server.DataStoreTestTypes.TEST_ITEMS; import static com.launchdarkly.sdk.server.TestComponents.sharedExecutor; -import static com.launchdarkly.sdk.server.TestUtil.verifyEqualityForType; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.junit.Assert.assertEquals; @@ -47,13 +46,13 @@ private PersistentDataStoreWrapper makeWrapper(Duration cacheTtl, StaleValuesPol @Test public void cacheKeyEquality() { - List> allPermutations = new ArrayList<>(); + List> allPermutations = new ArrayList<>(); for (DataKind kind: new DataKind[] { DataModel.FEATURES, DataModel.SEGMENTS }) { for (String key: new String[] { "a", "b" }) { allPermutations.add(() -> PersistentDataStoreWrapper.CacheKey.forItem(kind, key)); } } - verifyEqualityForType(allPermutations); + TypeBehavior.checkEqualsAndHashCode(allPermutations); } @Test diff --git a/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java index cf8bba201..c32bccc48 100644 --- a/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java @@ -13,6 +13,7 @@ import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.Status; import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; +import com.launchdarkly.testhelpers.ConcurrentHelpers; import com.launchdarkly.testhelpers.httptest.Handler; import com.launchdarkly.testhelpers.httptest.Handlers; import com.launchdarkly.testhelpers.httptest.HttpServer; @@ -27,6 +28,7 @@ import java.util.concurrent.Future; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; import static com.launchdarkly.sdk.server.TestComponents.clientContext; import static com.launchdarkly.sdk.server.TestComponents.dataStoreThatThrowsException; @@ -35,7 +37,7 @@ import static com.launchdarkly.sdk.server.TestUtil.assertDataSetEquals; import static com.launchdarkly.sdk.server.TestUtil.requireDataSourceStatus; import static com.launchdarkly.sdk.server.TestUtil.requireDataSourceStatusEventually; -import static com.launchdarkly.sdk.server.TestUtil.shouldNotTimeOut; +import static com.launchdarkly.testhelpers.ConcurrentHelpers.assertFutureIsCompleted; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.junit.Assert.assertEquals; @@ -135,7 +137,7 @@ public void successfulPolls() throws Exception { try (HttpServer server = HttpServer.start(pollingHandler)) { try (PollingProcessor pollingProcessor = makeProcessor(server.getUri(), Duration.ofMillis(100))) { Future initFuture = pollingProcessor.start(); - shouldNotTimeOut(initFuture, Duration.ofSeconds(1)); + assertFutureIsCompleted(initFuture, 1, TimeUnit.SECONDS); assertTrue(pollingProcessor.isInitialized()); assertDataSetEquals(datav1.build(), dataSourceUpdates.awaitInit()); @@ -160,7 +162,7 @@ public void testTimeoutFromConnectionProblem() throws Exception { try (HttpServer server = HttpServer.start(errorThenSuccess)) { try (PollingProcessor pollingProcessor = makeProcessor(server.getUri(), LENGTHY_INTERVAL)) { Future initFuture = pollingProcessor.start(); - TestUtil.shouldTimeOut(initFuture, Duration.ofMillis(200)); + ConcurrentHelpers.assertFutureIsNotCompleted(initFuture, 200, TimeUnit.MILLISECONDS); assertFalse(initFuture.isDone()); assertFalse(pollingProcessor.isInitialized()); assertEquals(0, dataSourceUpdates.receivedInits.size()); @@ -221,7 +223,7 @@ public void startingWhenAlreadyStartedDoesNothing() throws Exception { try (HttpServer server = HttpServer.start(new TestPollHandler())) { try (PollingProcessor pollingProcessor = makeProcessor(server.getUri(), LENGTHY_INTERVAL)) { Future initFuture1 = pollingProcessor.start(); - shouldNotTimeOut(initFuture1, Duration.ofSeconds(1)); + assertFutureIsCompleted(initFuture1, 1, TimeUnit.SECONDS); server.getRecorder().requireRequest(); Future initFuture2 = pollingProcessor.start(); @@ -272,7 +274,7 @@ private void testUnrecoverableHttpError(int statusCode) throws Exception { long startTime = System.currentTimeMillis(); Future initFuture = pollingProcessor.start(); - shouldNotTimeOut(initFuture, Duration.ofSeconds(2)); + assertFutureIsCompleted(initFuture, 2, TimeUnit.SECONDS); assertTrue((System.currentTimeMillis() - startTime) < 9000); assertTrue(initFuture.isDone()); assertFalse(pollingProcessor.isInitialized()); @@ -293,7 +295,7 @@ private void testUnrecoverableHttpError(int statusCode) throws Exception { try (PollingProcessor pollingProcessor = makeProcessor(server.getUri(), BRIEF_INTERVAL)) { Future initFuture = pollingProcessor.start(); - shouldNotTimeOut(initFuture, Duration.ofSeconds(20000)); + assertFutureIsCompleted(initFuture, 2, TimeUnit.SECONDS); assertTrue(initFuture.isDone()); assertTrue(pollingProcessor.isInitialized()); requireDataSourceStatus(statuses, State.VALID); @@ -335,7 +337,7 @@ private void testRecoverableHttpError(int statusCode) throws Exception { // now make it so polls will succeed handler.setError(0); - shouldNotTimeOut(initFuture, Duration.ofSeconds(1)); + assertFutureIsCompleted(initFuture, 1, TimeUnit.SECONDS); // verify that it got the error Status status0 = requireDataSourceStatus(statuses, State.INITIALIZING); @@ -357,7 +359,7 @@ private void testRecoverableHttpError(int statusCode) throws Exception { try (HttpServer server = HttpServer.start(handler)) { try (PollingProcessor pollingProcessor = makeProcessor(server.getUri(), BRIEF_INTERVAL)) { Future initFuture = pollingProcessor.start(); - shouldNotTimeOut(initFuture, Duration.ofSeconds(1)); + assertFutureIsCompleted(initFuture, 1, TimeUnit.SECONDS); assertTrue(pollingProcessor.isInitialized()); // first poll succeeded diff --git a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java index fa096b1c7..8f2828ae1 100644 --- a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java @@ -36,6 +36,7 @@ import java.util.concurrent.Future; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import static com.launchdarkly.sdk.server.DataModel.FEATURES; @@ -45,7 +46,7 @@ import static com.launchdarkly.sdk.server.TestComponents.clientContext; import static com.launchdarkly.sdk.server.TestComponents.dataSourceUpdates; import static com.launchdarkly.sdk.server.TestUtil.requireDataSourceStatus; -import static com.launchdarkly.sdk.server.TestUtil.shouldNotTimeOut; +import static com.launchdarkly.testhelpers.ConcurrentHelpers.assertFutureIsCompleted; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.endsWith; import static org.hamcrest.Matchers.equalTo; @@ -273,7 +274,7 @@ public void putCausesStoreAndProcessorToBeInitialized() throws Exception { Future future = sp.start(); dataSourceUpdates.awaitInit(); - shouldNotTimeOut(future, Duration.ofSeconds(1)); + assertFutureIsCompleted(future, 1, TimeUnit.SECONDS); assertTrue(dataStore.isInitialized()); assertTrue(sp.isInitialized()); assertTrue(future.isDone()); @@ -682,7 +683,7 @@ private void testUnrecoverableHttpError(int statusCode) throws Exception { try (HttpServer server = HttpServer.start(errorResp)) { try (StreamProcessor sp = createStreamProcessor(null, server.getUri())) { Future initFuture = sp.start(); - shouldNotTimeOut(initFuture, Duration.ofSeconds(2)); + assertFutureIsCompleted(initFuture, 2, TimeUnit.SECONDS); assertFalse(sp.isInitialized()); @@ -711,7 +712,7 @@ private void testRecoverableHttpError(int statusCode) throws Exception { try (HttpServer server = HttpServer.start(seriesOfResponses)) { try (StreamProcessor sp = createStreamProcessor(null, server.getUri())) { Future initFuture = sp.start(); - shouldNotTimeOut(initFuture, Duration.ofSeconds(2)); + assertFutureIsCompleted(initFuture, 2, TimeUnit.SECONDS); assertTrue(sp.isInitialized()); diff --git a/src/test/java/com/launchdarkly/sdk/server/TestComponents.java b/src/test/java/com/launchdarkly/sdk/server/TestComponents.java index 2c2519d9d..587cf4abb 100644 --- a/src/test/java/com/launchdarkly/sdk/server/TestComponents.java +++ b/src/test/java/com/launchdarkly/sdk/server/TestComponents.java @@ -40,6 +40,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; +import static com.launchdarkly.testhelpers.ConcurrentHelpers.awaitValue; import static java.util.concurrent.Executors.newSingleThreadScheduledExecutor; @SuppressWarnings("javadoc") @@ -222,23 +223,11 @@ public void register(DataSourceStatusProvider.StatusListener listener) { } public FullDataSet awaitInit() { - try { - FullDataSet value = receivedInits.poll(5, TimeUnit.SECONDS); - if (value != null) { - return value; - } - } catch (InterruptedException e) {} - throw new RuntimeException("did not receive expected init call"); + return awaitValue(receivedInits, 5, TimeUnit.SECONDS); } public UpsertParams awaitUpsert() { - try { - UpsertParams value = receivedUpserts.poll(5, TimeUnit.SECONDS); - if (value != null) { - return value; - } - } catch (InterruptedException e) {} - throw new RuntimeException("did not receive expected upsert call"); + return awaitValue(receivedUpserts, 5, TimeUnit.SECONDS); } } diff --git a/src/test/java/com/launchdarkly/sdk/server/TestUtil.java b/src/test/java/com/launchdarkly/sdk/server/TestUtil.java index d63ffe8f6..de9d52110 100644 --- a/src/test/java/com/launchdarkly/sdk/server/TestUtil.java +++ b/src/test/java/com/launchdarkly/sdk/server/TestUtil.java @@ -4,10 +4,8 @@ import com.google.common.collect.ImmutableSet; import com.google.common.collect.Iterables; import com.google.gson.Gson; -import com.google.gson.JsonElement; import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDValue; -import com.launchdarkly.sdk.LDValueType; import com.launchdarkly.sdk.server.DataModel.FeatureFlag; import com.launchdarkly.sdk.server.DataModel.Segment; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; @@ -16,41 +14,29 @@ import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; - -import org.hamcrest.Description; -import org.hamcrest.Matcher; -import org.hamcrest.TypeSafeDiagnosingMatcher; +import com.launchdarkly.testhelpers.Assertions; import java.io.IOException; import java.net.InetAddress; import java.net.InetSocketAddress; import java.net.Socket; import java.net.SocketAddress; -import java.time.Duration; -import java.time.Instant; import java.util.HashSet; -import java.util.List; import java.util.Set; import java.util.concurrent.BlockingQueue; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; -import java.util.function.Supplier; import javax.net.SocketFactory; import static com.launchdarkly.sdk.server.DataModel.FEATURES; import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; import static com.launchdarkly.sdk.server.DataStoreTestTypes.toDataMap; +import static com.launchdarkly.testhelpers.ConcurrentHelpers.assertNoMoreValues; +import static com.launchdarkly.testhelpers.ConcurrentHelpers.awaitValue; +import static com.launchdarkly.testhelpers.JsonAssertions.assertJsonEquals; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.not; -import static org.hamcrest.Matchers.sameInstance; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.fail; @SuppressWarnings("javadoc") public class TestUtil { @@ -68,23 +54,6 @@ public static String getSdkVersion() { return Version.SDK_VERSION; } - // repeats until action returns non-null value, throws exception on timeout - public static T repeatWithTimeout(Duration timeout, Duration interval, Supplier action) { - Instant deadline = Instant.now().plus(timeout); - while (Instant.now().isBefore(deadline)) { - T result = action.get(); - if (result != null) { - return result; - } - try { - Thread.sleep(interval.toMillis()); - } catch (InterruptedException e) { // it's annoying to have to keep declaring this exception further up the call chain - throw new RuntimeException(e); - } - } - throw new RuntimeException("timed out after " + timeout); - } - public static void upsertFlag(DataStore store, FeatureFlag flag) { store.upsert(FEATURES, flag.getKey(), new ItemDescriptor(flag.getVersion(), flag)); } @@ -93,59 +62,8 @@ public static void upsertSegment(DataStore store, Segment segment) { store.upsert(SEGMENTS, segment.getKey(), new ItemDescriptor(segment.getVersion(), segment)); } - public static void shouldNotTimeOut(Future future, Duration interval) throws ExecutionException, InterruptedException { - try { - future.get(interval.toMillis(), TimeUnit.MILLISECONDS); - } catch (TimeoutException ignored) { - fail("Should not have timed out"); - } - } - - public static void shouldTimeOut(Future future, Duration interval) throws ExecutionException, InterruptedException { - try { - future.get(interval.toMillis(), TimeUnit.MILLISECONDS); - fail("Expected timeout"); - } catch (TimeoutException ignored) { - } - } - - public static void verifyEqualityForType(List> creatorsForPossibleValues) { - for (int i = 0; i < creatorsForPossibleValues.size(); i++) { - for (int j = 0; j < creatorsForPossibleValues.size(); j++) { - T value1 = creatorsForPossibleValues.get(i).get(); - T value2 = creatorsForPossibleValues.get(j).get(); - assertThat(value1, not(sameInstance(value2))); - if (i == j) { - // instance is equal to itself - assertThat(value1, equalTo(value1)); - - // commutative equality - assertThat(value1, equalTo(value2)); - assertThat(value2, equalTo(value1)); - - // equal hash code - assertThat(value1.hashCode(), equalTo(value2.hashCode())); - - // unequal to null, unequal to value of wrong class - assertThat(value1, not(equalTo(null))); - assertThat(value1, not(equalTo(new Object()))); - } else { - // commutative inequality - assertThat(value1, not(equalTo(value2))); - assertThat(value2, not(equalTo(value1))); - } - } - } - } - public static DataSourceStatusProvider.Status requireDataSourceStatus(BlockingQueue statuses) { - try { - DataSourceStatusProvider.Status status = statuses.poll(1, TimeUnit.SECONDS); - assertNotNull(status); - return status; - } catch (InterruptedException e) { // it's annoying to have to keep declaring this exception further up the call chain - throw new RuntimeException(e); - } + return awaitValue(statuses, 1, TimeUnit.SECONDS); } public static DataSourceStatusProvider.Status requireDataSourceStatus(BlockingQueue statuses, @@ -157,7 +75,7 @@ public static DataSourceStatusProvider.Status requireDataSourceStatus(BlockingQu public static DataSourceStatusProvider.Status requireDataSourceStatusEventually(BlockingQueue statuses, DataSourceStatusProvider.State expectedState, DataSourceStatusProvider.State possibleStateBeforeThat) { - return repeatWithTimeout(Duration.ofSeconds(2), Duration.ZERO, () -> { + return Assertions.assertPolledFunctionReturnsValue(2, TimeUnit.SECONDS, 0, null, () -> { DataSourceStatusProvider.Status status = requireDataSourceStatus(statuses); if (status.getState() == expectedState) { return status; @@ -168,9 +86,9 @@ public static DataSourceStatusProvider.Status requireDataSourceStatusEventually( } public static void assertDataSetEquals(FullDataSet expected, FullDataSet actual) { - JsonElement expectedJson = TEST_GSON_INSTANCE.toJsonTree(toDataMap(expected)); - JsonElement actualJson = TEST_GSON_INSTANCE.toJsonTree(toDataMap(actual)); - assertEquals(expectedJson, actualJson); + String expectedJson = TEST_GSON_INSTANCE.toJson(toDataMap(expected)); + String actualJson = TEST_GSON_INSTANCE.toJson(toDataMap(actual)); + assertJsonEquals(expectedJson, actualJson); } public static String describeDataSet(FullDataSet data) { @@ -191,103 +109,21 @@ public static interface ActionCanThrowAnyException { void apply(T param) throws Exception; } - public static T awaitValue(BlockingQueue values, Duration timeout) { - try { - T value = values.poll(timeout.toMillis(), TimeUnit.MILLISECONDS); - assertNotNull("did not receive expected value within " + timeout, value); - return value; - } catch (InterruptedException e) { - throw new RuntimeException(e); - } - } - - public static void expectNoMoreValues(BlockingQueue values, Duration timeout) { - try { - T value = values.poll(timeout.toMillis(), TimeUnit.MILLISECONDS); - assertNull("expected no more values", value); - } catch (InterruptedException e) {} - } - public static void expectEvents(BlockingQueue events, String... flagKeys) { Set expectedChangedFlagKeys = ImmutableSet.copyOf(flagKeys); Set actualChangedFlagKeys = new HashSet<>(); for (int i = 0; i < expectedChangedFlagKeys.size(); i++) { - T e = awaitValue(events, Duration.ofSeconds(1)); + T e = awaitValue(events, 1, TimeUnit.SECONDS); actualChangedFlagKeys.add(e.getKey()); } assertThat(actualChangedFlagKeys, equalTo(expectedChangedFlagKeys)); - expectNoMoreValues(events, Duration.ofMillis(100)); + assertNoMoreValues(events, 100, TimeUnit.MILLISECONDS); } public static Evaluator.EvalResult simpleEvaluation(int variation, LDValue value) { return new Evaluator.EvalResult(value, variation, EvaluationReason.fallthrough()); } - public static Matcher hasJsonProperty(final String name, LDValue value) { - return hasJsonProperty(name, equalTo(value)); - } - - public static Matcher hasJsonProperty(final String name, String value) { - return hasJsonProperty(name, LDValue.of(value)); - } - - public static Matcher hasJsonProperty(final String name, int value) { - return hasJsonProperty(name, LDValue.of(value)); - } - - public static Matcher hasJsonProperty(final String name, double value) { - return hasJsonProperty(name, LDValue.of(value)); - } - - public static Matcher hasJsonProperty(final String name, boolean value) { - return hasJsonProperty(name, LDValue.of(value)); - } - - public static Matcher hasJsonProperty(final String name, final Matcher matcher) { - return new TypeSafeDiagnosingMatcher() { - @Override - public void describeTo(Description description) { - description.appendText(name + ": "); - matcher.describeTo(description); - } - - @Override - protected boolean matchesSafely(LDValue item, Description mismatchDescription) { - LDValue value = item.get(name); - if (!matcher.matches(value)) { - matcher.describeMismatch(value, mismatchDescription); - return false; - } - return true; - } - }; - } - - public static Matcher isJsonArray(final Matcher> matcher) { - return new TypeSafeDiagnosingMatcher() { - @Override - public void describeTo(Description description) { - description.appendText("array: "); - matcher.describeTo(description); - } - - @Override - protected boolean matchesSafely(LDValue item, Description mismatchDescription) { - if (item.getType() != LDValueType.ARRAY) { - matcher.describeMismatch(item, mismatchDescription); - return false; - } else { - Iterable values = item.values(); - if (!matcher.matches(values)) { - matcher.describeMismatch(values, mismatchDescription); - return false; - } - } - return true; - } - }; - } - // returns a socket factory that creates sockets that only connect to host and port static SocketFactorySingleHost makeSocketFactorySingleHost(String host, int port) { return new SocketFactorySingleHost(host, port); diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/DataLoaderTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/DataLoaderTest.java index 357f8fb4a..bdd99f4fc 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/DataLoaderTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/DataLoaderTest.java @@ -8,6 +8,7 @@ import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; +import com.launchdarkly.testhelpers.JsonTestValue; import org.junit.Assert; import org.junit.Test; @@ -20,6 +21,8 @@ import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.FLAG_VALUE_1_KEY; import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.resourceFilePath; import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.resourceLocation; +import static com.launchdarkly.testhelpers.JsonAssertions.jsonIncludes; +import static com.launchdarkly.testhelpers.JsonTestValue.jsonOf; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; @@ -71,16 +74,13 @@ public void canLoadMultipleFiles() throws Exception { @Test public void flagValueIsConvertedToFlag() throws Exception { DataLoader ds = new DataLoader(FileData.dataSource().filePaths(resourceFilePath("value-only.json")).sources); - LDValue expected = LDValue.parse( + String expected = "{\"key\":\"flag2\",\"on\":true,\"fallthrough\":{\"variation\":0},\"variations\":[\"value2\"]," + - "\"trackEvents\":false,\"deleted\":false,\"version\":1}"); + "\"trackEvents\":false,\"deleted\":false,\"version\":1}"; ds.load(builder); - LDValue actual = getItemAsJson(builder, FEATURES, FLAG_VALUE_1_KEY); - // 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 (String key: expected.keys()) { - assertThat(actual.get(key), equalTo(expected.get(key))); - } + assertThat(getItemAsJson(builder, FEATURES, FLAG_VALUE_1_KEY), jsonIncludes(expected)); + // Note, we're using jsonIncludes instead of jsonEquals because the version of the Java + // SDK we're building against may have more properties than it did when the test was written. } @Test @@ -130,7 +130,7 @@ public void duplicateKeysCanBeAllowed() throws Exception { resourceFilePath("flag-with-duplicate-key.json") ).sources); loader1.load(data1); - assertThat(getItemAsJson(data1, FEATURES, "flag1").get("on"), equalTo(LDValue.of(true))); // value from first file + assertThat(getItemAsJson(data1, FEATURES, "flag1"), jsonIncludes("{\"on\":true}")); // value from first file DataBuilder data2 = new DataBuilder(FileData.DuplicateKeysHandling.IGNORE); DataLoader loader2 = new DataLoader(FileData.dataSource().filePaths( @@ -138,8 +138,8 @@ public void duplicateKeysCanBeAllowed() throws Exception { resourceFilePath("flag-only.json") ).sources); loader2.load(data2); - assertThat(getItemAsJson(data2, FEATURES, "flag2").get("variations"), - equalTo(LDValue.buildArray().add(LDValue.of("value2a")).build())); // value from first file + assertThat(getItemAsJson(data2, FEATURES, "flag2"), + jsonIncludes("{\"variations\":[\"value2a\"]}")); // value from first file DataBuilder data3 = new DataBuilder(FileData.DuplicateKeysHandling.IGNORE); DataLoader loader3 = new DataLoader(FileData.dataSource().filePaths( @@ -147,8 +147,8 @@ public void duplicateKeysCanBeAllowed() throws Exception { resourceFilePath("segment-with-duplicate-key.json") ).sources); loader3.load(data3); - assertThat(getItemAsJson(data3, SEGMENTS, "seg1").get("included"), - equalTo(LDValue.buildArray().add(LDValue.of("user1")).build())); // value from first file + assertThat(getItemAsJson(data3, SEGMENTS, "seg1"), + jsonIncludes("{\"included\":[\"user1\"]}")); // value from first file } @Test @@ -188,8 +188,8 @@ private void assertVersionsMatch(FullDataSet data, int expectedV } } - private LDValue getItemAsJson(DataBuilder builder, DataKind kind, String key) { + private JsonTestValue getItemAsJson(DataBuilder builder, DataKind kind, String key) { ItemDescriptor flag = toDataMap(builder.build()).get(kind).get(key); - return LDValue.parse(kind.serialize(flag)); + return jsonOf(kind.serialize(flag)); } } diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceAutoUpdateTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceAutoUpdateTest.java index d8f5362b3..d60f5cd9f 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceAutoUpdateTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceAutoUpdateTest.java @@ -3,28 +3,28 @@ import com.launchdarkly.sdk.server.LDConfig; import com.launchdarkly.sdk.server.TestComponents; import com.launchdarkly.sdk.server.TestComponents.MockDataSourceUpdates; -import com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.TempDir; -import com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.TempFile; import com.launchdarkly.sdk.server.interfaces.DataSource; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStore; +import com.launchdarkly.testhelpers.TempDir; +import com.launchdarkly.testhelpers.TempFile; import org.junit.Test; import java.nio.file.Path; -import java.time.Duration; import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; import static com.launchdarkly.sdk.server.DataModel.FEATURES; import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; import static com.launchdarkly.sdk.server.DataStoreTestTypes.toItemsMap; import static com.launchdarkly.sdk.server.TestComponents.clientContext; import static com.launchdarkly.sdk.server.TestComponents.inMemoryDataStore; -import static com.launchdarkly.sdk.server.TestUtil.repeatWithTimeout; import static com.launchdarkly.sdk.server.TestUtil.requireDataSourceStatusEventually; import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.ALL_FLAG_KEYS; import static com.launchdarkly.sdk.server.integrations.FileDataSourceTestData.getResourceContents; +import static com.launchdarkly.testhelpers.Assertions.assertPolledFunctionReturnsValue; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.junit.Assert.assertEquals; @@ -55,7 +55,7 @@ public void modifiedFileIsNotReloadedIfAutoUpdateIsOff() throws Exception { try (TempDir dir = TempDir.create()) { try (TempFile file = dir.tempFile(".json")) { file.setContents(getResourceContents("flag-only.json")); - FileDataSourceBuilder factory1 = makeFactoryWithFile(file.path); + FileDataSourceBuilder factory1 = makeFactoryWithFile(file.getPath()); try (DataSource fp = makeDataSource(factory1)) { fp.start(); file.setContents(getResourceContents("segment-only.json")); @@ -74,13 +74,13 @@ public void modifiedFileIsNotReloadedIfAutoUpdateIsOff() throws Exception { public void modifiedFileIsReloadedIfAutoUpdateIsOn() throws Exception { try (TempDir dir = TempDir.create()) { try (TempFile file = dir.tempFile(".json")) { - FileDataSourceBuilder factory1 = makeFactoryWithFile(file.path).autoUpdate(true); + FileDataSourceBuilder factory1 = makeFactoryWithFile(file.getPath()).autoUpdate(true); file.setContents(getResourceContents("flag-only.json")); // this file has 1 flag try (DataSource fp = makeDataSource(factory1)) { fp.start(); Thread.sleep(1000); file.setContents(getResourceContents("all-properties.json")); // this file has all the flags - repeatWithTimeout(Duration.ofSeconds(10), Duration.ofMillis(500), () -> { + assertPolledFunctionReturnsValue(10, TimeUnit.SECONDS, 500, TimeUnit.MILLISECONDS, () -> { if (toItemsMap(store.getAll(FEATURES)).size() == ALL_FLAG_KEYS.size()) { // success - return a non-null value to make repeatWithTimeout end return fp; @@ -100,12 +100,12 @@ public void ifFilesAreBadAtStartTimeAutoUpdateCanStillLoadGoodDataLater() throws try (TempDir dir = TempDir.create()) { try (TempFile file = dir.tempFile(".json")) { file.setContents("not valid"); - FileDataSourceBuilder factory1 = makeFactoryWithFile(file.path).autoUpdate(true); + FileDataSourceBuilder factory1 = makeFactoryWithFile(file.getPath()).autoUpdate(true); try (DataSource fp = makeDataSource(factory1)) { fp.start(); Thread.sleep(1000); file.setContents(getResourceContents("flag-only.json")); // this file has 1 flag - repeatWithTimeout(Duration.ofSeconds(10), Duration.ofMillis(500), () -> { + assertPolledFunctionReturnsValue(10, TimeUnit.SECONDS, 500, TimeUnit.MILLISECONDS, () -> { if (toItemsMap(store.getAll(FEATURES)).size() > 0) { // success - status is now VALID, after having first been INITIALIZING - can still see that an error occurred DataSourceStatusProvider.Status status = requireDataSourceStatusEventually(statuses, diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTestData.java b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTestData.java index e0c18399c..31c506194 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTestData.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTestData.java @@ -4,7 +4,6 @@ import com.google.common.collect.ImmutableSet; import com.launchdarkly.sdk.LDValue; -import java.io.IOException; import java.net.URISyntaxException; import java.net.URL; import java.nio.file.Files; @@ -48,55 +47,4 @@ public static String resourceLocation(String filename) throws URISyntaxException public static String getResourceContents(String filename) throws Exception { return new String(Files.readAllBytes(resourceFilePath(filename))); } - - // These helpers ensure that we clean up all temporary files, and also that we only create temporary - // files within our own temporary directories - since creating a file within a shared system temp - // directory might mean there are thousands of other files there, which could be a problem if the - // filesystem watcher implementation has to traverse the directory. - - static class TempDir implements AutoCloseable { - final Path path; - - private TempDir(Path path) { - this.path = path; - } - - public void close() throws IOException { - Files.delete(path); - } - - public static TempDir create() throws IOException { - return new TempDir(Files.createTempDirectory("java-sdk-tests")); - } - - public TempFile tempFile(String suffix) throws IOException { - return new TempFile(Files.createTempFile(path, "java-sdk-tests", suffix)); - } - } - - // These helpers ensure that we clean up all temporary files, and also that we only create temporary - // files within our own temporary directories - since creating a file within a shared system temp - // directory might mean there are thousands of other files there, which could be a problem if the - // filesystem watcher implementation has to traverse the directory. - - static class TempFile implements AutoCloseable { - final Path path; - - private TempFile(Path path) { - this.path = path; - } - - @Override - public void close() throws IOException { - delete(); - } - - public void delete() throws IOException { - Files.delete(path); - } - - public void setContents(String content) throws IOException { - Files.write(path, content.getBytes("UTF-8")); - } - } } diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataTest.java index 050591b38..2379f2db3 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataTest.java @@ -12,6 +12,7 @@ import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.testhelpers.JsonAssertions; import org.junit.Test; @@ -22,6 +23,9 @@ import java.util.function.Function; import static com.google.common.collect.Iterables.get; +import static com.launchdarkly.testhelpers.JsonAssertions.assertJsonEquals; +import static com.launchdarkly.testhelpers.JsonAssertions.jsonProperty; +import static com.launchdarkly.testhelpers.JsonTestValue.jsonOf; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.emptyIterable; import static org.hamcrest.Matchers.equalTo; @@ -79,8 +83,8 @@ public void initializesWithFlags() throws Exception { assertThat(flag2, not(nullValue())); assertThat(flag1.getVersion(), equalTo(1)); assertThat(flag2.getVersion(), equalTo(1)); - assertThat(flagJson(flag1).get("on").booleanValue(), is(true)); - assertThat(flagJson(flag2).get("on").booleanValue(), is(false)); + assertThat(jsonOf(flagJson(flag1)), jsonProperty("on", true)); + assertThat(jsonOf(flagJson(flag2)), jsonProperty("on", false)); } @Test @@ -100,7 +104,7 @@ public void addsFlag() throws Exception { assertThat(up.key, equalTo("flag1")); ItemDescriptor flag1 = up.item; assertThat(flag1.getVersion(), equalTo(1)); - assertThat(flagJson(flag1).get("on").booleanValue(), is(true)); + assertThat(jsonOf(flagJson(flag1)), jsonProperty("on", true)); } @Test @@ -125,7 +129,7 @@ public void updatesFlag() throws Exception { assertThat(up.key, equalTo("flag1")); ItemDescriptor flag1 = up.item; assertThat(flag1.getVersion(), equalTo(2)); - assertThat(flagJson(flag1).get("on").booleanValue(), is(true)); + assertThat(jsonOf(flagJson(flag1)), jsonProperty("on", true)); String expectedJson = "{\"trackEventsFallthrough\":false,\"deleted\":false," + "\"variations\":[true,false],\"clientSide\":false,\"rules\":[{\"clauses\":" @@ -133,7 +137,7 @@ public void updatesFlag() throws Exception { + "\"id\":\"rule0\",\"trackEvents\":false,\"variation\":0}],\"trackEvents\":false," + "\"fallthrough\":{\"variation\":0},\"offVariation\":1,\"version\":2,\"targets\":" + "[{\"values\":[\"a\"],\"variation\":0}],\"key\":\"flag1\",\"on\":true}"; - assertThat(flagJson(flag1), equalTo(LDValue.parse(expectedJson))); + assertThat(jsonOf(flagJson(flag1)), JsonAssertions.jsonEquals(expectedJson)); } @Test @@ -317,11 +321,11 @@ private void verifyFlag( assertThat(updates.upserts.size(), equalTo(1)); UpsertParams up = updates.upserts.take(); ItemDescriptor flag = up.item; - assertThat(flagJson(flag), equalTo(LDValue.parse(expectedJson))); + assertJsonEquals(expectedJson, flagJson(flag)); } - private static LDValue flagJson(ItemDescriptor flag) { - return LDValue.parse(DataModel.FEATURES.serialize(flag)); + private static String flagJson(ItemDescriptor flag) { + return DataModel.FEATURES.serialize(flag); } private static class UpsertParams { diff --git a/src/test/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProviderTypesTest.java b/src/test/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProviderTypesTest.java index 39a9debd3..2fbf73169 100644 --- a/src/test/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProviderTypesTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/interfaces/DataSourceStatusProviderTypesTest.java @@ -4,15 +4,14 @@ import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorKind; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.State; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.Status; +import com.launchdarkly.testhelpers.TypeBehavior; import org.junit.Test; import java.time.Instant; import java.util.ArrayList; import java.util.List; -import java.util.function.Supplier; -import static com.launchdarkly.sdk.server.TestUtil.verifyEqualityForType; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.not; @@ -33,7 +32,7 @@ public void statusProperties() { @Test public void statusEquality() { - List> allPermutations = new ArrayList<>(); + List> allPermutations = new ArrayList<>(); for (State state: State.values()) { for (Instant time: new Instant[] { Instant.ofEpochMilli(1000), Instant.ofEpochMilli(2000) }) { for (ErrorInfo e: new ErrorInfo[] { null, ErrorInfo.fromHttpError(400), ErrorInfo.fromHttpError(401) }) { @@ -41,7 +40,7 @@ public void statusEquality() { } } } - verifyEqualityForType(allPermutations); + TypeBehavior.checkEqualsAndHashCode(allPermutations); } @Test @@ -78,7 +77,7 @@ public void errorInfoProperties() { @Test public void errorInfoEquality() { - List> allPermutations = new ArrayList<>(); + List> allPermutations = new ArrayList<>(); for (ErrorKind kind: ErrorKind.values()) { for (int statusCode: new int[] { 0, 1 }) { for (String message: new String[] { null, "a", "b" }) { @@ -88,7 +87,7 @@ public void errorInfoEquality() { } } } - verifyEqualityForType(allPermutations); + TypeBehavior.checkEqualsAndHashCode(allPermutations); } @Test diff --git a/src/test/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProviderTypesTest.java b/src/test/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProviderTypesTest.java index 59647d21c..4cdd368cc 100644 --- a/src/test/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProviderTypesTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/interfaces/DataStoreStatusProviderTypesTest.java @@ -2,14 +2,13 @@ import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider.CacheStats; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider.Status; +import com.launchdarkly.testhelpers.TypeBehavior; import org.junit.Test; import java.util.ArrayList; import java.util.List; -import java.util.function.Supplier; -import static com.launchdarkly.sdk.server.TestUtil.verifyEqualityForType; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; @@ -28,12 +27,12 @@ public void statusProperties() { @Test public void statusEquality() { - List> allPermutations = new ArrayList<>(); + List> allPermutations = new ArrayList<>(); allPermutations.add(() -> new Status(false, false)); allPermutations.add(() -> new Status(false, true)); allPermutations.add(() -> new Status(true, false)); allPermutations.add(() -> new Status(true, true)); - verifyEqualityForType(allPermutations); + TypeBehavior.checkEqualsAndHashCode(allPermutations); } @Test @@ -54,7 +53,7 @@ public void cacheStatsProperties() { @Test public void cacheStatsEquality() { - List> allPermutations = new ArrayList<>(); + List> allPermutations = new ArrayList<>(); int[] values = new int[] { 0, 1, 2 }; for (int hit: values) { for (int miss: values) { @@ -69,7 +68,7 @@ public void cacheStatsEquality() { } } } - verifyEqualityForType(allPermutations); + TypeBehavior.checkEqualsAndHashCode(allPermutations); } @Test diff --git a/src/test/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypesTest.java b/src/test/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypesTest.java index fc7889637..51dc94d90 100644 --- a/src/test/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypesTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/interfaces/DataStoreTypesTest.java @@ -8,6 +8,7 @@ import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.SerializedItemDescriptor; +import com.launchdarkly.testhelpers.TypeBehavior; import org.junit.Test; @@ -15,9 +16,7 @@ import java.util.ArrayList; import java.util.List; import java.util.function.Function; -import java.util.function.Supplier; -import static com.launchdarkly.sdk.server.TestUtil.verifyEqualityForType; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.emptyIterable; @@ -55,13 +54,13 @@ public void itemDescriptorProperties() { @Test public void itemDescriptorEquality() { - List> allPermutations = new ArrayList<>(); + List> allPermutations = new ArrayList<>(); for (int version: new int[] { 1, 2 }) { for (Object item: new Object[] { null, "a", "b" }) { allPermutations.add(() -> new ItemDescriptor(version, item)); } } - verifyEqualityForType(allPermutations); + TypeBehavior.checkEqualsAndHashCode(allPermutations); } @Test @@ -85,7 +84,7 @@ public void serializedItemDescriptorProperties() { @Test public void serializedItemDescriptorEquality() { - List> allPermutations = new ArrayList<>(); + List> allPermutations = new ArrayList<>(); for (int version: new int[] { 1, 2 }) { for (boolean deleted: new boolean[] { true, false }) { for (String item: new String[] { null, "a", "b" }) { @@ -93,7 +92,7 @@ public void serializedItemDescriptorEquality() { } } } - verifyEqualityForType(allPermutations); + TypeBehavior.checkEqualsAndHashCode(allPermutations); } @Test @@ -122,7 +121,7 @@ public void keyedItemsProperties() { @Test public void keyedItemsEquality() { - List>> allPermutations = new ArrayList<>(); + List>> allPermutations = new ArrayList<>(); for (String key: new String[] { "key1", "key2"}) { for (int version: new int[] { 1, 2 }) { for (String data: new String[] { null, "a", "b" }) { @@ -130,7 +129,7 @@ public void keyedItemsEquality() { } } } - verifyEqualityForType(allPermutations); + TypeBehavior.checkEqualsAndHashCode(allPermutations); } @SuppressWarnings("unchecked") @@ -151,7 +150,7 @@ public void fullDataSetProperties() { @Test public void fullDataSetEquality() { - List>> allPermutations = new ArrayList<>(); + List>> allPermutations = new ArrayList<>(); for (DataKind kind: new DataKind[] { DataModel.FEATURES, DataModel.SEGMENTS }) { for (int version: new int[] { 1, 2 }) { allPermutations.add(() -> new FullDataSet<>( @@ -160,6 +159,6 @@ public void fullDataSetEquality() { ).entrySet())); } } - verifyEqualityForType(allPermutations); + TypeBehavior.checkEqualsAndHashCode(allPermutations); } } From dda102562df4f2046e481c09df86a11660c7f1f2 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 20 Sep 2021 17:29:03 -0700 Subject: [PATCH 601/673] use Releaser v2 config + newer CI images (#298) --- .circleci/config.yml | 31 +++++-------------------------- .ldrelease/config.yml | 13 +++++++++---- .ldrelease/publish-docs.sh | 7 ------- packaging-test/Makefile | 4 +++- 4 files changed, 17 insertions(+), 38 deletions(-) delete mode 100755 .ldrelease/publish-docs.sh diff --git a/.circleci/config.yml b/.circleci/config.yml index 64fc00b7f..7a4ce6ad5 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -9,33 +9,12 @@ workflows: - 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 + docker-image: cimg/openjdk:8.0 requires: - build-linux - test-linux: name: Java 11 - Linux - OpenJDK - docker-image: circleci/openjdk:11 - requires: - - build-linux - - test-linux: - name: Java 13 - Linux - OpenJDK - docker-image: circleci/openjdk:13-jdk-buster - requires: - - build-linux - - test-linux: - name: Java 14 - Linux - OpenJDK - docker-image: circleci/openjdk:14-jdk-buster - with-coverage: true + docker-image: cimg/openjdk:11.0 requires: - build-linux - packaging: @@ -50,7 +29,7 @@ workflows: jobs: build-linux: docker: - - image: circleci/openjdk:8u131-jdk # To match the version pre-installed in Ubuntu 16 and used by Jenkins for releasing + - image: cimg/openjdk:8.0 steps: - checkout - run: cp gradle.properties.example gradle.properties @@ -134,7 +113,7 @@ jobs: packaging: docker: - - image: circleci/openjdk:8 + - image: cimg/openjdk:8.0 steps: - run: java -version - run: sudo apt-get install make -y -q @@ -154,7 +133,7 @@ jobs: benchmarks: docker: - - image: circleci/openjdk:11 + - image: cimg/openjdk:11.0 steps: - run: java -version - run: sudo apt-get install make -y -q diff --git a/.ldrelease/config.yml b/.ldrelease/config.yml index 49e2daad7..2a24ae6f1 100644 --- a/.ldrelease/config.yml +++ b/.ldrelease/config.yml @@ -1,3 +1,5 @@ +version: 2 + repo: public: java-server-sdk private: java-server-sdk-private @@ -8,16 +10,19 @@ publications: - url: https://javadoc.io/doc/com.launchdarkly/launchdarkly-java-server-sdk description: documentation (javadoc.io) -template: - name: gradle +jobs: + - docker: + image: gradle:6.8.3-jdk11 + template: + name: gradle -releasableBranches: +branches: - name: master description: 5.x - name: 4.x documentation: - githubPages: true + gitHubPages: true sdk: displayName: "Java" diff --git a/.ldrelease/publish-docs.sh b/.ldrelease/publish-docs.sh deleted file mode 100755 index 81e1bb48b..000000000 --- a/.ldrelease/publish-docs.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash - -set -ue - -# Publish to Github Pages -echo "Publishing to Github Pages" -./gradlew gitPublishPush diff --git a/packaging-test/Makefile b/packaging-test/Makefile index e1f3b18eb..d79ce3329 100644 --- a/packaging-test/Makefile +++ b/packaging-test/Makefile @@ -15,6 +15,8 @@ SDK_VERSION=$(shell grep "version=" $(PROJECT_DIR)/gradle.properties | cut -d '= export TEMP_DIR=$(BASE_DIR)/temp +JAR=$(if $(shell which jar),jar,$(JAVA_HOME)/bin/jar) + LOCAL_VERSION=99.99.99-SNAPSHOT MAVEN_LOCAL_REPO=$(HOME)/.m2/repository TEMP_MAVEN_OUTPUT_DIR=$(MAVEN_LOCAL_REPO)/com/launchdarkly/launchdarkly-java-server-sdk/$(LOCAL_VERSION) @@ -48,7 +50,7 @@ RUN_JARS_test-thin-jar=$(TEST_APP_JAR) $(SDK_THIN_JAR) \ $(shell ls $(TEMP_DIR)/dependencies-internal/*.jar 2>/dev/null) \ $(shell ls $(TEMP_DIR)/dependencies-external/*.jar 2>/dev/null) -classes_prepare=echo " checking $(1)..." && jar tf $(1) | grep '\.class$$' >$(TEMP_OUTPUT) +classes_prepare=echo " checking $(1)..." && $(JAR) tf $(1) | grep '\.class$$' >$(TEMP_OUTPUT) classes_should_contain=echo " should contain $(2)" && grep "^$(1)/.*\.class$$" $(TEMP_OUTPUT) >/dev/null classes_should_not_contain=echo " should not contain $(2)" && ! grep "^$(1)/.*\.class$$" $(TEMP_OUTPUT) From d7358f0aecc2a20b30de69affd6a167948f6140e Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Fri, 24 Sep 2021 09:56:20 -0700 Subject: [PATCH 602/673] [ch123129] Fix `PollingDataSourceBuilder` example. (#299) --- .../sdk/server/integrations/PollingDataSourceBuilder.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/PollingDataSourceBuilder.java b/src/main/java/com/launchdarkly/sdk/server/integrations/PollingDataSourceBuilder.java index 43f1fa38a..05000fa81 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/PollingDataSourceBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/PollingDataSourceBuilder.java @@ -18,7 +18,7 @@ * change its properties with the methods of this class, and pass it to {@link com.launchdarkly.sdk.server.LDConfig.Builder#dataSource(DataSourceFactory)}: *

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

    From f93e43b23cc22fbb64dd327faec3e8b643780dfc Mon Sep 17 00:00:00 2001 From: Ember Stevens Date: Fri, 24 Sep 2021 13:49:36 -0700 Subject: [PATCH 603/673] Updates docs URLs --- CHANGELOG.md | 4 ++-- CONTRIBUTING.md | 2 +- README.md | 8 ++++---- src/main/java/com/launchdarkly/sdk/server/Components.java | 4 ++-- .../com/launchdarkly/sdk/server/FeatureFlagsState.java | 2 +- .../sdk/server/integrations/EventProcessorBuilder.java | 2 +- .../sdk/server/integrations/PollingDataSourceBuilder.java | 2 +- .../server/integrations/StreamingDataSourceBuilder.java | 2 +- 8 files changed, 13 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6fe31899..bf7e5172c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -448,7 +448,7 @@ It is now possible to inject feature flags into the client from local JSON or YA ## [4.0.0] - 2018-05-10 ### Changed: -- To reduce the network bandwidth used for analytics events, feature request events are now sent as counters rather than individual events, and user details are now sent only at intervals rather than in each event. These behaviors can be modified through the LaunchDarkly UI and with the new configuration option `inlineUsersInEvents`. For more details, see [Analytics Data Stream Reference](https://docs.launchdarkly.com/v2.0/docs/analytics-data-stream-reference). +- To reduce the network bandwidth used for analytics events, feature request events are now sent as counters rather than individual events, and user details are now sent only at intervals rather than in each event. These behaviors can be modified through the LaunchDarkly UI and with the new configuration option `inlineUsersInEvents`. - When sending analytics events, if there is a connection error or an HTTP 5xx response, the client will try to send the events again one more time after a one-second delay. - The `LdClient` class is now `final`. @@ -509,7 +509,7 @@ _This release was broken and should not be used._ ## [2.5.0] - 2018-01-08 ## Added -- Support for specifying [private user attributes](https://docs.launchdarkly.com/docs/private-user-attributes) in order to prevent user attributes from being sent in analytics events back to LaunchDarkly. See the `allAttributesPrivate` and `privateAttributeNames` methods on `LDConfig.Builder` as well as the `privateX` methods on `LDUser.Builder`. +- Support for specifying [private user attributes](https://docs.launchdarkly.com/home/users/attributes#creating-private-user-attributes) in order to prevent user attributes from being sent in analytics events back to LaunchDarkly. See the `allAttributesPrivate` and `privateAttributeNames` methods on `LDConfig.Builder` as well as the `privateX` methods on `LDUser.Builder`. ## [2.4.0] - 2017-12-20 ## Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 815dd346c..107428c6c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # 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. +LaunchDarkly has published an [SDK contributor's guide](https://docs.launchdarkly.com/sdk/concepts/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 diff --git a/README.md b/README.md index 760700a9c..48d0beb15 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ## 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! +[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/home/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) @@ -17,13 +17,13 @@ This version of the LaunchDarkly SDK works with Java 8 and above. Three variants of the SDK jar are published to Maven: -* 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 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/sdk/server-side/java#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 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 SLF4J is also bundled, without shading (and is exported in OSGi). * The "thin" jar - add `thin` in Maven, or `:thin` in Gradle. This contains _only_ the SDK classes. ## Getting started -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. +Refer to the [SDK reference guide](https://docs.launchdarkly.com/sdk/server-side/java#getting-started) for instructions on getting started with using the SDK. ## Logging @@ -62,7 +62,7 @@ We encourage pull requests and other contributions from the community. Check out * 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 a wide variety of languages and technologies. Check out [our documentation](https://docs.launchdarkly.com/docs) for a complete list. +* LaunchDarkly provides feature flag SDKs for a wide variety of languages and technologies. Read [our documentation](https://docs.launchdarkly.com/sdk) for a complete list. * Explore LaunchDarkly * [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 diff --git a/src/main/java/com/launchdarkly/sdk/server/Components.java b/src/main/java/com/launchdarkly/sdk/server/Components.java index 0b2694fff..9b15bdfa3 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Components.java +++ b/src/main/java/com/launchdarkly/sdk/server/Components.java @@ -74,7 +74,7 @@ public static DataStoreFactory inMemoryDataStore() { * See {@link PersistentDataStoreBuilder} for more on how this method is used. *

    * For more information on the available persistent data store implementations, see the reference - * guide on Using a persistent feature store. + * guide on Using a persistent feature store. * * @param storeFactory the factory/builder for the specific kind of persistent data store * @return a {@link PersistentDataStoreBuilder} @@ -197,7 +197,7 @@ static PollingDataSourceBuilderImpl pollingDataSourceInternal() { *

    * Passing this to {@link LDConfig.Builder#dataSource(DataSourceFactory)} 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 + * 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 diff --git a/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java b/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java index ba3cb8ff4..5ac6d7fe4 100644 --- a/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java +++ b/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java @@ -23,7 +23,7 @@ * calling {@link LDClientInterface#allFlagsState(com.launchdarkly.sdk.LDUser, FlagsStateOption...)}. *

    * LaunchDarkly defines a standard JSON encoding for this object, suitable for - * bootstrapping + * bootstrapping * the LaunchDarkly JavaScript browser SDK. You can convert it to JSON in any of these ways: *

      *
    1. With {@link com.launchdarkly.sdk.json.JsonSerialization}. diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/EventProcessorBuilder.java b/src/main/java/com/launchdarkly/sdk/server/integrations/EventProcessorBuilder.java index 271c4e204..0a027ecdc 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/EventProcessorBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/EventProcessorBuilder.java @@ -92,7 +92,7 @@ public EventProcessorBuilder allAttributesPrivate(boolean allAttributesPrivate) *

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

        - *
      • You are using the Relay Proxy with + *
      • 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. *
      diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/PollingDataSourceBuilder.java b/src/main/java/com/launchdarkly/sdk/server/integrations/PollingDataSourceBuilder.java index 43f1fa38a..1cee639f4 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/PollingDataSourceBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/PollingDataSourceBuilder.java @@ -40,7 +40,7 @@ public abstract class PollingDataSourceBuilder implements DataSourceFactory { *

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

        - *
      • You are using the Relay Proxy. Set + *
      • 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. *
      diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/StreamingDataSourceBuilder.java b/src/main/java/com/launchdarkly/sdk/server/integrations/StreamingDataSourceBuilder.java index d00bf9e8f..3f5ab2f92 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/StreamingDataSourceBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/StreamingDataSourceBuilder.java @@ -36,7 +36,7 @@ public abstract class StreamingDataSourceBuilder implements DataSourceFactory { *

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

        - *
      • You are using the Relay Proxy. Set + *
      • 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. *
      From ac2fef7f02fde5e89d56b572a7b3d1c5516267c1 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 12 Oct 2021 15:00:22 -0700 Subject: [PATCH 604/673] always use US locale when parsing HTTP dates --- .../java/com/launchdarkly/sdk/server/DefaultEventSender.java | 4 +++- .../com/launchdarkly/sdk/server/DefaultEventSenderTest.java | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/DefaultEventSender.java b/src/main/java/com/launchdarkly/sdk/server/DefaultEventSender.java index e61fb795d..bf7e8afff 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DefaultEventSender.java +++ b/src/main/java/com/launchdarkly/sdk/server/DefaultEventSender.java @@ -13,6 +13,7 @@ import java.text.SimpleDateFormat; import java.time.Duration; import java.util.Date; +import java.util.Locale; import java.util.UUID; import static com.launchdarkly.sdk.server.Util.checkIfErrorIsRecoverableAndLog; @@ -38,7 +39,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 MediaType JSON_CONTENT_TYPE = MediaType.parse("application/json; charset=utf-8"); - private static final SimpleDateFormat HTTP_DATE_FORMAT = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz"); + private static final SimpleDateFormat HTTP_DATE_FORMAT = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", + Locale.US); // server dates as defined by RFC-822/RFC-1123 use English day/month names private static final Object HTTP_DATE_FORMAT_LOCK = new Object(); // synchronize on this because DateFormat isn't thread-safe private final OkHttpClient httpClient; diff --git a/src/test/java/com/launchdarkly/sdk/server/DefaultEventSenderTest.java b/src/test/java/com/launchdarkly/sdk/server/DefaultEventSenderTest.java index a24d1d002..c2f61279c 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DefaultEventSenderTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultEventSenderTest.java @@ -15,6 +15,7 @@ import java.text.SimpleDateFormat; import java.time.Duration; import java.util.Date; +import java.util.Locale; import java.util.Map; import java.util.UUID; @@ -37,7 +38,8 @@ 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 SimpleDateFormat httpDateFormat = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", + Locale.US); private static final Duration BRIEF_RETRY_DELAY = Duration.ofMillis(50); private static EventSender makeEventSender() { From b2bd37998bc7d84e2a240972644704cc0d4dfa0d Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 30 Nov 2021 11:37:57 -0800 Subject: [PATCH 605/673] use Gson 2.8.9 --- benchmarks/build.gradle | 2 +- build.gradle | 4 ++-- packaging-test/test-app/build.gradle | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/benchmarks/build.gradle b/benchmarks/build.gradle index d910d6f0b..0b9f04dd8 100644 --- a/benchmarks/build.gradle +++ b/benchmarks/build.gradle @@ -23,7 +23,7 @@ ext.versions = [ dependencies { compile files("lib/launchdarkly-java-server-sdk-all.jar") compile files("lib/launchdarkly-java-server-sdk-test.jar") - compile "com.google.code.gson:gson:2.7" + compile "com.google.code.gson:gson:2.8.9" compile "com.google.guava:guava:${versions.guava}" // required by SDK test code compile "com.squareup.okhttp3:mockwebserver:3.12.10" compile "org.openjdk.jmh:jmh-core:1.21" diff --git a/build.gradle b/build.gradle index 8ab85094d..45e88b080 100644 --- a/build.gradle +++ b/build.gradle @@ -69,10 +69,10 @@ ext.libraries = [:] ext.versions = [ "commonsCodec": "1.15", - "gson": "2.7", + "gson": "2.8.9", "guava": "30.1-jre", "jackson": "2.11.2", - "launchdarklyJavaSdkCommon": "1.2.0", + "launchdarklyJavaSdkCommon": "1.2.1", "okhttp": "4.8.1", // specify this for the SDK build instead of relying on the transitive dependency from okhttp-eventsource "okhttpEventsource": "2.3.2", "slf4j": "1.7.21", diff --git a/packaging-test/test-app/build.gradle b/packaging-test/test-app/build.gradle index de5748c5a..a389a7ca2 100644 --- a/packaging-test/test-app/build.gradle +++ b/packaging-test/test-app/build.gradle @@ -26,7 +26,7 @@ allprojects { } ext.versions = [ - "gson": "2.7", + "gson": "2.8.9", "jackson": "2.10.0" ] From f34f3a397b9082c278d25fd3423a6c2a7d864f9a Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 7 Dec 2021 14:18:06 -0800 Subject: [PATCH 606/673] don't try to send more diagnostic events after an unrecoverable HTTP error --- .../sdk/server/DefaultEventProcessor.java | 18 +++++-- .../DefaultEventProcessorDiagnosticsTest.java | 49 +++++++++++++++---- 2 files changed, 54 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java index 9095cb87c..49da1e250 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java @@ -262,7 +262,7 @@ private EventDispatcher( if (diagnosticAccumulator != null) { // Set up diagnostics - this.sendDiagnosticTaskFactory = new SendDiagnosticTaskFactory(eventsConfig); + this.sendDiagnosticTaskFactory = new SendDiagnosticTaskFactory(eventsConfig, this::handleResponse); sharedExecutor.submit(sendDiagnosticTaskFactory.createSendDiagnosticTask(diagnosticInitEvent)); } else { sendDiagnosticTaskFactory = null; @@ -334,6 +334,9 @@ private void runMainLoop(BlockingQueue inbox, } private void sendAndResetDiagnostics(EventBuffer outbox) { + if (disabled.get()) { + return; + } long droppedEvents = outbox.getAndClearDroppedCount(); // 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); @@ -597,9 +600,14 @@ void stop() { private static final class SendDiagnosticTaskFactory { private final EventsConfiguration eventsConfig; + private final EventResponseListener eventResponseListener; - SendDiagnosticTaskFactory(EventsConfiguration eventsConfig) { + SendDiagnosticTaskFactory( + EventsConfiguration eventsConfig, + EventResponseListener eventResponseListener + ) { this.eventsConfig = eventsConfig; + this.eventResponseListener = eventResponseListener; } Runnable createSendDiagnosticTask(final DiagnosticEvent diagnosticEvent) { @@ -607,7 +615,11 @@ Runnable createSendDiagnosticTask(final DiagnosticEvent diagnosticEvent) { @Override public void run() { String json = JsonHelpers.serialize(diagnosticEvent); - eventsConfig.eventSender.sendEventData(EventDataKind.DIAGNOSTICS, json, 1, eventsConfig.eventsUri); + EventSender.Result result = eventsConfig.eventSender.sendEventData(EventDataKind.DIAGNOSTICS, + json, 1, eventsConfig.eventsUri); + if (eventResponseListener != null) { + eventResponseListener.handleResponse(result); + } } }; } diff --git a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorDiagnosticsTest.java b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorDiagnosticsTest.java index 3d7c7b592..b018737f7 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorDiagnosticsTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorDiagnosticsTest.java @@ -123,18 +123,31 @@ public void periodicDiagnosticEventGetsEventsInLastBatchAndDeduplicatedUsers() t @Test public void periodicDiagnosticEventsAreSentAutomatically() throws Exception { - // This test overrides the diagnostic recording interval to a small value and verifies that we see - // at least one periodic event without having to force a send via ep.postDiagnostic(). MockEventSender es = new MockEventSender(); DiagnosticId diagnosticId = new DiagnosticId(SDK_KEY); ClientContext context = clientContext(SDK_KEY, LDConfig.DEFAULT); DiagnosticEvent.Init initEvent = new DiagnosticEvent.Init(0, diagnosticId, LDConfig.DEFAULT, context.getBasic(), context.getHttp()); DiagnosticAccumulator diagnosticAccumulator = new DiagnosticAccumulator(diagnosticId); - Duration briefPeriodicInterval = Duration.ofMillis(50); + EventsConfiguration eventsConfig = makeEventsConfigurationWithBriefDiagnosticInterval(es); + + try (DefaultEventProcessor ep = new DefaultEventProcessor(eventsConfig, sharedExecutor, Thread.MAX_PRIORITY, + diagnosticAccumulator, initEvent)) { + // Ignore the initial diagnostic event + es.awaitRequest(); + + MockEventSender.Params periodicReq = es.awaitRequest(); + + assertNotNull(periodicReq); + DiagnosticEvent.Statistics statsEvent = gson.fromJson(periodicReq.data, DiagnosticEvent.Statistics.class); + assertEquals("diagnostic", statsEvent.kind); + } + } + + private EventsConfiguration makeEventsConfigurationWithBriefDiagnosticInterval(EventSender es) { // Can't use the regular config builder for this, because it will enforce a minimum flush interval - EventsConfiguration eventsConfig = new EventsConfiguration( + return new EventsConfiguration( false, 100, es, @@ -144,18 +157,34 @@ public void periodicDiagnosticEventsAreSentAutomatically() throws Exception { ImmutableSet.of(), 100, Duration.ofSeconds(5), - briefPeriodicInterval + Duration.ofMillis(50) ); + } + + @Test + public void diagnosticEventsStopAfter401Error() throws Exception { + // This is easier to test with a mock component than it would be in LDClientEndToEndTest, because + // we don't have to worry about the latency of a real HTTP request which could allow the periodic + // task to fire again before we received a response. In real life, that wouldn't matter because + // the minimum diagnostic interval is so long, but in a test we need to be able to use a short + // interval. + MockEventSender es = new MockEventSender(); + es.result = new EventSender.Result(false, true, null); // mustShutdown=true; this is what would be returned for a 401 error + + DiagnosticId diagnosticId = new DiagnosticId(SDK_KEY); + ClientContext context = clientContext(SDK_KEY, LDConfig.DEFAULT); + DiagnosticEvent.Init initEvent = new DiagnosticEvent.Init(0, diagnosticId, LDConfig.DEFAULT, + context.getBasic(), context.getHttp()); + DiagnosticAccumulator diagnosticAccumulator = new DiagnosticAccumulator(diagnosticId); + + EventsConfiguration eventsConfig = makeEventsConfigurationWithBriefDiagnosticInterval(es); + try (DefaultEventProcessor ep = new DefaultEventProcessor(eventsConfig, sharedExecutor, Thread.MAX_PRIORITY, diagnosticAccumulator, initEvent)) { // Ignore the initial diagnostic event es.awaitRequest(); - MockEventSender.Params periodicReq = es.awaitRequest(); - - assertNotNull(periodicReq); - DiagnosticEvent.Statistics statsEvent = gson.fromJson(periodicReq.data, DiagnosticEvent.Statistics.class); - assertEquals("diagnostic", statsEvent.kind); + es.expectNoRequests(Duration.ofMillis(100)); } } From c6389225266cfed881e4914dea40714d71f3439a Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 6 Jan 2022 18:23:05 -0800 Subject: [PATCH 607/673] ensure module-info file isn't copied into our jars during build --- build.gradle | 6 ++++++ packaging-test/Makefile | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/build.gradle b/build.gradle index 45e88b080..98f9080d8 100644 --- a/build.gradle +++ b/build.gradle @@ -245,6 +245,10 @@ shadowJar { exclude '**/*.kotlin_module' exclude '**/*.kotlin_builtins' + // Shadow is not supposed to copy any module-info.class files from dependencies, + // but sometimes it does unless we explicitly exclude them here + exclude '**/module-info.class' + // 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 { @@ -274,6 +278,8 @@ task shadowJarAll(type: com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJ exclude '**/*.kotlin_module' exclude '**/*.kotlin_builtins' + exclude '**/module-info.class' + dependencies { // We don't need to exclude anything here, because we want everything to be // embedded in the "all" jar. diff --git a/packaging-test/Makefile b/packaging-test/Makefile index d79ce3329..972a82506 100644 --- a/packaging-test/Makefile +++ b/packaging-test/Makefile @@ -53,6 +53,7 @@ RUN_JARS_test-thin-jar=$(TEST_APP_JAR) $(SDK_THIN_JAR) \ classes_prepare=echo " checking $(1)..." && $(JAR) tf $(1) | grep '\.class$$' >$(TEMP_OUTPUT) classes_should_contain=echo " should contain $(2)" && grep "^$(1)/.*\.class$$" $(TEMP_OUTPUT) >/dev/null classes_should_not_contain=echo " should not contain $(2)" && ! grep "^$(1)/.*\.class$$" $(TEMP_OUTPUT) +should_not_have_module_info=echo " should not have module-info.class" && ! grep "module-info\.class$$" $(TEMP_OUTPUT) verify_sdk_classes= \ $(call classes_should_contain,com/launchdarkly/sdk,com.launchdarkly.sdk) && \ @@ -93,6 +94,7 @@ test-all-jar-classes: $(SDK_ALL_JAR) $(TEMP_DIR) @$(call classes_should_not_contain,com/fasterxml/jackson,unshaded Jackson) @$(call classes_should_not_contain,com/launchdarkly/shaded/com/fasterxml/jackson,shaded Jackson) @$(call classes_should_not_contain,com/launchdarkly/shaded/org/slf4j,shaded SLF4j) + @$(call should_not_have_module_info) @$(call manifest_should_not_have_classpath,$<) test-default-jar-classes: $(SDK_DEFAULT_JAR) $(TEMP_DIR) @@ -106,6 +108,7 @@ test-default-jar-classes: $(SDK_DEFAULT_JAR) $(TEMP_DIR) @$(call classes_should_not_contain,com/fasterxml/jackson,unshaded Jackson) @$(call classes_should_not_contain,com/launchdarkly/shaded/com/fasterxml/jackson,shaded Jackson) @$(call classes_should_not_contain,org/slf4j,unshaded SLF4j) + @$(call should_not_have_module_info) @$(call manifest_should_not_have_classpath,$<) test-thin-jar-classes: $(SDK_THIN_JAR) $(TEMP_DIR) @@ -114,6 +117,7 @@ test-thin-jar-classes: $(SDK_THIN_JAR) $(TEMP_DIR) @$(call verify_sdk_classes) @echo " should not contain anything other than SDK classes" @! grep -v "^com/launchdarkly/sdk" $(TEMP_OUTPUT) + @$(call should_not_have_module_info) @$(call manifest_should_not_have_classpath,$<) test-pom: $(POM_XML) From f07c0e6eb97bf10cef8c2e38794ef1117f6ef3a4 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 7 Jan 2022 18:22:29 -0800 Subject: [PATCH 608/673] use Gradle 7 --- benchmarks/build.gradle | 1 - build.gradle | 11 +++++------ gradle/wrapper/gradle-wrapper.jar | Bin 58695 -> 59203 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 2 ++ gradlew.bat | 22 ++++------------------ packaging-test/test-app/build.gradle | 1 - 7 files changed, 12 insertions(+), 27 deletions(-) diff --git a/benchmarks/build.gradle b/benchmarks/build.gradle index 0b9f04dd8..07a80926d 100644 --- a/benchmarks/build.gradle +++ b/benchmarks/build.gradle @@ -1,7 +1,6 @@ buildscript { repositories { - jcenter() mavenCentral() } } diff --git a/build.gradle b/build.gradle index 98f9080d8..21bc46813 100644 --- a/build.gradle +++ b/build.gradle @@ -19,7 +19,7 @@ plugins { id "checkstyle" id "jacoco" id "signing" - id "com.github.johnrengelman.shadow" version "5.2.0" + id "com.github.johnrengelman.shadow" version "7.1.2" id "maven-publish" id "de.marcphilipp.nexus-publish" version "0.3.0" id "io.codearte.nexus-staging" version "0.21.2" @@ -196,7 +196,6 @@ dependencies { checkstyle { configFile file("${project.rootDir}/config/checkstyle/checkstyle.xml") - configDir file("${project.rootDir}/config/checkstyle") } task generateJava(type: Copy) { @@ -224,7 +223,7 @@ jar { // 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 ], []) + addOsgiManifest(project.tasks.jar, [ configurations.runtimeClasspath ], []) } } @@ -534,9 +533,9 @@ test { jacocoTestReport { // code coverage report reports { - xml.enabled - csv.enabled true - html.enabled true + xml.required = true + csv.required = true + html.required = true } } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index f3d88b1c2faf2fc91d853cd5d4242b5547257070..e708b1c023ec8b20f512888fe07c5bd3ff77bb8f 100644 GIT binary patch delta 12842 zcmY+q1ymhDmoSOmQr324hm`&0SZbS3R18r37J8Z0F-T>@gIK` zatajf2b1lO#&E_RnGDF51uk%8Wcxm3`%Yhe7Psx?*?eZ?8M9_+G(?L=s^OG1n#S(NP3gF?2Mr2^f5E7sM~iVC`Rn;(-^MZ zZu*ZXB;XmgvPls(e#)MMTObsEx9oNz-K?AmQ8pP&P7vqx*=5zxjU+ye_1R<%KSg1? z7H&Yh))(Ke!Pa+aVuWxPKa_~Qo_IH}*;tV8n~O*Xa?t3P^9=L%=wOL1=~{LVv}mU8Q#e6s>v}iV8cDP|EdY)`dp≶7^21 ziF~qst3+S0y_IcTmzBD?t^AL=8|hpx>4aXc#L1YriEI=T#&IZ=SoAEyLg|^3d~uWZ zL(@1$!3on^gfz^e5VdZe5qx_>I%?g|J-FS>NG7S8Uwqt9t6KDa`8Nu!bDng+bM`&i zd>s2#sQ2Dsh6c}3YYi}8DqsK)DG!%;@xqz(<#=W`C`X+!HhtF~r~9OsI`@n36>D}N zz^HjPst0d<*2#=afSFiYwBeNZDk>BahnaW;GkQDA235(RJ%j;vVg80O#gk|q<#+OO z!F(BArIYDQG-{DlHpf+F=!)yw08zWccjd6DKgR+zJ(0X3zS;mzg+Na{$2N+AhF7`& zXj`aBWy{YG#8s$C5=GZH$a@!+F42?=O~WoaIjO;k;0P0nE5|ma;I^@xN`kKvIjTQe z1!_si%O1V@BP`r(WwTpr7HN&p#_-)5!T z%!r5ZL79g`v%i29=J2rPglr;%LCc+ZSZeh71?CfOgZ&EJdacV35*58xwhWGhyMhx{ z5KAVHq&&zae)(vc?T~KB9rtcfzy#SAUvce5`+$`_U7}=j*;@5(PyBoTp#IwDtV?s% zQ%T#rekISAFx`AeHyBx6BP^4OtUo>VhbksSk&W=OkQIO#SJ13R8z6r|HNM}$TK=58 z^$>Cg`+P;E@||v&RXQ8dF?fqSS3;wKND5tF(tf3C`q!LEI9_~9LgscI=n#Q>Vl6%6 z^xQ<;f6C*>yStD8WZ4LPzJjmeuu1L`A4BDvEy6DgDMC)PB+3}KWft<^5DPgko{>P8 zJL=zIrDlQ3l54nAxi=;0*HF+cQ`|0Z;~#mt0NHndDI8Ft6^Gp+Lz!19<=L3-abvfX zelFvqpMs+)n2}tXR2j_UG99=i2A)GzpZxTtF=_i+PyVcT4m=oLbh0j3wb~T*1D(f! zOnvTcyI^VbldY>z*{sBnk&j3`-I6GqvB;Qa*bl<5YKpLMNKjDk-$VW9y)f6Qa?T73 z1=aTsLXIW~Xm4p^spI@Hmcn0=j#SgUrQ(LwQu{s6rO7cNL8G>CZWT(hIbdv%x(Jlp zoI&SgpA?j``5vR&m!53m5=zO&hWkznAAO#F&1pI^L3;~$fir#2Cha{-SD4F2dZ$Yj zNWSt;3dLNmPZ<;D0kQqC&yn>qqCMISL58?}bV(f=u%P^DVHARl?p=uptqCK6qHR%G znz@gHYqEnCEL=>78`c?7$>81*%RQ`@urhDyDti}_ZIXnVa)~U{)lq9bj?aBpb1|OX zQEOY8nV`I7nqbYP%pqaNpQZht6Jst`i`{B$ycuhg>p)3{T|=C)ZRx zwhOaI{+g~G@s-nQB66k4ZKP7Wk4v)bVT$sdEEvJj5EkX)2#Rp1J(m+pLGRGtgR}!C zJ1^uNmx6bMEDWh)dOtRzDkdg7lNs7AO6;LFpmezCp}|2dbseLD5M?D7VP+y`GysD~ zXb)?J3jG=5(Rn1_;i`Dqld zLN8F94c4{|1+YfvKa)vn+;*{ju_%uj`H`ke;KQ2P7DD5nGOQP(R8l=AL0{o9qc%9& z4e))*rFyxhsM%wgJC6S4tJLteds>&34_6tvv7a(#F`kk%031W1Aq<#&3|2ZN-Cqq`-l5Ajt zmAD72)g^6kQ@$3=wef)3tC4m)dsw?AxwR=`#N_`9Hd+t$4SzJ+Za8)malG?}{YbGV zxcLZ87Enlr@O~eE@6qx44m*uKyFE-L%FP1HxR_($c}_VqmXk%xb*nPsReTfvRCy#; zLY#`)G1RGkp=-|NJ^jIMW}3=(vjF6sXq}{QLw%AcwOIS4Xzu*SI~An6k)^=@T}{+b z;{p5VP*8g0P*4>A`R0;9;+Nh5H3o>@M5CSo@o)`_E?{vin&S{F5*+l|B+sN&hr~i^ zxo)Y1WCr~t-M*v{c=O$137j0hxQnsK3wkdHI@jz{r>wsxUt;$AWa$ls__3NTo)gRm zxs5xy_-19*m7b*fKPY(QViL^@bJ0u|)*rDFGM{5vt|In|Dbn>J8Y}9zMGS2hhAyEyX;#g( z3$svdx$kb`47#gDE}`MAb8TA7R|g7nS`|htx!e*OH3KH;-=d|O^tcqIH0d%+3iWA0 zc>|NUCIvSN=od!@4k5Y~-RqKc;Mj?P6lV=^P5w4Y*^K}?DsbbI!s~r})~$Z%6UvLI z5j+vg$jfj?7@8$a{2edF8S?`VQ@8Z4q4sv=4Npp2Rk!3}&cKMVgm2Y^BjZl#jZ?}) zxnI}B=kjh{W(VDN$z6XX19np0>bUe=C6Ih6_wN`?Vce#N4D9Rl3fX67_r(uM_$4H;FS0L^8S4SsUjic=MUP==s$OM)&NEvp)gDY#mLjhipsjL4`P4~y+`Ffxn){Z zM1N=8c5d!;9JDPTH^%wTa}r{{C6e<~q%Z-FCbp1aBH&ZH-)oNV1Fn^++vwDsdP6*} zaVhuu2m6!6^tlgaCy^m$Egs|YdX=V|Me#&Rq<3K`OoZI~O5Bm)H(OTPBblGUmJg0| z-izCVizZ(K;ct6*^BV0U#+S@wOf5Zixt#8bM^uTH0|NxC-~Y)n6Xq#4ROj%DVD)8= zBCj(Tvjoy@S#8(io3h46W>%(0?BH3|f~ zZq)Djpdf3=53UQ^^XbRF&r^wwiCCoP-$on0UIe_qQp8l(D;vgp5?-tOGOH$Gjwd~0 zP-c9qN8_Z?BDcLlJvT^o_QFSEa0&@kuTLnrD_uKDAQZ7!g`IO9R9fTT@7Imu&@^@m z?qLv2Y{YygNjB;sk7J*DU>$t@B2QBhPY|r*5cj8Z9cR1l3OW>>kyz_7VIbUnO1*T+ z9R_H!tG(65#gKrw8j9+gx+_FfNZzggvg8ut<>bLdeet7<#JLJ_x1f%XFklxkhk;Q& zlehT2JngNwJRkN<$!~!2&0Ypouxad+=bQtZ!X$UphLDQG_S5)O&__;c_f*|+lp2ZZ zb76mUyr3?H+16hMIi0xCNVPOzqbJg&7(w8MVCzHGtbS&j1f^eEw%Ax_x;-@du9i|; zY=1W0GG4sSZfnWW9v0COT!>0Kp4UDL2Hj+kovXh(BB?mhhdoSJ4=u}gXno7mDu=dC zoEbrTZk!pao1a3}xpEUS8VADYb;P9bB4H%rWa4Mx=Y$I85KhEnNeh!@&^4nf9H9Xm z!v|Iya%#6W8M4A#kbg|B7~F`1Ag0{=1FS6FcG-QC&M0#{?C~|i(mn~Fqr7Sf?Yse5 zuAfH&M+NU zr;=Ulc0TW}u9-^{O7RHY6OPIhyaTZE=(Nl&!jj2uVS5!ZQY2LBqP6e)7&F3Q_Hab_ zLrV(2QNJR@Q3@ySlY`qAJ9RW~ANP~kCZVc+(E_?xFf#1GdC0(ny@RWUMHZ%x#$)ve zcJ}OJcEnWXXK3lgvCS6;RcVVxAN!_E@w<4vAMGFa<$G1RE+wyj%X;u}&YuEpoFBLY zM0*$}OUKT3lDDD0O&TM_#1oBU&8>dbIXCK0$D*1#`;Df(2uT^Ys9whszl=P|xI zK4W*LaJ@*-8JDeOOjy3z`Q?(C2>>3>fNK3wAi&Px<>6wQKf}hc^znUVz-_hJ(=Wd4 z9IgSrq}Qf)hGfeb7E!z>^fAEWN>&Y|bLXLO1^4350UN=XN>k)gBATK}fRry2DzFf> zeI(W{Xb~dAwxAs=Iz=}3s2+eqikF2ZX30Fu3T?1I`cx!0rN1g2`c0fKmhEaZJ7nf# z7i(J~BWxF}HSK0xuHTjRBVugcLHr;P4EsClv;7N>sBvGKacB8^N>1Pj{+H4BO>brw z0Z=^L{Yk5nDlH1J>{YeU!Y6DDQyZmEqf3LCmI8@+&eAWLcdAks4e)z-%Fp|y7pkRL zh}ia&0kgEwac`26TUTAYnw`*P9-Q7OWaElwe(&9MbY7Am?<9OE zFl?kb79&+DH2+NM@b%G?f-GhaeE#IQrH%0x#4(vYW~7*gBkYlZ_UG$X<**g4C=s1F zetpyc6kF;#-gZ{o zo5%FNck~|JmFqChIa4HyzJ;9s^E2Q(q|ExN5GJ_+N&t=PR~t6#B$4n*`iuX z1ar~vtGu~kDGyTq9B@908w}v9v2?bp-!9ig&naSfi|YE`Z$sk-lBk*YOlN|43gU^lO_#1eF&9897yLT7 zYcHsfu2`qSC6AbNB}O{3wPcAu%$O;&a4co+Tp2_=;n%-!MlMGm-@306*aPxS&vEN4 zj(vxTGWQ6asMvUfb*p&*Fs`N}LjI4Z&xgki`R^F0>I4N9QCZ+RXKPC_CuR2&Y^#{2+ z!ilm<k;?lLILj?ya|l9 zOrF)I76r5|z+J+8 ziFg6yfg3{uQbl69k(zf@XXb1Y%n*+s5lv;=~4G5mx-nnZb5v}SQj%*ymKZCt1qOy+hkMR?8 z3gvp`aXHm=DrW6Ny_oLcLxJA%*=Qtx`2sd34-LI3R;| zOyl7p2TBbRhEzz#+0d|LY0{h(3twB_~^+8*w z(MzSza-w44?3^wDf(gH9>2>qWL&SogA z-~dj4T9o)~d0>{1_V$*al&)P&kJE z(;N?J$~(vn9K#c|HbCAO?f$k2SDJQWKaxirtifx|+UMwRCosQtaG|O>CaCtzh+1k_ zUN-Kl6?5rfCS-I>#mU)Ru z`~1=H;0sspAO7fElv->{+Q6+a2p>b zzWKwufce=pd5)@gn41XkS@m8%-2@asCj|(nG2jk~7Sq~Y$}9DXI}3OP5GhER=U&AP z46t6NH}f!7|Jn|2T{;w|BDLC1_ipdm=dMIyzuCbBc>%lXcp#a~kgzS0JDfa0u40m48`rku!q$3Z`?6tz{7*3^*p;uG^uikJU&oxP-l&}0XQc|~h7{Re)JHg*6b%(nxs+$7)^Z;BFV`}*L;>Ih zMs0u!*7d+jPeqM>>`JVZNg&G2h&w?{eiRg}{_C-q$%M!Li&?YZ(2o10ogN!NtSeNC zjIimtk-Li5J5$w6iCygi?yi5%rFx>QPLksnXoCOsKnj zWm@ShV5616Y;`R6(k1=$nob4SvJJy_(#wO@~}HOesm>Yt?QkErQn-m;~p50(~qBvdQO0Tqg#3yus1TtZVpt zez2NfwQCkO9BbTyZKb53b=pgfR5#)@qqG_jn;*jYd8%il*I7t~jol8g3`&N1tZZcU zP?@z6(Ef>6&(i8g=}|}YxuzVGL*SZ}6Xc?$r~90bt*|D*l?gqfI`mopjp%Mhfm?-x z^|Bt(sH}sqdH{8p-4YV~9?TQS?iq*C9y#_-a)8xJ9W+}0A{X$4W6q7yGx!# zioE>n)y?!4H)ge$jK${3)1^DbijD9)Tbm=idENin5;KJ7luQlufA)Y6ykK04aG;=A zS)icEA@z26kQp%oz|5ODGKAd$O^%$&Ocur*fL?wa7`4$$}c9uTHjTP0W!qq|U!ZTXG! zwem4Au0gAe-)dl%%kFQJdq!$^wL1&-O#7CA>qah-HDa=g!Cx@~#P%n-dWGatXH5pk zk`c+UtVD^6d52Jq=ezrLZC@~B>n!Jq_T)DrWX?LLT7^$JoeVtHsWP}AN(G+3&OWvB zI(4}i1CqC`HK;8cZQKq{oi2*sT2YnYWATa7K-%h5+xklmhKb%s_N9oPh`ac0p9$uY z3BUU*z1bEvEi|WFbJ12$SE@|f#%F2^r_OCT8feGbV{pP=MCN*PnKg5M^P+OlOCwFw z?nLdXdPg~8P&5Fp-3Vdn;7aG(I(LjNO-fY!2K-7a*I!t+riEn1v=>-bx$Ua~1Bj+a zAF(54&s&r(8NkRr+XfIwv~g$fxM7+tZw4*5%$~I-pnxQCI@xM9QOeJ=V*gIAP{Dz+)^?Hv zuwfLo(wQwMnKVpXPWE$dD^$WJxp!TtUGHsyrVj0({$@OqbjXyc$x-@JUf#=Uqqbi) zyT#X;Ww$0@Bjh~ATwOgraYm`5Q*M^y+45K`*XB2v+84RTvXBJ&h}vda&w?8Yc8AL~ z{E(zrVR*+rbokD+ihF5}qJC<_c=F%`_~1K77UV3r_yx=XH_jX`KJTEYkJ(jck3EZM zTN~|>DNocbL;@2$a9)Xe{WCc>MTv@bZh0NylBl(x3+y2upl3t7 zQ7zZDZ{h4a^raO-@xk1 z9TpSAw6VztodyVF+}N^t+`@ObqHijM>hLM9<5Cm$oZ4bByuMxEcs3k#se;Ob<;y`{ zqnfp+BI3w$bQh%Zm2*^j#r*Sx0PlHn=rDdx$O^$HD0=yY+DrI*2afM}3sKTZ@{v$O z-))`LxK!);ST-&LCmeR{K^H0?l-DmJlXHfv41D|tq6k}S-gm20i(Z{LNmI^n{J>+P zu zB%Yv;$Pk$%K`K`Vi`gYjjZS2Hnk3~gC}*QCLT;KZ1J--!f>fo73wVt^rrBk9PiE{s z5sNm%+$*S`pjQy9%J73s{&hyJYw8(v9${NeZ#8yu5JwA=ezl1Vbxl9)8ZkwGl362v z*~bq>^-=E|qukP$Mm0G&fvk051!m_ihTqYxyb#9dk=mbPNumYUbkIw!QlCGnl$smp z?Pd0FTDj-HYXak>3#oIM&8n5=wAs#4ma44Our{&10kl=!+tTyQsn+B5IFnLH52(CU zp=XS10t|z;9qb0~&oORiyw67 z*QlVQ@4qfLhFW%QrxT5z)7qI%;-Evg9T}+v->Wv};S)o;a`O4kH-|JI!P6(hWbVZG zu3klVR@UPg!(Xo~05p37>P17&(^+DK)UKQ`b{dp1+2xJ!9=|Yb*WH#q$;66Mks)~W zMmjG)HTiMckF|*bzs=3=`E#6i4GSb{!y+DjpmL|s`+4-nipJ;9kbFZlcL}WZ69mMM z*lyB1-adRRyA^*!a*OvREWFnBd;vdt72M0F!=GewE(`H9SOrwaiKba_ z2auy6$O9LMoP=?7=j@C+8xcc;GTrEwc&#fT#Z95R&vzyOQ7iT?n&nOXTC^koI=)GE z$(dmUqlI4kw;KE+!=tVz(wumguhX!82n+CZIFvnJSc=pG4Q+Hm)3Q${v6l(6TgRPinsg&fK*bQBHc%w@veNmwotb;NZVfgLKZ^#2% zIxyH5z3g|#kQU+yol=TUc44&Ouo4&~*(9|Mth4^Ziw`sodKfG@2tlkZMm|36g9<~Y zWE%=J!`Lbut!g;PM|kaKi#AKUdzP+35Vb)K>IU&~%mgGWmk;j8`q5!&vlSA*M98m8b9sZWplD(I~<0?+~bfN?)r{9JjUZ z9F80S7*a(lDl2}veqYj>63% z>lmBeOXGCiRh7V>Bp`H`v`l32Y2}3|2bcuDO3I%aV4mFBy!A{27_x7Pf07%n(i@eJ zR;Yiy>Te3UH)Hd}mmifLmhNs+H2nEEL;@_Gklm@~{2BQOgQS}Ml7W|PP7>0{&&k{$ zljJ&nLN>xFqFX!R1F;)1Bo1_UO)^yZ;j+GLMdOpbzcZA$gkpckam_KH&fmhifYSzO zXyrf^pR?N8CX{9MZ#qeAdo&4ccDL zQRt^{SOU{mZC}AFVtA~br7&%K74Xd4msMx`!k_TY(=~%unotUH5qP^Q(FPFP zWsL6l4qCNs?i`H^PmD~J^HdiW~& z8oZnipal~XC8Md&EVg)5YTnRVsLCM=1lC=n2CLdH4&Qq9?C;%;h6R4z=I{c9YshCl z-^V%dCZE?>&Oc!z5>c3XI7?70I}oL^kfu`~niM5Q717u8fQ~aR0+=|7Y4rV5i)WIH z##D{*zk(4@F~?nLSX+~$QI6gQ##Oj$#-+Hdval|TN)JY&*Ul_oT;1$iwmYIG5a343 zG39F~7hCJg=Fp^aJa2p@nK=N$fknb=DdHBp#YAiS$jQ*isIX!^gQ0Jpi&qy3%N9}& zizKTkS`I%fZwj;FxNq=0Gk+h3RDT}1QhQES^nt}{i9Kcf^v#X}JQbQ=Jf@tnF_@i+ z+Lft*f;}Hee=F=}=;EV0nWJ5*rct)4iL^MSZFn5Ag5&lp>6lnw@xB1ajN{L6R_qM4 z$pjP>Yo}e@ylmI>4p?1$-S1_~vxAlxv$$z}5|&ZNfedapJEN6Zj3DdFA92{Go#I;( zv?M4UCX=BGZav&L5)K+^iJQswQ_tmu!G;*f`}@{)Id8-lc@8jhrmROub7UL)MsDF@ ziQGSKsu*=wFjsu(zSIMC2piGz?zU_xSc&lxchH?N>8zu=r2Yv=2h-4(@NUorxw`Wr zzq+GpM{ZGOO(e;re{=X5qoJ7y9i^bowl|6+wc^Cgl*zu66P3W8n21l%(QyrVu}YD( z-7{+$7@bq06J75}CoE;~z_U!3ZK@z}zCAIBN#++i!M>BH{6!0VXz;Xff4PDxf%_bK;(#smOVP*Zp*&Gj(tN!!bR0zr6^3C$<+#<{n>3P@zCM zn5(D6FVLC`tmA!4x6lT&)GOh~CgDG}o z-tLW_Bd=~C#zF8Q4vbe*Ky9tB6@TM9u*k9i61T1s^KB;2o8bA{MSX9TfR#z

      XiM^r_M1uFTw)(UPqqUJ zed7DJQvOpplYJoNl`A3a2Zr~v)Y}K|*%d7?8;dC*Af}0=(EXrk7hP8PM4v)ZawEv0 z5j5q_6vilv4*ppZ3Wke7%8b`odJu26#YseA?$sP8?@d?TpACTX`G^?%K=Hly9Y$Tj z5`i!}-WH0lQ3yt-&Pf&Z)OxjKfAZ3X`YI2)5o@9EiOA}?kX`^rk;yaOh^Li4VHcT& zd6ztJz^`H!8>cL)a%?Lnofzo0$WPrgkNcP#u@{%~EvA7g#go z+Q4$Q^o_MJTE0G_&9@cgp)WF>2zo>AG}C|(~E^_?ijc2EQ>> zFH1Lxc@i!gDHxvEsrfc+Kiz3Q6Z*NM+U6DH6*-Fv{YDY4>U&eegGH~Xmk~!sd6fw2 z!6^4(MZWhzf(xs6BHy=oS+dir0_JW(j*AIu$9&&p@}T;&6s7(yYidEdkp6pk9}Z(F zk6lG`d!HcJWP{7X)&P5FW;XWU6;zke2e+g*#1kW4L0Auj5pVA45Bhzt{8lj)XyMHu z0p;Q}t*NLnXbGQOTC9WdOQsX;_yLThOP$mzcEbqSBifmDecV;Fqhtm#KxfJTMhY!K zw{1L9za2QtuIWXu0ZYm@Uy72(9^vYajuP$(VAKn+&Jp z!%S;#BqO;02)n~6pFYK8oRC6wXx@kyb?VENM7l?NFg)%^=q4aZ* zwVCZxA8lalF&n`vk#gxD@!9~Aktc+h2UUUax3q2XKQLuL@CpVEpx2IyiH3bALY+Oi zDydtacE1Z|$z5qsCG=%F{}8_ozwieWuM4VcDesuu+wX&YjHm^Ryx)pVtiSLpch7<` zIw9QM%I;(VG9~#Rw2JNt+;?_=O2lA#YSudpgsM`e&(}1 zGdj7U)St4`+Cj#Vn>a;Bj~Q3S1G54;gzq)`b_Hh~ip(`BK#GMkI*RUcK+6ycjl?QN zP%sH5*R$$6A&8j8O4iFJhuHMXLJm|drH+=!_{`60&+tC8*m3Z=YsR@~%eaZyWQI|W zie7;BRL6`LR+4B{%FY~;-^*1GGTCLp!U6VoS36GUAWv$Rcc#|a7DD01{QPB=VyT)? z+1ZV>P=bP1;>v-+j0;BwenO`EKcIbmB3$r*@a~=?5#KB_R$3bTg zOjEutZmWuylIJQEM?28D!4uA#0CZEJHar0TFAW|NwP;WL|EIb_L2>;}e*N!K9E61S zH&u@m!n#CH{C_j}{#ybCRU8z7`Cs{b>@bxSkp3lOm`qAYgB>npnw%V>w}t_+S_Z)s zFhGKq^e%m@=^@sD0CJdAK=`Upbr}?}N zf;snqpt#dOwhxFg82(%Tw=ND+@`O0JGeOWd7-3R8A%Yu%FhiaYXD>p?t2+o%_1CKE z{g(>=g%}X(O%M!}KZK%$7-F<34wD-24`y$WLDv6z?ty=_b)Oi*x&?v}3j0f`AV3HL zWPAYw!XNs-1EmZ9=d=$6LAJISVJ4&gQM5=bh{!f0OmFNz8oMnGgOl_RK5VPP3@87C zpLS$muCo5YTmOslSjFLb`AXIJ@1|O{pm5r z9MxUb-8HMe*|TpFa%dE?+7}MVQ-A-N8wvtq85ROU2%KYt4etC52X0%W08haQgGSU| zvw=K$djTYSLy@4My_Te_8JcZp8Oozg{-ey>*Nm7BkGrX(M~HU6N16U>DSj<`;cy`u zR?0B23zx|*o1V=#fLZ=wd6*NfWjC`pqA^mt>DTZjGBeX`0ZK>Qgxz+37Dyb#NT4WT zl@7Lmh{WdY*h%e_bo6(oXKw=`(9=mft10fOR4< zCOUungtJZPAM~qw4Ydy9PhvYTi1ZRqR5nY)?OX2O3FJ6VXy&`&H%^U|d>N>@f zQ}Cd;DV}_bNiVTW8HcUJDRvXCUwGx|(r2-KbXY>jpd!~jmt(

      p*eWM!w;MI3hb2}DlKZV3$r+FsPh`1u zZIhE^SI{5xAuy z+6s18Cax7>1@Y#i1q$NF_)Mm4=(dAqT=saVC z3ws?DY|72z{D*xEl#|0weiXdOyz;mQF%odcChZxu=xzy3zt9$`P-=&#C8aI?6n%3_ zz5<`IpS1r9aqk`~?k)EH2L|zR(c4Uv$Z2a7olc=EI=84->IDAgQ0O!c?s;j~GwDOV zK+prKX=xdIJ0N3!LOV2$b)N7Uaj>3E@S6-{hjDvE>t}L0kZcXl=YV}q=M|8&#+IV% z7RD^fabFCVe->sNrUPX?l52oFvgCj*D!(yOiEb6ol7ttL7T=<BUI`ejr zgg*+ZP+8(22v|(^48*#}u^g}B3f>xir^adn-`lmfOb<^G-!y7%`k3IZ`#M zB{(rrRg$?dR!WsBMPBXZhE#(*5aSeOsDc}n@*FavsCX4WLSl+8v?3`toT5z?Tru|% z6?^pxQ6s6!8MuxUMnH2qd1lR2{b+Z7{vwZ;`Ts(=fC zfROnoU9kNpRnRWsf)bQ~o;_|;`3e3h0#0{2iZq`)?zha}?%a*6PcgK1jgmijN#M&dYfe=TeRB#a0%Y3Of-H;!G z-nt(l!?_lU2Lp5&eSC;vz@;ZmG))Y7eVe8d>|(`l`0BrmtG9x4ViWwD)_yVm{NN+$_Iq_fR=NdfKXQO9!4q;TmDTPWEhSHC(H4OP- z5**=I$LJrFaxI9{BXMC!X@#$%I3AX}cp}#yj9^kX({mj|U&A@Xm1Nwj>YaO(b&FH$cCc{u$!BM}fBu4imE&Nr$UwbW0Ai?7U`GlSm}-%GfbhuDvw`ysfRC9Tl8_e1vG zFc(_hSkSZ5_t6T|zTl5;1m6_vxp zv8jFu#m0PB;MT-~Gk4klcW6H^X7>#l0>YgboQ*~e z(u8wYS#o)gVFUiQxT|OO)9)TMV%9Kc#|>bxwuS=01d_9T7uAo<%BQl>XCs?x7t$XZ zbQPJ4DwJIxsIRGGb4cYt=DPl@9VXctOQ}0cp*zc_yWwotnlC-ebqe}TpZaSse6Gsx zvhDY})0FSKSJqRno1PC+x0=UjjLQ={Nbu$QnRc=>JNSospB?U#tf2Q3!~Koe!2KGf z?@-Lvz;C>#I1+5%tr)>>l9y}3_wPtQ)c8Q+L@GMM-In1m;wpxXA-pC^cS zVTO+a{P)rRGv9U;P(^SR-V?$7o3`L)OxNw+?`ssxry*L)S1OE;^P#0{CYbjHP=D8R z4cy0N(2FvP${xWJieTmtDD{a+@SR{w1--L?dW-c+a5UPkYzK+mTLQ_65XusjT?JRk zB6J9|iiBu4&%`j<@P&n1weU%{grol^4z1RQ${fj#X*2{0?XshpYqAz> zCUrEjg=}fFhEioTHkL+hq;9yvYZdls)}J>l+>X3*ATV>LLZor;SdHKs~>D&N01H-%VlfQl~aT)oDl zW11w^joAb`l@;o!(BxZO*NN(lEQaDMex6SH`@FWk$cyz3wLzg*LgAAZU!48k*xJGh zOJ8;JtEE%*gzD5V^wz^3^*YX#z;$Xi$<#+sf8;BD>rnLVH)YVQvvBB%!|-e;0|u7^#EtDm+1(w zlx6!HBqj+PiySwT^SB$zTL#P5oA(+~?n0cja>E{cW|H&RaUYJ0L99_Oild`1crHq| zY?*Va+O0{@(=N9CDM}IRI$2A2(QR_9wnOGRJb2n)8q(G*=V+)}yw*olX)y%Ti3yak zlpS)x5B+oCKhd=vtFq0m*?pifv53mPmejslmfNAUjZC3)hCNaIpP8R60N<4=dRaJuIOQWc>5tI1pi z1C*g5^!@?^-UI8cMJ$pT{@RinnTv#g@DIZK<){K3TZV zsaV~_btV*zT5TSN6*4adonBssH$nkp$)s~K_(QK6kTO{iP;0%$`rc5VR!`~Xv24eW z!hqX+=jd7yp=yUGuNcv8!J=+I)>+$8!@a&zxA&8@XTek)*{t37{UadA>{^M!%pix!xr2zD#!Ys5{XQu-g&!%3 zw&9oqNIF>cu3|T?!5C_Z0Z%m`Z=686&VgOVKAS6dWR>De2tVJCnm_&1$N9?j&E8oP z+0nu4qUNJ=h3T=gk|jk9?ctjK>IuT5aX{hnE_Z7;kbJWl$o$JdFA5QtXFXHCBE1T{ zQBJ=m6<+P0Gyg&4l|8})S;B05$O|fG(8HNEC{SE8a^%=v>$*PZ#SocA#ztCfWhcj3 z$RIyTH)ozAZbrf>yT#H#-nB4~B?`B*+#^v&YQ1;pDwhHdtBuB^KQ6yGK=#5WZ&gFP zC|FinN3zcEuqU=?8n9loUoLY5U+4bYCWxp(lbJ7d7(aea88Iw4y>7pqZxk1WaAV06 z)Iqn(u0BPsM(nNG&ZxjP^~Sr3O-(jhWLI%Y#iv(6&aBPeytXZkUQTcHN)!gjgG-JS)z^Rn9`; zC<0~P`>qKwWogD?a9{?jKI*1+v-Uz$&^8E~|{zX!{2OsMF_- zW@v&)Zd_x=K)1KZWS+XgHLl;f`0Q5V`YmXI)5DZ4Rp!JBShI3Q>HG$te(N@G5*5KT z+B0~ABi^7U?Y1`%rd{{VbmR~4Th&X3#B96n6zu5(Yg6^j7F5GseLk@oR*ROm_Qd9L zaq6?TRiKc?XyE~Ztp_X$?aJnf@w`b6Zln-b zIqz}?)G1Yth90DGU(O!TcBxf;$dlWnp~$l@D0-*i`M3v!p)CdHLB{;xMJOqi(|W0liNk zO-^Ow=C5j!&Saz-jnHIB^g@ao;{U}kd7lqC8vs|{gGo%&PW7Bg>*oh++|pB&)gH5RK}d462GM?XrMWOq&rku3Ez=suNUiy4gdkH7+69=&YKrrP72W;* zQda+U23SxkJQZInRk3@L9!`=6f3H0zD+??(xAetJkgZ_qo5Q?oN3@%x_ZFF805a6YBmV+@P;MirfnQJWH|0$aW&tWrrsdl_l-zYf|??SBf zJd;pYsjiJs{`sj2l=A{(X=Z>lf@oQpqqd?{VpFp4`rDNLvd8g!zO~}KGyO6mRXq{> z^v9h_)i_VPgmbaMSRqO1PYvb4X{`rIouixL<)3uH?1SK1ZFqr&9oVY?Ep;N_&w}F_ zg1s#v@kZ%CM(N7RL*zepXU3)~k?n5Tp;$FGchUyJb2Q5dLAkrZc;%;XFRU6HI~JD6 zo~EeA!%NP%Lh|}H)5F_^*;D~(yzOK7*EDr~Dt1lQkLnl2m8*&vcQ6x(!Xj&Bw3#7J zNKK~I@0#V_a0GxRlWGU-v|vEfRQ9!}$o*((#6$FHF#ex%i(*ax39#x^I}Ucz8%|bB zBzx-Nd9lR{hd#FA=73GaRc*WMuy|g* z?%?Rwo3mgolttaH(QJ1I$SR;p)t=Q$itIs*A>>ep&Xz0n$$38tr(BEwbL@p2-a)rP(fYNMm>U<>@xg&kP z$wU@jwaeDP`^00h_2nWhzr%m-YiL-=ETegvRa3Gy+)@&0$N&50ZL zDK*$459PvD>Fsh2E$SeA2cIDg8aXZEV%3S6SUk+^STZ z4CCYH8w&TvYy88T23Mxc_qXO~=;TnK+`gQ)H?^5b$41Avsv{FBjt8 z@mooKTAqpPlzl#3V%x(eaa`=5#n9t;Eo!{3U_w^dj$;x{bNhWwlY z?qF7(3mqNNwy0PqR7x#U{+3ZyzhDBeZs-Lxrivnt@(NMLne@T<;NN( z%$5RMP6K1&y3B|+L^qG?j`)ipgdwb$lU@P0^+KjbG1M#kieWK2oy&!CsbgNfD7BMo zTA14eoWOM#PLx2Od?op(G)5Ev8nZPfV`+poQkSV1WjTs~pa6C!mhw#oTWOvR$3yY( zRpMUQ(!@*U)z*!!dW+@qdWGZGFAs0Us;rd7fI=JP-fBIrEd#-YIe9|R6kHbTxQmb{ zn<_=!j=u=jYl%LWkm}Q9C)opTT+l9WurhgHYJ{kXQEv8z~v{ELf;%baSF;Oh^gc}i6l5j&M&y>=+a*-sOWv&d0+ zqa#Gefl7@qQKo*^7$-z(FED@Vlyr-d6Wx}n#Vu^b%j`v15Lb+ugv0K?L}rkQ+M?I? zBv*@Q#tjEa$t+Ar`IoX!^*_4{zT-(B4^F0N9edgkurNm-sEqY$@|RioV1#-)E<%uA|I z^%13Gno@-HyRoh{DGDH7PRfY+Q?ybt$7CTCxq% z$SfaEz+$MY#i0MMFzD|*)|rn90r8Ci0^-ZR*Axl7B8&hUxmpI0BA)p{31jz0L*)y9 zMo0VvhYG3cLC!QXOn*H=5LaB$DCS_HtFZRdr6L?bRZ+5=dR1$wbfL7NLL29zvO%p( zjcx0rofDWsj`9ig!*`_P_lDPHi`jFQ(^Q+sVFWA+`i#u`xcrfQG+SRj9;0j}8(Hnl zz9djd57HMzyR8Tx84DsE(Fp&@ zzEUBWG&N59MO6l$VQxqSK zDN2A>f3*;B9ayKeP?L5Fuhu3bwDfLQ6JMlh*W1w|$vWNR!CT%gqG(r4t$ICi5s=-P z!x9i7=6pUeUVN+n$w{a4yGXUy2b%LqBPmJT;*e&2zsP01+;~JS*S9p`S7;W; z4K)c!q98^RI5oxKofw1ki#v0Il1j9$LclhmQx?in{mEL8Jwi9_IsMZeX%^iCC+2%$ z)>rRvwj;QmXLykGj@|1B>T2B+Z>@eBwU>XEz%IhUy|BkBBXO(3P6TFWvSdZ%pezF+ zqut_JlSY-|1~rP+bu+OUJbi^mqr2|HDlieGwpgyK++w`3I;y&0R<76X%B?K7kg}>) z!B#GoCS@f@8BpW1;;>}sT~T=Yv`|W5d!2exx_Oifnc?eT zg`X~j4V!Lglo=@Tp_VQ6p0SgW{~i%)S|R*-G{|*n{fa;PdWNJ6yf4UU9%0*34A5!| z5g`zfg%bga)ExgTX`Bm7(>f2`+m<22Kd%CQ#jL2W4QbcvuX7M_9sd)P`!<9 z$RoS5(db}dBa{r;t7JpJ7DaR=OT!BTT?00P89YdSXAHA7&7xmz<1>4sS*o2ZJ&Z=i zy2QadKJQNoxLL`RFu2zvldlG#65=XOVTQ(l4J1;?QIwizBLN*PtlIM3CQYUoG5sO5 zj=X{n3M`f$0R)@}Y_AzW5Yul(AYNOdhQsxF`p@@pB1MP52J{-A&nV{irosr?TqDs# z=;9r=Vto)D6=GGK_b^t2IE|m+R0AgUM^!e+wm+S0M|DVIdBUg72d3tNQd5|#pYO=7 zPR)?A$t%;aZ2WR*V00=7ekt%VUP(Ya9KeXxL8X$-?Q!JZ1+%v<>YL3rub@gNTRdrL zGezK`O|UTl+;CG+ytO#UBFu1|8qmP$?c`|iYw7msv(@iVta*>&(W)o0y0H&k4Y{o0 z3{j#8k(rXL(=bXN^yo97+|LqNqAX;1f$%>*frED&a;PVnk56fnTR&M2>@K82FtrJx zoW)ro!M^$_gi)2(K`ZS}sBa;6j3 z!X^6G)ML_=3!9P`^+2A)llH_J*3ug3%;r|Z!`0rfCaa2Kpz&vbmUI&}E@5<$aSqN^ zGRM!l+K?nWm3D~We?ZqS4r#3d9dYLB0YmAB^qEl{8j~hFBUspUFYIHj;6nMV$@zWv z`GU!Tk16kj1rFU*yxH;dUxtm}cf7X?^X=Cubg2q(C(y(ZVlos>i4u0%ABWqclkJt- z58dsf+x_)$r*g2Hh8?FEyir2U#U=xH6*? zX9dS_3)(6CxK_?u_}25=OTlo@GjHZZ_7~qVW>y0xK&xu}&6-PjLdp^K-{DZLky0k+ zZt@mG{L$c2CsM=2QRE!xUyGo$BXO})FNBU~7`;}G=_PZWv>b_Hkfi=#AP(d-k}d{} z5g1D<0KaIlLIZRcT}KXj#L2MzePqbcaO9T@me~~PlQlPC6f+W_Z}*;OR2Y$@Y-p4p z>{pZSK_j#^<2UfjP&nEJdR=e{AV!v_=27`8FuY*F&D>k$XxFxxBFx?X>OgQ#gLJRz zGqi@PLmfZ11yI&Z@H zHaVeBn@rfA&lGvFCis^s#t(6}Hg#xLpoXrmig(flx(b>!9pIjHTOoxTjuTV6ix5ni z3oj(rS`@{>f!-^?P+8#iuk%Ug!5W=c?*iWVbOA%{tZx?go{TDQ+0s9XtY1fiH0{cW z8XPCF^3GN7A@4wa?qGC1$NvP6?DsPfX2#Q;t8ZseA9RYw z=)T^q7qD%$7gv%H%Q!QeouXuA&>fuKM2ZJ@`Lo1v09fzlTVKfyn_P3i-^AYnoe;fz zz1vnYV%^Bh;1xEpU*_g4k1dm{Jo@C`GM@X2Ejpe`-@d2{=P2A*qY{ z!*E9J+scte+VBh)ZAkRTEuEK`2c+FgKzwdiQxD4M^^v4E1-xjDNvY_v7n&yT`u8ZU zsak|l(?S>p1^;rG#9`*2`L$?fdb3u8@;r04yD%kuO^R35-IM5OX7EFGS<~YB(49V= z*(L0DGeMYllq{bZ2YF-MDq(@yc1pH~ImFs5fsIF_GKjT ztjgq}U{T>qdsc$*bwLRaUakca#JPh+r++`TNVZiAc9fXnQLv4P1SXeGhsHBMV>IRt z&|%tXD!<;66ayMbj+kyA7#-Cf*}nAZ>6(8_6mrg4Zbs|zgECJ*5=M2gV7b=_KHgW| zD$YZ_@RAg$6B#fu$_WX61~J@kzSh@B;0#}=mjdbpf@5e=@)<7xAsrX71nC;!41RSg zI#A(=!~jT0is6st zVr;A#33q6%{hLssmFHF-lI`Lyol&qJ+9KK2CqKb2rA;X#OP!P7K~!YWhqZRZ9lsbJ zHr%se#s4slO95RjT~>1 zCiZVKJA)Ac{Q9NU9T01M-f)ELS2Y+5E+SYw${DWFp*iDLLSPVweCy9j^4d&M%EnC5 zQu;H=eMbXlSluABU#5c3l0sd@#Q`u?qQLytt55iWAvq?Oer*AcqkOYSbhn1y`rT_{ zeP{5Hn|?*j#ra@IUi1FEcsDx|?jqqKVSC2latgN8LZO?JL{R$J#m1~7V{X|A{`dWy z=R2R5Pt8vj9Dh*N91UzpFY-x*>Sm^Id0L58Ff0%^{HT~VLKkey+u^LP`0dX6jts|R zQV<-)FN?ZI8Sz!s=Oy#Xbe%RtLZ(GJS>-Ev&tUMC(XX7RlUpuz9`84PQHmL zx;?H95V2JvYJsAw&hqtBc2m#B?xEXZ?FvssN_e*??k8RjeNz<@iH0w;!!8LdzJ0@E z?Ffi2L!xG7D{)PWadQ(SQQ)EeF}_xC{_;1gkV4bJq;BvB$ez$Ou=x&TUAYy?9^ zPh(vYIJ!=OZUuCkq3^a=vzfFWP^vC2oQ(44W_QVqOXacaW`IxfxXf$$x$80g^3-8b zjHUI|1t7T0{uFw<6Iu;nXw6EV8T9TR>hx7q6COg>RxBIGi1yF?s0<`(h+rSs7#v>D zP769_q1zh)i~dNwzjLCK`d#Pv6$BHU;wc~}pTCi1lH(+XGa;dnAepFH4aUyZ)N|J) zw!&*ct{?#UISa*_spr-)G}-YQu|G#N7yuewxg+O?hW?_dHG?JD^jfWyo{`{M!+*mp zF1qv8(S?kPrlgsxczW*1B-uQ@dm|we;u@&t`Ud@;S#Wf&3;}bel`9ymylurh($N%g za~kd_cb-2$*U}o1IPXDHc*CPUsq`dJ1jvMiNL+B9m2}n8i>^o9QoqbM(XG#|i~z}1 zf*(uev{ob+;=0v(7RwAo>|isL)DOLW-T7)gl!Kg2~BxL41UDz>AiDTe`R#Ep-R?Go6fW!u{ zfi2&FdnM`?dip|53_v6)WKA}O%LV0NUsuD$t^q^4%f&!ZM2e#?M<&@modn$@rRl2t|U`T|lC7_@5< zmd5nd&B217g(u2&z{U8|3=I1oy6B(ps*h^DmCXre7XogQ7m6R4zgpb-sB8%#sfuxX z!m^ug2opaNFJ(G=A8G7%Le2aL_W1E>{YOJ2OYdQrae2H)<&6}#k z{mNH^&m*3<;pNO}!Ic&TRx_Yw!*o{+c!qD-F&S{8u5sFOqq_SJ7b}OtpF5#vHTl2F z*6+TuYrMO}ovrLB(37m;7}goKPvr|1kuj5~fbs=}!i1>5@BfpVCyYJQHfu1GlOFo` z%3B3Wy1zO4$OT}&AjJH^l&RhSAu)pSA!g0^t3EFx^`IPO<-36LWC{bwaWe1<)wG-5 za7f0P>LAQ<;08UwlW6{@f_q8Cq%a|#OUEG(&88$}c$S}bF#0DuDw~94%MDq`i(^Y9 z>X`G(PAv`_uu#@L<`sV}j#Or_$fln&46^gdABbnyyETV*Yk7ide0{PPSktWn(mSU8 zvqyt;6#e#(X}CMmoBJWq_8pD8k2p*A*tf0dcbyl)w^j=Rgdy8j#6Jn&g$>WytH;QB z65f$JiOY%QKcL*0o|Xc_f7Pb7FWXbU znj@lQe<@=NgEdWmd$y3?@0)bfOdKxy=rMG>iD?=o$N7>5JGPZrA1I|6lFzp47V6j{ zaZD=Uet4Tdjio^WN>qc7ub7%-O(%$61YK`y4SEwzRR4_5WgmtQ0mFrAcw zv8VCOx%uwvYFe=h#bHJ7TC-+Q^LUKywVbA-lPII1SC;RjXG2A9r$tv)f%Qaf9{@@6 zm}#^Ro_NnsQSal4!}ehY3P|gC5pl1ONOrPOk#Mb;M4?0p>n=V)pp4w5V5@(^qH;6k zw!1yJY!~Ru=!Qqx?U5VQH`@$hfiby8%{j`slSQ~?+z)m-CJ5cwG5$E;4dW86`#`Dl zR)+>b|Lu|n2QOL@{#O&^KW&l!`{nz8Mh5%SWBqNPrd^^Y{J))U5D=vQ>-uNR_jg47 z*Z3J6vBV6hAo&knUHS&d`0|&Iefhmj@^~fw$LH0Vz&k&JA9`@ITB22u9sR1vhQr1C(9= zp?Ki)4LJam7kDS05UjB&1W5J%3nzV1{u&oI@c}PBeL^e2h{yt0J~uHVK7;Ku%yZnxzDBXR#gL9qNjDj2+j z54cPD8(~T(0AK9T0&dg)(r-mDV74smzb^A#e85xIf8>f?9>C|k|4_*Sw7)Lk9zFoN z;9nX2@8Kep{=b{{srcU@B6w?$2XIyTmkR9j0z@nRLQoa)-NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto init +if "%ERRORLEVEL%" == "0" goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. @@ -54,7 +54,7 @@ goto fail set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe -if exist "%JAVA_EXE%" goto init +if exist "%JAVA_EXE%" goto execute echo. echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% @@ -64,28 +64,14 @@ echo location of your Java installation. goto fail -:init -@rem Get command-line arguments, handling Windows variants - -if not "%OS%" == "Windows_NT" goto win9xME_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* - :execute @rem Setup the command line set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* :end @rem End local scope for the variables with windows NT shell diff --git a/packaging-test/test-app/build.gradle b/packaging-test/test-app/build.gradle index a389a7ca2..53b26c0b3 100644 --- a/packaging-test/test-app/build.gradle +++ b/packaging-test/test-app/build.gradle @@ -1,7 +1,6 @@ buildscript { repositories { - jcenter() mavenCentral() } } From 6339515622773c93aebb28ae12610ec05750123f Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 7 Jan 2022 18:47:46 -0800 Subject: [PATCH 609/673] update build for benchmarks --- benchmarks/build.gradle | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/benchmarks/build.gradle b/benchmarks/build.gradle index 07a80926d..b23f2b9cf 100644 --- a/benchmarks/build.gradle +++ b/benchmarks/build.gradle @@ -20,13 +20,13 @@ ext.versions = [ ] dependencies { - compile files("lib/launchdarkly-java-server-sdk-all.jar") - compile files("lib/launchdarkly-java-server-sdk-test.jar") - compile "com.google.code.gson:gson:2.8.9" - compile "com.google.guava:guava:${versions.guava}" // required by SDK test code - compile "com.squareup.okhttp3:mockwebserver:3.12.10" - compile "org.openjdk.jmh:jmh-core:1.21" - compile "org.openjdk.jmh:jmh-generator-annprocess:${versions.jmh}" + implementation files("lib/launchdarkly-java-server-sdk-all.jar") + implementation files("lib/launchdarkly-java-server-sdk-test.jar") + implementation "com.google.code.gson:gson:2.8.9" + implementation "com.google.guava:guava:${versions.guava}" // required by SDK test code + implementation "com.squareup.okhttp3:mockwebserver:3.12.10" + implementation "org.openjdk.jmh:jmh-core:1.21" + implementation "org.openjdk.jmh:jmh-generator-annprocess:${versions.jmh}" } jmh { From 4119fbc224bf0fcd7ad28c10abd1b38adc2214ec Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 7 Jan 2022 18:52:29 -0800 Subject: [PATCH 610/673] more Gradle 7 compatibility changes for benchmark job --- benchmarks/build.gradle | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/benchmarks/build.gradle b/benchmarks/build.gradle index b23f2b9cf..36abab03f 100644 --- a/benchmarks/build.gradle +++ b/benchmarks/build.gradle @@ -29,6 +29,10 @@ dependencies { implementation "org.openjdk.jmh:jmh-generator-annprocess:${versions.jmh}" } +// need to set duplicatesStrategy because otherwise some non-class files with +// duplicate names in our dependencies will cause an error +tasks.getByName('jmhJar').doFirst() {duplicatesStrategy(DuplicatesStrategy.EXCLUDE)} + jmh { iterations = 10 // Number of measurement iterations to do. benchmarkMode = ['avgt'] // "average time" - reports execution time as ns/op and allocations as B/op. From 5c68abd1c4258765ee86dae7c38ea5670da001bd Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 10 Jan 2022 09:47:03 -0800 Subject: [PATCH 611/673] test with Java 17 in CI (#307) * test with Java 17 in CI * also test in Java 17 for Windows * fix choco install command --- .circleci/config.yml | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 7a4ce6ad5..0dc768359 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -17,6 +17,12 @@ workflows: docker-image: cimg/openjdk:11.0 requires: - build-linux + - test-linux: + name: Java 17 - Linux - OpenJDK + docker-image: cimg/openjdk:17.0 + with-coverage: true + requires: + - build-linux - packaging: requires: - build-linux @@ -25,6 +31,10 @@ workflows: - build-linux - build-test-windows: name: Java 11 - Windows - OpenJDK + openjdk-version: 11.0.2.01 + - build-test-windows: + name: Java 17 - Windows - OpenJDK + openjdk-version: 17.0.1 jobs: build-linux: @@ -85,6 +95,9 @@ jobs: path: coverage build-test-windows: + parameters: + openjdk-version: + type: string executor: name: win/vs2019 shell: powershell.exe @@ -94,8 +107,8 @@ jobs: 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' + choco install openjdk --version <> --allow-downgrade + - run: java -version - run: name: build and test command: | From 3b60cdb6e7a89f6614ccecebdbd298059c6249c6 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 24 Jan 2022 20:14:00 -0800 Subject: [PATCH 612/673] do date comparisons as absolute times, regardless of time zone (#310) --- .../sdk/server/EvaluatorOperators.java | 6 +- .../sdk/server/EvaluatorPreprocessing.java | 6 +- .../sdk/server/EvaluatorTypeConversion.java | 7 +-- .../EvaluatorOperatorsParameterizedTest.java | 59 +++++++++---------- .../server/EvaluatorPreprocessingTest.java | 5 +- 5 files changed, 40 insertions(+), 43 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/EvaluatorOperators.java b/src/main/java/com/launchdarkly/sdk/server/EvaluatorOperators.java index fa9a2cdf2..3b383942f 100644 --- a/src/main/java/com/launchdarkly/sdk/server/EvaluatorOperators.java +++ b/src/main/java/com/launchdarkly/sdk/server/EvaluatorOperators.java @@ -2,7 +2,7 @@ import com.launchdarkly.sdk.LDValue; -import java.time.ZonedDateTime; +import java.time.Instant; import java.util.regex.Pattern; import static com.launchdarkly.sdk.server.EvaluatorTypeConversion.valueToDateTime; @@ -120,11 +120,11 @@ private static boolean compareDate( ) { // If preprocessed is non-null, it means we've already tried to parse the clause value as a date/time, // in which case if preprocessed.parsedDate is null it was not a valid date/time. - ZonedDateTime clauseDate = preprocessed == null ? valueToDateTime(clauseValue) : preprocessed.parsedDate; + Instant clauseDate = preprocessed == null ? valueToDateTime(clauseValue) : preprocessed.parsedDate; if (clauseDate == null) { return false; } - ZonedDateTime userDate = valueToDateTime(userValue); + Instant userDate = valueToDateTime(userValue); if (userDate == null) { return false; } diff --git a/src/main/java/com/launchdarkly/sdk/server/EvaluatorPreprocessing.java b/src/main/java/com/launchdarkly/sdk/server/EvaluatorPreprocessing.java index c3e95a0ba..b31f9a3ff 100644 --- a/src/main/java/com/launchdarkly/sdk/server/EvaluatorPreprocessing.java +++ b/src/main/java/com/launchdarkly/sdk/server/EvaluatorPreprocessing.java @@ -11,7 +11,7 @@ import com.launchdarkly.sdk.server.DataModel.Segment; import com.launchdarkly.sdk.server.DataModel.SegmentRule; -import java.time.ZonedDateTime; +import java.time.Instant; import java.util.ArrayList; import java.util.List; import java.util.Set; @@ -40,11 +40,11 @@ static final class ClauseExtra { } static final class ValueExtra { - final ZonedDateTime parsedDate; + final Instant parsedDate; final Pattern parsedRegex; final SemanticVersion parsedSemVer; - ValueExtra(ZonedDateTime parsedDate, Pattern parsedRegex, SemanticVersion parsedSemVer) { + ValueExtra(Instant parsedDate, Pattern parsedRegex, SemanticVersion parsedSemVer) { this.parsedDate = parsedDate; this.parsedRegex = parsedRegex; this.parsedSemVer = parsedSemVer; diff --git a/src/main/java/com/launchdarkly/sdk/server/EvaluatorTypeConversion.java b/src/main/java/com/launchdarkly/sdk/server/EvaluatorTypeConversion.java index ccc0e12b9..f23dd901a 100644 --- a/src/main/java/com/launchdarkly/sdk/server/EvaluatorTypeConversion.java +++ b/src/main/java/com/launchdarkly/sdk/server/EvaluatorTypeConversion.java @@ -3,7 +3,6 @@ import com.launchdarkly.sdk.LDValue; import java.time.Instant; -import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.util.regex.Pattern; import java.util.regex.PatternSyntaxException; @@ -11,12 +10,12 @@ abstract class EvaluatorTypeConversion { private EvaluatorTypeConversion() {} - static ZonedDateTime valueToDateTime(LDValue value) { + static Instant valueToDateTime(LDValue value) { if (value.isNumber()) { - return ZonedDateTime.ofInstant(Instant.ofEpochMilli(value.longValue()), ZoneOffset.UTC); + return Instant.ofEpochMilli(value.longValue()); } else if (value.isString()) { try { - return ZonedDateTime.parse(value.stringValue()); + return ZonedDateTime.parse(value.stringValue()).toInstant(); } catch (Throwable t) { return null; } diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorOperatorsParameterizedTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorOperatorsParameterizedTest.java index c8a21bf44..9dec6a0ba 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EvaluatorOperatorsParameterizedTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorOperatorsParameterizedTest.java @@ -1,5 +1,6 @@ package com.launchdarkly.sdk.server; +import com.google.common.collect.ImmutableList; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.UserAttribute; import com.launchdarkly.sdk.server.DataModel.Clause; @@ -18,14 +19,8 @@ @SuppressWarnings("javadoc") @RunWith(Parameterized.class) public class EvaluatorOperatorsParameterizedTest { - private static final LDValue dateStr1 = LDValue.of("2017-12-06T00:00:00.000-07:00"); - private static final LDValue dateStr2 = LDValue.of("2017-12-06T00:01:01.000-07:00"); - private static final LDValue dateStrUtc1 = LDValue.of("2017-12-06T00:00:00.000Z"); - private static final LDValue dateStrUtc2 = LDValue.of("2017-12-06T00:01:01.000Z"); - private static final LDValue dateMs1 = LDValue.of(10000000); - private static final LDValue dateMs2 = LDValue.of(10000001); - private static final LDValue invalidDate = LDValue.of("hey what's this?"); private static final LDValue invalidVer = LDValue.of("xbad%ver"); + private static final UserAttribute userAttr = UserAttribute.forName("attr"); private final Operator op; @@ -50,7 +45,9 @@ public EvaluatorOperatorsParameterizedTest( @Parameterized.Parameters(name = "{1} {0} {2}+{3} should be {4}") public static Iterable data() { - return Arrays.asList(new Object[][] { + ImmutableList.Builder tests = ImmutableList.builder(); + + tests.add(new Object[][] { // numeric comparisons { Operator.in, LDValue.of(99), LDValue.of(99), null, true }, { Operator.in, LDValue.of(99), LDValue.of(99), new LDValue[] { LDValue.of(98), LDValue.of(97), LDValue.of(96) }, true }, @@ -116,28 +113,6 @@ public static Iterable data() { { Operator.matches, LDValue.of("hello world"), LDValue.of("***not a regex"), null, false }, { Operator.matches, LDValue.of(2), LDValue.of("that 2 is not a string"), null, false }, - // dates - { Operator.before, dateStr1, dateStr2, null, true }, - { Operator.before, dateStrUtc1, dateStrUtc2, null, true }, - { Operator.before, dateMs1, dateMs2, null, true }, - { Operator.before, dateStr2, dateStr1, null, false }, - { Operator.before, dateStrUtc2, dateStrUtc1, null, false }, - { Operator.before, dateMs2, dateMs1, null, false }, - { Operator.before, dateStr1, dateStr1, null, false }, - { Operator.before, dateMs1, dateMs1, null, false }, - { Operator.before, dateStr1, invalidDate, null, false }, - { Operator.before, invalidDate, dateStr1, null, false }, - { Operator.after, dateStr1, dateStr2, null, false }, - { Operator.after, dateStrUtc1, dateStrUtc2, null, false }, - { Operator.after, dateMs1, dateMs2, null, false }, - { Operator.after, dateStr2, dateStr1, null, true }, - { Operator.after, dateStrUtc2, dateStrUtc1, null, true }, - { Operator.after, dateMs2, dateMs1, null, true }, - { Operator.after, dateStr1, dateStr1, null, false }, - { Operator.after, dateMs1, dateMs1, null, false }, - { Operator.after, dateStr1, invalidDate, null, false }, - { Operator.after, invalidDate, dateStr1, null, false }, - // semver { Operator.semVerEqual, LDValue.of("2.0.1"), LDValue.of("2.0.1"), null, true }, { Operator.semVerEqual, LDValue.of("2.0.2"), LDValue.of("2.0.1"), null, false }, @@ -167,6 +142,30 @@ public static Iterable data() { { null, LDValue.of("x"), LDValue.of("y"), null, false }, // no operator { Operator.segmentMatch, LDValue.of("x"), LDValue.of("y"), null, false } // segmentMatch is handled elsewhere }); + + // add permutations of date values for before & after operators + // dateStr1, dateStrUtc1, and dateMs1 are the same timestamp in different formats; etc. + LDValue dateStr1 = LDValue.of("2017-12-06T00:00:00.000-07:00"); + LDValue dateStrUtc1 = LDValue.of("2017-12-06T07:00:00.000Z"); + LDValue dateMs1 = LDValue.of(1512543600000L); + LDValue dateStr2 = LDValue.of("2017-12-06T00:00:01.000-07:00"); + LDValue dateStrUtc2 = LDValue.of("2017-12-06T07:00:01.000Z"); + LDValue dateMs2 = LDValue.of(1512543601000L); + LDValue invalidDate = LDValue.of("hey what's this?"); + for (LDValue lowerValue: new LDValue[] { dateStr1, dateStrUtc1, dateMs1 }) { + for (LDValue higherValue: new LDValue[] { dateStr2, dateStrUtc2, dateMs2 }) { + tests.add(new Object[] { Operator.before, lowerValue, higherValue, null, true }); + tests.add(new Object[] { Operator.before, lowerValue, lowerValue, null, false }); + tests.add(new Object[] { Operator.before, higherValue, lowerValue, null, false }); + tests.add(new Object[] { Operator.before, lowerValue, invalidDate, null, false }); + tests.add(new Object[] { Operator.after, higherValue, lowerValue, null, true }); + tests.add(new Object[] { Operator.after, lowerValue, lowerValue, null, false }); + tests.add(new Object[] { Operator.after, lowerValue, higherValue, null, false}); + tests.add(new Object[] { Operator.after, lowerValue, invalidDate, null, false}); + } + } + + return tests.build(); } @Test diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorPreprocessingTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorPreprocessingTest.java index d93fc58b0..a09a1d8d5 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EvaluatorPreprocessingTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorPreprocessingTest.java @@ -14,7 +14,6 @@ import org.junit.Test; import java.time.Instant; -import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.util.List; @@ -120,9 +119,9 @@ public void preprocessFlagDoesNotCreateClauseValuesMapForNonEqualityOperators() @Test public void preprocessFlagParsesClauseDate() { String time1Str = "2016-04-16T17:09:12-07:00"; - ZonedDateTime time1 = ZonedDateTime.parse(time1Str); + Instant time1 = ZonedDateTime.parse(time1Str).toInstant(); int time2Num = 1000000; - ZonedDateTime time2 = ZonedDateTime.ofInstant(Instant.ofEpochMilli(time2Num), ZoneOffset.UTC); + Instant time2 = Instant.ofEpochMilli(time2Num); for (Operator op: new Operator[] { Operator.after, Operator.before }) { Clause c = new Clause( From 401a6c77eec7790d8024ebee2b9ce3421d4054cd Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 28 Jan 2022 13:53:41 -0800 Subject: [PATCH 613/673] fix suppression of nulls in JSON representations (#311) * fix suppression of nulls in JSON representations * distinguish between situations where we do or do not want to suppress nulls --- .../sdk/server/FeatureFlagsState.java | 10 +++---- .../launchdarkly/sdk/server/JsonHelpers.java | 26 ++++++++++++++----- .../sdk/server/FeatureFlagsStateTest.java | 4 +-- 3 files changed, 27 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java b/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java index 5ac6d7fe4..66e8bf27d 100644 --- a/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java +++ b/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java @@ -16,7 +16,7 @@ import java.util.Map; import java.util.Objects; -import static com.launchdarkly.sdk.server.JsonHelpers.gsonInstance; +import static com.launchdarkly.sdk.server.JsonHelpers.gsonInstanceWithNullsAllowed; /** * A snapshot of the state of all feature flags with regard to a specific user, generated by @@ -259,7 +259,7 @@ public void write(JsonWriter out, FeatureFlagsState state) throws IOException { for (Map.Entry entry: state.flagMetadata.entrySet()) { out.name(entry.getKey()); - gsonInstance().toJson(entry.getValue().value, LDValue.class, out); + gsonInstanceWithNullsAllowed().toJson(entry.getValue().value, LDValue.class, out); } out.name("$flagsState"); @@ -277,7 +277,7 @@ public void write(JsonWriter out, FeatureFlagsState state) throws IOException { } if (meta.reason != null) { out.name("reason"); - gsonInstance().toJson(meta.reason, EvaluationReason.class, out); + gsonInstanceWithNullsAllowed().toJson(meta.reason, EvaluationReason.class, out); } if (meta.version != null) { out.name("version"); @@ -314,14 +314,14 @@ public FeatureFlagsState read(JsonReader in) throws IOException { in.beginObject(); while (in.hasNext()) { String metaName = in.nextName(); - FlagMetadata meta = gsonInstance().fromJson(in, FlagMetadata.class); + FlagMetadata meta = gsonInstanceWithNullsAllowed().fromJson(in, FlagMetadata.class); flagMetadataWithoutValues.put(metaName, meta); } in.endObject(); } else if (name.equals("$valid")) { valid = in.nextBoolean(); } else { - LDValue value = gsonInstance().fromJson(in, LDValue.class); + LDValue value = gsonInstanceWithNullsAllowed().fromJson(in, LDValue.class); flagValues.put(name, value); } } diff --git a/src/main/java/com/launchdarkly/sdk/server/JsonHelpers.java b/src/main/java/com/launchdarkly/sdk/server/JsonHelpers.java index 9b7d6a3b7..ed9b36878 100644 --- a/src/main/java/com/launchdarkly/sdk/server/JsonHelpers.java +++ b/src/main/java/com/launchdarkly/sdk/server/JsonHelpers.java @@ -24,16 +24,30 @@ abstract class JsonHelpers { private JsonHelpers() {} - private static final Gson gson = new Gson(); + private static final Gson gsonWithNullsAllowed = new GsonBuilder().serializeNulls().create(); + private static final Gson gsonWithNullsSuppressed = new GsonBuilder().create(); /** * 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. + * + * @see #gsonWithNullsAllowed */ static Gson gsonInstance() { - return gson; + return gsonWithNullsSuppressed; + } + + /** + * Returns a shared instance of Gson with default configuration except that properties with null values + * are not automatically dropped. We use this in contexts where we want to exactly reproduce + * whatever the serializer for a type is outputting. + * + * @see #gsonInstance() + */ + static Gson gsonInstanceWithNullsAllowed() { + return gsonWithNullsAllowed; } /** @@ -57,7 +71,7 @@ static Gson gsonInstanceForEventsSerialization(EventsConfiguration config) { */ static T deserialize(String json, Class objectClass) throws SerializationException { try { - return gson.fromJson(json, objectClass); + return gsonInstance().fromJson(json, objectClass); } catch (Exception e) { throw new SerializationException(e); } @@ -73,7 +87,7 @@ static T deserialize(String json, Class objectClass) throws Serialization * @return the serialized JSON string */ static String serialize(Object o) { - return gson.toJson(o); + return gsonInstance().toJson(o); } /** @@ -93,9 +107,9 @@ static VersionedData deserializeFromParsedJson(DataKind kind, JsonElement parsed VersionedData item; try { if (kind == FEATURES) { - item = gson.fromJson(parsedJson, FeatureFlag.class); + item = gsonInstance().fromJson(parsedJson, FeatureFlag.class); } else if (kind == SEGMENTS) { - item = gson.fromJson(parsedJson, Segment.class); + item = gsonInstance().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"); diff --git a/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java b/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java index 539d160b8..411bb76c1 100644 --- a/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java @@ -181,14 +181,14 @@ private static FeatureFlagsState makeInstanceForSerialization() { DataModel.FeatureFlag flag1 = flagBuilder("key1").version(100).trackEvents(false).build(); Evaluator.EvalResult eval2 = new Evaluator.EvalResult(LDValue.of("value2"), 1, EvaluationReason.fallthrough()); DataModel.FeatureFlag flag2 = flagBuilder("key2").version(200).trackEvents(true).debugEventsUntilDate(1000L).build(); - Evaluator.EvalResult eval3 = new Evaluator.EvalResult(LDValue.of("default"), NO_VARIATION, EvaluationReason.error(MALFORMED_FLAG)); + Evaluator.EvalResult eval3 = new Evaluator.EvalResult(LDValue.ofNull(), NO_VARIATION, EvaluationReason.error(MALFORMED_FLAG)); DataModel.FeatureFlag flag3 = flagBuilder("key3").version(300).build(); return FeatureFlagsState.builder(FlagsStateOption.WITH_REASONS) .addFlag(flag1, eval1).addFlag(flag2, eval2).addFlag(flag3, eval3).build(); } private static String makeExpectedJsonSerialization() { - return "{\"key1\":\"value1\",\"key2\":\"value2\",\"key3\":\"default\"," + + return "{\"key1\":\"value1\",\"key2\":\"value2\",\"key3\":null," + "\"$flagsState\":{" + "\"key1\":{" + "\"variation\":0,\"version\":100,\"reason\":{\"kind\":\"OFF\"}" + // note, "trackEvents: false" is omitted From a6c79c2d464e09192a0764882bf5b6efa51af3df Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 28 Jan 2022 14:13:43 -0800 Subject: [PATCH 614/673] fix identify/track null user key check, also don't create index event for alias --- .../sdk/server/DefaultEventProcessor.java | 19 ++++++++++------- .../com/launchdarkly/sdk/server/LDClient.java | 12 +++++------ .../DefaultEventProcessorOutputTest.java | 16 ++++++++++++++ .../server/DefaultEventProcessorTestBase.java | 11 ++++++++++ .../sdk/server/LDClientEventTest.java | 21 ++++++++++++++++++- 5 files changed, 65 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java index 49da1e250..854de0240 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java @@ -401,13 +401,18 @@ private void processEvent(Event e, SimpleLRUCache userKeys, Even if (!addFullEvent || !eventsConfig.inlineUsersInEvents) { LDUser user = e.getUser(); if (user != null && user.getKey() != null) { - boolean isIndexEvent = e instanceof Event.Identify; - String key = user.getKey(); - // Add to the set of users we've noticed - boolean alreadySeen = (userKeys.put(key, key) != null); - addIndexEvent = !isIndexEvent & !alreadySeen; - if (!isIndexEvent & alreadySeen) { - deduplicatedUsers++; + if (e instanceof Event.FeatureRequest || e instanceof Event.Custom) { + String key = user.getKey(); + // Add to the set of users we've noticed + boolean alreadySeen = (userKeys.put(key, key) != null); + if (alreadySeen) { + deduplicatedUsers++; + } else { + addIndexEvent = true; + } + } else if (e instanceof Event.Identify) { + String key = user.getKey(); + userKeys.put(key, key); // just mark that we've seen it } } } diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClient.java b/src/main/java/com/launchdarkly/sdk/server/LDClient.java index 578f789a5..f39e041cb 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClient.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClient.java @@ -261,8 +261,8 @@ public void track(String eventName, LDUser user) { @Override public void trackData(String eventName, LDUser user, LDValue data) { - if (user == null || user.getKey() == null) { - Loggers.MAIN.warn("Track called with null user or null user key!"); + if (user == null || user.getKey() == null || user.getKey().isEmpty()) { + Loggers.MAIN.warn("Track called with null user or null/empty user key!"); } else { eventProcessor.sendEvent(eventFactoryDefault.newCustomEvent(eventName, user, data, null)); } @@ -270,8 +270,8 @@ public void trackData(String eventName, LDUser user, LDValue data) { @Override public void trackMetric(String eventName, LDUser user, LDValue data, double metricValue) { - if (user == null || user.getKey() == null) { - Loggers.MAIN.warn("Track called with null user or null user key!"); + if (user == null || user.getKey() == null || user.getKey().isEmpty()) { + Loggers.MAIN.warn("Track called with null user or null/empty user key!"); } else { eventProcessor.sendEvent(eventFactoryDefault.newCustomEvent(eventName, user, data, metricValue)); } @@ -279,8 +279,8 @@ public void trackMetric(String eventName, LDUser user, LDValue data, double metr @Override public void identify(LDUser user) { - if (user == null || user.getKey() == null) { - Loggers.MAIN.warn("Identify called with null user or null user key!"); + if (user == null || user.getKey() == null || user.getKey().isEmpty()) { + Loggers.MAIN.warn("Identify called with null user or null/empty user key!"); } else { eventProcessor.sendEvent(eventFactoryDefault.newIdentifyEvent(user)); } diff --git a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorOutputTest.java b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorOutputTest.java index dd4850263..90b1cb602 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorOutputTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorOutputTest.java @@ -493,4 +493,20 @@ public void customEventWithNullUserOrNullUserKeyDoesNotCauseError() throws Excep isCustomEvent(event2, LDValue.ofNull()) )); } + + @Test + public void aliasEventIsQueued() throws Exception { + MockEventSender es = new MockEventSender(); + LDUser user1 = new LDUser.Builder("anon-user").anonymous(true).build(); + LDUser user2 = new LDUser("non-anon-user"); + Event.AliasEvent event = EventFactory.DEFAULT.newAliasEvent(user2, user1); + + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { + ep.sendEvent(event); + } + + assertThat(es.getEventsFromLastRequest(), contains( + isAliasEvent(event) + )); + } } diff --git a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTestBase.java b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTestBase.java index 532f1f24d..ddf6a7b5d 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTestBase.java +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTestBase.java @@ -201,6 +201,17 @@ public static Matcher isCustomEvent(Event.Custom sourceEvent, LDV ); } + public static Matcher isAliasEvent(Event.AliasEvent sourceEvent) { + return allOf( + jsonProperty("kind", "alias"), + jsonProperty("creationDate", (double)sourceEvent.getCreationDate()), + jsonProperty("key", sourceEvent.getKey()), + jsonProperty("previousKey", sourceEvent.getPreviousKey()), + jsonProperty("contextKind", sourceEvent.getContextKind()), + jsonProperty("previousContextKind", sourceEvent.getPreviousContextKind()) + ); + } + public static Matcher hasUserOrUserKey(Event sourceEvent, LDValue inlineUser) { if (inlineUser != null && !inlineUser.isNull()) { return allOf( diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientEventTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientEventTest.java index d91fe8ada..4408af8b1 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientEventTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientEventTest.java @@ -30,6 +30,7 @@ public class LDClientEventTest { private static final LDUser user = new LDUser("userkey"); private static final LDUser userWithNullKey = new LDUser.Builder((String)null).build(); + private static final LDUser userWithEmptyKey = new LDUser.Builder("").build(); private DataStore dataStore = initedDataStore(); private TestComponents.TestEventProcessor eventSink = new TestComponents.TestEventProcessor(); @@ -62,6 +63,12 @@ public void identifyWithUserWithNoKeyDoesNotSendEvent() { client.identify(userWithNullKey); assertEquals(0, eventSink.events.size()); } + + @Test + public void identifyWithUserWithEmptyKeyDoesNotSendEvent() { + client.identify(userWithEmptyKey); + assertEquals(0, eventSink.events.size()); + } @Test public void trackSendsEventWithoutData() throws Exception { @@ -103,7 +110,7 @@ public void trackSendsEventWithDataAndMetricValue() throws Exception { assertEquals(user.getKey(), ce.getUser().getKey()); assertEquals("eventkey", ce.getKey()); assertEquals(data, ce.getData()); - assertEquals(new Double(metricValue), ce.getMetricValue()); + assertEquals(Double.valueOf(metricValue), ce.getMetricValue()); } @Test @@ -130,6 +137,18 @@ public void trackWithUserWithNoKeyDoesNotSendEvent() { assertEquals(0, eventSink.events.size()); } + @Test + public void trackWithUserWithEmptyKeyDoesNotSendEvent() { + client.track("eventkey", userWithEmptyKey); + assertEquals(0, eventSink.events.size()); + + client.trackData("eventkey", userWithEmptyKey, LDValue.of(1)); + assertEquals(0, eventSink.events.size()); + + client.trackMetric("eventkey", userWithEmptyKey, LDValue.of(1), 1.5); + assertEquals(0, eventSink.events.size()); + } + @Test public void boolVariationSendsEvent() throws Exception { DataModel.FeatureFlag flag = flagWithValue("key", LDValue.of(true)); From aee24877cc075212fa6cbca2de901f1e10deeb4a Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 28 Jan 2022 14:18:53 -0800 Subject: [PATCH 615/673] use latest java-sdk-common --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 21bc46813..0f33c52b9 100644 --- a/build.gradle +++ b/build.gradle @@ -72,7 +72,7 @@ ext.versions = [ "gson": "2.8.9", "guava": "30.1-jre", "jackson": "2.11.2", - "launchdarklyJavaSdkCommon": "1.2.1", + "launchdarklyJavaSdkCommon": "1.2.2", "okhttp": "4.8.1", // specify this for the SDK build instead of relying on the transitive dependency from okhttp-eventsource "okhttpEventsource": "2.3.2", "slf4j": "1.7.21", From 8f9a5c7f9a96b1ea74284ea0ff492907ffaa319e Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 28 Jan 2022 14:44:49 -0800 Subject: [PATCH 616/673] fix setting of trackEvents/trackReason in allFlagsState data when there's an experiment --- .../launchdarkly/sdk/server/EventFactory.java | 2 +- .../sdk/server/FeatureFlagsState.java | 62 ++++++++++++++++--- .../sdk/server/FeatureFlagsStateTest.java | 8 ++- .../sdk/server/LDClientEvaluationTest.java | 13 +++- 4 files changed, 70 insertions(+), 15 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/EventFactory.java b/src/main/java/com/launchdarkly/sdk/server/EventFactory.java index 32ceb9c5d..73a815e6e 100644 --- a/src/main/java/com/launchdarkly/sdk/server/EventFactory.java +++ b/src/main/java/com/launchdarkly/sdk/server/EventFactory.java @@ -206,7 +206,7 @@ Event.AliasEvent newAliasEvent(LDUser user, LDUser previousUser) { } } - private static boolean isExperiment(DataModel.FeatureFlag flag, EvaluationReason reason) { + static boolean isExperiment(DataModel.FeatureFlag flag, EvaluationReason reason) { if (reason == null) { // doesn't happen in real life, but possible in testing return false; diff --git a/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java b/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java index 66e8bf27d..cb44a98be 100644 --- a/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java +++ b/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java @@ -45,16 +45,18 @@ static class FlagMetadata { final Integer variation; final EvaluationReason reason; final Integer version; - final Boolean trackEvents; + final boolean trackEvents; + final boolean trackReason; final Long debugEventsUntilDate; FlagMetadata(LDValue value, Integer variation, EvaluationReason reason, Integer version, - boolean trackEvents, Long debugEventsUntilDate) { + boolean trackEvents, boolean trackReason, Long debugEventsUntilDate) { this.value = LDValue.normalize(value); this.variation = variation; this.reason = reason; this.version = version; - this.trackEvents = trackEvents ? Boolean.TRUE : null; + this.trackEvents = trackEvents; + this.trackReason = trackReason; this.debugEventsUntilDate = debugEventsUntilDate; } @@ -66,7 +68,8 @@ public boolean equals(Object other) { Objects.equals(variation, o.variation) && Objects.equals(reason, o.reason) && Objects.equals(version, o.version) && - Objects.equals(trackEvents, o.trackEvents) && + trackEvents == o.trackEvents && + trackReason == o.trackReason && Objects.equals(debugEventsUntilDate, o.debugEventsUntilDate); } return false; @@ -74,7 +77,7 @@ public boolean equals(Object other) { @Override public int hashCode() { - return Objects.hash(variation, version, trackEvents, debugEventsUntilDate); + return Objects.hash(variation, version, trackEvents, trackReason, debugEventsUntilDate); } } @@ -215,15 +218,47 @@ public Builder add( boolean trackEvents, Long debugEventsUntilDate ) { + return add(flagKey, value, variationIndex, reason, flagVersion, trackEvents, false, debugEventsUntilDate); + } + + /** + * Adds data to the builder representing the result of a feature flag evaluation. + *

      + * The {@code flagVersion}, {@code trackEvents}, and {@code debugEventsUntilDate} parameters are + * normally generated internally by the SDK; they are used if the {@link FeatureFlagsState} data + * has been passed to front-end code, to control how analytics events are generated by the front + * end. If you are using this builder in back-end test code, those values are unimportant. + * + * @param flagKey the feature flag key + * @param value the evaluated value + * @param variationIndex the evaluated variation index + * @param reason the evaluation reason + * @param flagVersion the current flag version + * @param trackEvents true if full event tracking is turned on for this flag + * @param trackReason true if evaluation reasons must be included due to experimentation + * @param debugEventsUntilDate if set, event debugging is turned until this time (millisecond timestamp) + * @return the builder + */ + public Builder add( + String flagKey, + LDValue value, + Integer variationIndex, + EvaluationReason reason, + int flagVersion, + boolean trackEvents, + boolean trackReason, + Long debugEventsUntilDate + ) { final boolean flagIsTracked = trackEvents || (debugEventsUntilDate != null && debugEventsUntilDate > System.currentTimeMillis()); final boolean wantDetails = !detailsOnlyForTrackedFlags || flagIsTracked; FlagMetadata data = new FlagMetadata( value, variationIndex, - (saveReasons && wantDetails) ? reason : null, + (saveReasons && wantDetails) || trackReason ? reason : null, wantDetails ? Integer.valueOf(flagVersion) : null, trackEvents, + trackReason, debugEventsUntilDate ); flagMetadata.put(flagKey, data); @@ -231,13 +266,15 @@ public Builder add( } Builder addFlag(DataModel.FeatureFlag flag, Evaluator.EvalResult eval) { + boolean requireExperimentData = EventFactory.isExperiment(flag, eval.getReason()); return add( flag.getKey(), eval.getValue(), eval.isDefault() ? null : eval.getVariationIndex(), eval.getReason(), flag.getVersion(), - flag.isTrackEvents(), + flag.isTrackEvents() || requireExperimentData, + requireExperimentData, flag.getDebugEventsUntilDate() ); } @@ -283,9 +320,13 @@ public void write(JsonWriter out, FeatureFlagsState state) throws IOException { out.name("version"); out.value(meta.version.intValue()); } - if (meta.trackEvents != null) { + if (meta.trackEvents) { out.name("trackEvents"); - out.value(meta.trackEvents.booleanValue()); + out.value(meta.trackEvents); + } + if (meta.trackReason) { + out.name("trackReason"); + out.value(meta.trackReason); } if (meta.debugEventsUntilDate != null) { out.name("debugEventsUntilDate"); @@ -335,7 +376,8 @@ public FeatureFlagsState read(JsonReader in) throws IOException { m0.variation, m0.reason, m0.version, - m0.trackEvents != null && m0.trackEvents.booleanValue(), + m0.trackEvents, + m0.trackReason, m0.debugEventsUntilDate ); allFlagMetadata.put(e.getKey(), m1); diff --git a/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java b/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java index 411bb76c1..23f660668 100644 --- a/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java @@ -145,9 +145,11 @@ public void equalMetadataInstancesAreEqual() { for (EvaluationReason reason: new EvaluationReason[] { null, EvaluationReason.off(), EvaluationReason.fallthrough() }) { for (Integer version: new Integer[] { null, 10, 11 }) { for (boolean trackEvents: new boolean[] { false, true }) { - for (Long debugEventsUntilDate: new Long[] { null, 1000L, 1001L }) { - allPermutations.add(() -> new FeatureFlagsState.FlagMetadata( - value, variation, reason, version, trackEvents, debugEventsUntilDate)); + for (boolean trackReason: new boolean[] { false, true }) { + for (Long debugEventsUntilDate: new Long[] { null, 1000L, 1001L }) { + allPermutations.add(() -> new FeatureFlagsState.FlagMetadata( + value, variation, reason, version, trackEvents, trackReason, debugEventsUntilDate)); + } } } } diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java index 62f07123a..e0fb127f3 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java @@ -385,18 +385,29 @@ public void allFlagsStateReturnsState() throws Exception { .fallthrough(fallthroughVariation(1)) .variations(LDValue.of("off"), LDValue.of("value2")) .build(); + DataModel.FeatureFlag flag3 = flagBuilder("key3") + .version(300) + .on(true) + .fallthroughVariation(1) + .variations(LDValue.of("x"), LDValue.of("value3")) + .trackEvents(false) + .trackEventsFallthrough(true) + .build(); upsertFlag(dataStore, flag1); upsertFlag(dataStore, flag2); + upsertFlag(dataStore, flag3); FeatureFlagsState state = client.allFlagsState(user); assertTrue(state.isValid()); - String json = "{\"key1\":\"value1\",\"key2\":\"value2\"," + + String json = "{\"key1\":\"value1\",\"key2\":\"value2\",\"key3\":\"value3\"," + "\"$flagsState\":{" + "\"key1\":{" + "\"variation\":0,\"version\":100" + "},\"key2\":{" + "\"variation\":1,\"version\":200,\"trackEvents\":true,\"debugEventsUntilDate\":1000" + + "},\"key3\":{" + + "\"variation\":1,\"version\":300,\"trackEvents\":true,\"trackReason\":true,\"reason\":{\"kind\":\"FALLTHROUGH\"}" + "}" + "}," + "\"$valid\":true" + From 6ff46e78ea3be42b4a2c6041b2d3bc7a8210b9dd Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 28 Jan 2022 16:51:28 -0800 Subject: [PATCH 617/673] implement contract tests (#314) --- .circleci/config.yml | 14 +- CONTRIBUTING.md | 6 + Makefile | 29 +++ build.gradle | 7 +- contract-tests/README.md | 7 + contract-tests/gradle.properties | 1 + contract-tests/service/build.gradle | 38 ++++ contract-tests/service/settings.gradle | 0 .../main/java/sdktest/Representations.java | 95 ++++++++ .../main/java/sdktest/SdkClientEntity.java | 211 ++++++++++++++++++ .../src/main/java/sdktest/TestService.java | 124 ++++++++++ .../service/src/main/resources/logback.xml | 20 ++ contract-tests/settings.gradle | 3 + 13 files changed, 550 insertions(+), 5 deletions(-) create mode 100644 Makefile create mode 100644 contract-tests/README.md create mode 100644 contract-tests/gradle.properties create mode 100644 contract-tests/service/build.gradle create mode 100644 contract-tests/service/settings.gradle create mode 100644 contract-tests/service/src/main/java/sdktest/Representations.java create mode 100644 contract-tests/service/src/main/java/sdktest/SdkClientEntity.java create mode 100644 contract-tests/service/src/main/java/sdktest/TestService.java create mode 100644 contract-tests/service/src/main/resources/logback.xml create mode 100644 contract-tests/settings.gradle diff --git a/.circleci/config.yml b/.circleci/config.yml index 0dc768359..37d487759 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -60,6 +60,8 @@ jobs: default: false docker: - image: <> + environment: + TEST_HARNESS_PARAMS: -junit /home/circleci/junit/contract-tests-junit.xml steps: - checkout - run: cp gradle.properties.example gradle.properties @@ -78,12 +80,18 @@ jobs: ./gradlew jacocoTestReport mkdir -p coverage/ cp -r build/reports/jacoco/test/* ./coverage + - run: mkdir -p ~/junit/ - run: name: Save test results - command: | - mkdir -p ~/junit/ - find . -type f -regex ".*/build/test-results/.*xml" -exec cp {} ~/junit/ \; + command: find . -type f -regex ".*/build/test-results/.*xml" -exec cp {} ~/junit/ \; when: always + + - run: make build-contract-tests + - run: + command: make start-contract-test-service + background: true + - run: make run-contract-tests + - store_test_results: path: ~/junit - store_artifacts: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 107428c6c..3bacac93b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,6 +42,12 @@ To build the SDK and run all unit tests: ./gradlew test ``` +To run the SDK contract test suite in Linux (see [`contract-tests/README.md`](./contract-tests/README.md)): + +```bash +make contract-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`. diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..413483f3d --- /dev/null +++ b/Makefile @@ -0,0 +1,29 @@ + +build: + ./gradlew jar + +clean: + ./gradlew clean + +test: + ./gradlew test + +TEMP_TEST_OUTPUT=/tmp/sdk-test-service.log + +build-contract-tests: + @cd contract-tests && ../gradlew installDist + +start-contract-test-service: + @contract-tests/service/build/install/service/bin/service + +start-contract-test-service-bg: + @echo "Test service output will be captured in $(TEMP_TEST_OUTPUT)" + @make start-contract-test-service >$(TEMP_TEST_OUTPUT) 2>&1 & + +run-contract-tests: + @curl -s https://raw.githubusercontent.com/launchdarkly/sdk-test-harness/v1.0.0/downloader/run.sh \ + | VERSION=v1 PARAMS="-url http://localhost:8000 -debug -stop-service-at-end $(TEST_HARNESS_PARAMS)" sh + +contract-tests: build-contract-tests start-contract-test-service-bg run-contract-tests + +.PHONY: build-contract-tests start-contract-test-service start-contract-test-service-bg run-contract-tests contract-tests diff --git a/build.gradle b/build.gradle index 0f33c52b9..afefab719 100644 --- a/build.gradle +++ b/build.gradle @@ -108,7 +108,6 @@ ext.versions = [ // Jackson in "libraries.optional" because we need to generate OSGi optional import // headers for it. libraries.internal = [ - "com.launchdarkly:launchdarkly-java-sdk-common:${versions.launchdarklyJavaSdkCommon}", "commons-codec:commons-codec:${versions.commonsCodec}", "com.google.code.gson:gson:${versions.gson}", "com.google.guava:guava:${versions.guava}", @@ -117,6 +116,10 @@ libraries.internal = [ "org.yaml:snakeyaml:${versions.snakeyaml}", ] +libraries.common = [ + "com.launchdarkly:launchdarkly-java-sdk-common:${versions.launchdarklyJavaSdkCommon}", +] + // 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. Putting dependencies // here has the following effects: @@ -176,7 +179,7 @@ configurations { dependencies { implementation libraries.internal - api libraries.external + api libraries.external, libraries.common testImplementation libraries.test, libraries.internal, libraries.external optional libraries.optional diff --git a/contract-tests/README.md b/contract-tests/README.md new file mode 100644 index 000000000..aa3942b8a --- /dev/null +++ b/contract-tests/README.md @@ -0,0 +1,7 @@ +# SDK contract test service + +This directory contains an implementation of the cross-platform SDK testing protocol defined by https://github.com/launchdarkly/sdk-test-harness. See that project's `README` for details of this protocol, and the kinds of SDK capabilities that are relevant to the contract tests. This code should not need to be updated unless the SDK has added or removed such capabilities. + +To run these tests locally, run `make contract-tests` from the SDK project root directory. This downloads the correct version of the test harness tool automatically. + +Or, to test against an in-progress local version of the test harness, run `make start-contract-test-service` from the SDK project root directory; then, in the root directory of the `sdk-test-harness` project, build the test harness and run it from the command line. diff --git a/contract-tests/gradle.properties b/contract-tests/gradle.properties new file mode 100644 index 000000000..ea8e8fb0c --- /dev/null +++ b/contract-tests/gradle.properties @@ -0,0 +1 @@ +gnsp.disableApplyOnlyOnRootProjectEnforcement=true diff --git a/contract-tests/service/build.gradle b/contract-tests/service/build.gradle new file mode 100644 index 000000000..8e62273e0 --- /dev/null +++ b/contract-tests/service/build.gradle @@ -0,0 +1,38 @@ + +plugins { + id "java" + id "application" +} + +repositories { + mavenCentral() + maven { url "https://oss.sonatype.org/content/groups/public/" } +} + +allprojects { + sourceCompatibility = 1.8 + targetCompatibility = 1.8 +} + +archivesBaseName = "java-sdk-test-service" + +application { + mainClassName = "sdktest.TestService" +} + +ext.versions = [ + "gson": "2.7", + "logback": "1.1.3", + "testHelpers": "1.1.0" +] + +configurations { + deps.extendsFrom(implementation) +} + +dependencies { + implementation project(":sdk") + implementation "ch.qos.logback:logback-classic:${versions.logback}" + implementation "com.google.code.gson:gson:${versions.gson}" + implementation "com.launchdarkly:test-helpers:${versions.testHelpers}" +} diff --git a/contract-tests/service/settings.gradle b/contract-tests/service/settings.gradle new file mode 100644 index 000000000..e69de29bb diff --git a/contract-tests/service/src/main/java/sdktest/Representations.java b/contract-tests/service/src/main/java/sdktest/Representations.java new file mode 100644 index 000000000..13de9568e --- /dev/null +++ b/contract-tests/service/src/main/java/sdktest/Representations.java @@ -0,0 +1,95 @@ +package sdktest; + +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; + +import java.net.URI; + +public abstract class Representations { + public static class Status { + String name; + String[] capabilities; + String clientVersion; + } + + public static class CreateInstanceParams { + SdkConfigParams configuration; + String tag; + } + + public static class SdkConfigParams { + String credential; + Long startWaitTimeMs; + boolean initCanFail; + SdkConfigStreamParams streaming; + SdkConfigEventParams events; + } + + public static class SdkConfigStreamParams { + URI baseUri; + long initialRetryDelayMs; + } + + public static class SdkConfigEventParams { + URI baseUri; + boolean allAttributesPrivate; + int capacity; + boolean enableDiagnostics; + String[] globalPrivateAttributes; + Long flushIntervalMs; + boolean inlineUsers; + } + + public static class CommandParams { + String command; + EvaluateFlagParams evaluate; + EvaluateAllFlagsParams evaluateAll; + IdentifyEventParams identifyEvent; + CustomEventParams customEvent; + AliasEventParams aliasEvent; + } + + public static class EvaluateFlagParams { + String flagKey; + LDUser user; + String valueType; + LDValue value; + LDValue defaultValue; + boolean detail; + } + + public static class EvaluateFlagResponse { + LDValue value; + Integer variationIndex; + EvaluationReason reason; + } + + public static class EvaluateAllFlagsParams { + LDUser user; + boolean clientSideOnly; + boolean detailsOnlyForTrackedFlags; + boolean withReasons; + } + + public static class EvaluateAllFlagsResponse { + LDValue state; + } + + public static class IdentifyEventParams { + LDUser user; + } + + public static class CustomEventParams { + String eventKey; + LDUser user; + LDValue data; + boolean omitNullData; + Double metricValue; + } + + public static class AliasEventParams { + LDUser user; + LDUser previousUser; + } +} diff --git a/contract-tests/service/src/main/java/sdktest/SdkClientEntity.java b/contract-tests/service/src/main/java/sdktest/SdkClientEntity.java new file mode 100644 index 000000000..946c57b24 --- /dev/null +++ b/contract-tests/service/src/main/java/sdktest/SdkClientEntity.java @@ -0,0 +1,211 @@ +package sdktest; + +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.json.JsonSerialization; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.FeatureFlagsState; +import com.launchdarkly.sdk.server.FlagsStateOption; +import com.launchdarkly.sdk.server.LDClient; +import com.launchdarkly.sdk.server.LDConfig; +import com.launchdarkly.sdk.server.integrations.EventProcessorBuilder; +import com.launchdarkly.sdk.server.integrations.StreamingDataSourceBuilder; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +import sdktest.Representations.AliasEventParams; +import sdktest.Representations.CommandParams; +import sdktest.Representations.CreateInstanceParams; +import sdktest.Representations.CustomEventParams; +import sdktest.Representations.EvaluateAllFlagsParams; +import sdktest.Representations.EvaluateAllFlagsResponse; +import sdktest.Representations.EvaluateFlagParams; +import sdktest.Representations.EvaluateFlagResponse; +import sdktest.Representations.IdentifyEventParams; +import sdktest.Representations.SdkConfigParams; + +public class SdkClientEntity { + private final LDClient client; + final Logger logger; + + public SdkClientEntity(TestService owner, CreateInstanceParams params) { + this.logger = LoggerFactory.getLogger(params.tag); + logger.info("Starting SDK client"); + + LDConfig config = buildSdkConfig(params.configuration); + this.client = new LDClient(params.configuration.credential, config); + if (!client.isInitialized() && !params.configuration.initCanFail) { + throw new RuntimeException("client initialization failed or timed out"); + } + } + + public Object doCommand(CommandParams params) throws TestService.BadRequestException { + logger.info("Test harness sent command: {}", TestService.gson.toJson(params)); + switch (params.command) { + case "evaluate": + return doEvaluateFlag(params.evaluate); + case "evaluateAll": + return doEvaluateAll(params.evaluateAll); + case "identifyEvent": + doIdentifyEvent(params.identifyEvent); + return null; + case "customEvent": + doCustomEvent(params.customEvent); + return null; + case "aliasEvent": + doAliasEvent(params.aliasEvent); + return null; + case "flushEvents": + client.flush(); + return null; + default: + throw new TestService.BadRequestException("unknown command: " + params.command); + } + } + + private EvaluateFlagResponse doEvaluateFlag(EvaluateFlagParams params) { + EvaluateFlagResponse resp = new EvaluateFlagResponse(); + if (params.detail) { + EvaluationDetail genericResult; + switch (params.valueType) { + case "bool": + EvaluationDetail boolResult = client.boolVariationDetail(params.flagKey, + params.user, params.defaultValue.booleanValue()); + resp.value = LDValue.of(boolResult.getValue()); + genericResult = boolResult; + break; + case "int": + EvaluationDetail intResult = client.intVariationDetail(params.flagKey, + params.user, params.defaultValue.intValue()); + resp.value = LDValue.of(intResult.getValue()); + genericResult = intResult; + break; + case "double": + EvaluationDetail doubleResult = client.doubleVariationDetail(params.flagKey, + params.user, params.defaultValue.doubleValue()); + resp.value = LDValue.of(doubleResult.getValue()); + genericResult = doubleResult; + break; + case "string": + EvaluationDetail stringResult = client.stringVariationDetail(params.flagKey, + params.user, params.defaultValue.stringValue()); + resp.value = LDValue.of(stringResult.getValue()); + genericResult = stringResult; + break; + default: + EvaluationDetail anyResult = client.jsonValueVariationDetail(params.flagKey, + params.user, params.defaultValue); + resp.value = anyResult.getValue(); + genericResult = anyResult; + break; + } + resp.variationIndex = genericResult.getVariationIndex() == EvaluationDetail.NO_VARIATION ? + null : Integer.valueOf(genericResult.getVariationIndex()); + resp.reason = genericResult.getReason(); + } else { + switch (params.valueType) { + case "bool": + resp.value = LDValue.of(client.boolVariation(params.flagKey, params.user, params.defaultValue.booleanValue())); + break; + case "int": + resp.value = LDValue.of(client.intVariation(params.flagKey, params.user, params.defaultValue.intValue())); + break; + case "double": + resp.value = LDValue.of(client.doubleVariation(params.flagKey, params.user, params.defaultValue.doubleValue())); + break; + case "string": + resp.value = LDValue.of(client.stringVariation(params.flagKey, params.user, params.defaultValue.stringValue())); + break; + default: + resp.value = client.jsonValueVariation(params.flagKey, params.user, params.defaultValue); + break; + } + } + return resp; + } + + private EvaluateAllFlagsResponse doEvaluateAll(EvaluateAllFlagsParams params) { + List options = new ArrayList<>(); + if (params.clientSideOnly) { + options.add(FlagsStateOption.CLIENT_SIDE_ONLY); + } + if (params.detailsOnlyForTrackedFlags) { + options.add(FlagsStateOption.DETAILS_ONLY_FOR_TRACKED_FLAGS); + } + if (params.withReasons) { + options.add(FlagsStateOption.WITH_REASONS); + } + FeatureFlagsState state = client.allFlagsState(params.user, options.toArray(new FlagsStateOption[0])); + EvaluateAllFlagsResponse resp = new EvaluateAllFlagsResponse(); + resp.state = LDValue.parse(JsonSerialization.serialize(state)); + return resp; + } + + private void doIdentifyEvent(IdentifyEventParams params) { + client.identify(params.user); + } + + private void doCustomEvent(CustomEventParams params) { + if ((params.data == null || params.data.isNull()) && params.omitNullData && params.metricValue == null) { + client.track(params.eventKey, params.user); + } else if (params.metricValue == null) { + client.trackData(params.eventKey, params.user, params.data); + } else { + client.trackMetric(params.eventKey, params.user, params.data, params.metricValue.doubleValue()); + } + } + + private void doAliasEvent(AliasEventParams params) { + client.alias(params.user, params.previousUser); + } + + public void close() { + try { + client.close(); + } catch (Exception e) { + logger.error("Unexpected error from LDClient.close(): {}", e); + } + logger.info("Test ended"); + } + + private LDConfig buildSdkConfig(SdkConfigParams params) { + LDConfig.Builder builder = new LDConfig.Builder(); + if (params.startWaitTimeMs != null) { + builder.startWait(Duration.ofMillis(params.startWaitTimeMs.longValue())); + } + if (params.streaming != null) { + StreamingDataSourceBuilder dataSource = Components.streamingDataSource() + .baseURI(params.streaming.baseUri); + if (params.streaming.initialRetryDelayMs > 0) { + dataSource.initialReconnectDelay(Duration.ofMillis(params.streaming.initialRetryDelayMs)); + } + builder.dataSource(dataSource); + } + if (params.events == null) + { + builder.events(Components.noEvents()); + } else { + EventProcessorBuilder eb = Components.sendEvents() + .baseURI(params.events.baseUri) + .allAttributesPrivate(params.events.allAttributesPrivate) + .inlineUsersInEvents(params.events.inlineUsers); + if (params.events.capacity > 0) { + eb.capacity(params.events.capacity); + } + if (params.events.flushIntervalMs != null) { + eb.flushInterval(Duration.ofMillis(params.events.flushIntervalMs.longValue())); + } + if (params.events.globalPrivateAttributes != null) { + eb.privateAttributeNames(params.events.globalPrivateAttributes); + } + builder.events(eb); + builder.diagnosticOptOut(!params.events.enableDiagnostics); + } + return builder.build(); + } +} diff --git a/contract-tests/service/src/main/java/sdktest/TestService.java b/contract-tests/service/src/main/java/sdktest/TestService.java new file mode 100644 index 000000000..b4ecbe8e2 --- /dev/null +++ b/contract-tests/service/src/main/java/sdktest/TestService.java @@ -0,0 +1,124 @@ +package sdktest; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.launchdarkly.testhelpers.httptest.Handlers; +import com.launchdarkly.testhelpers.httptest.HttpServer; +import com.launchdarkly.testhelpers.httptest.RequestContext; +import com.launchdarkly.testhelpers.httptest.SimpleRouter; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.regex.Pattern; + +import sdktest.Representations.CommandParams; +import sdktest.Representations.CreateInstanceParams; +import sdktest.Representations.Status; + +public class TestService { + private static final int PORT = 8000; + private static final String[] CAPABILITIES = new String[]{ + "server-side", + "strongly-typed", + "all-flags-client-side-only", + "all-flags-details-only-for-tracked-flags", + "all-flags-with-reasons" + }; + + static final Gson gson = new GsonBuilder().serializeNulls().create(); + + private final Map clients = new ConcurrentHashMap(); + private final AtomicInteger clientCounter = new AtomicInteger(0); + + public static class BadRequestException extends Exception { + public BadRequestException(String message) { + super(message); + } + } + + public static void main(String[] args) { + // ((ch.qos.logback.classic.Logger)LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME)).setLevel( + // Level.valueOf(config.logLevel.toUpperCase())); + + TestService service = new TestService(); + + SimpleRouter router = new SimpleRouter() + .add("GET", "/", ctx -> service.writeJson(ctx, service.getStatus())) + .add("DELETE", "/", ctx -> service.forceQuit()) + .add("POST", "/", ctx -> service.postCreateClient(ctx)) + .addRegex("POST", Pattern.compile("/clients/(.*)"), ctx -> service.postClientCommand(ctx)) + .addRegex("DELETE", Pattern.compile("/clients/(.*)"), ctx -> service.deleteClient(ctx)); + + HttpServer server = HttpServer.start(PORT, router); + server.getRecorder().setEnabled(false); // don't accumulate a request log + + System.out.println("Listening on port " + PORT); + } + + private Status getStatus() { + Status rep = new Status(); + rep.capabilities = CAPABILITIES; + return rep; + } + + private void forceQuit() { + System.out.println("Test harness has told us to quit"); + System.exit(0); + } + + private void postCreateClient(RequestContext ctx) { + CreateInstanceParams params = readJson(ctx, CreateInstanceParams.class); + + String clientId = String.valueOf(clientCounter.incrementAndGet()); + SdkClientEntity client = new SdkClientEntity(this, params); + + clients.put(clientId, client); + + ctx.addHeader("Location", "/clients/" + clientId); + } + + private void postClientCommand(RequestContext ctx) { + CommandParams params = readJson(ctx, CommandParams.class); + + String clientId = ctx.getPathParam(0); + SdkClientEntity client = clients.get(clientId); + if (client == null) { + ctx.setStatus(404); + } else { + try { + Object resp = client.doCommand(params); + ctx.setStatus(202); + if (resp != null) { + String json = gson.toJson(resp); + client.logger.info("Sending response: {}", json); + writeJson(ctx, resp); + } + } catch (BadRequestException e) { + ctx.setStatus(400); + } catch (Exception e) { + client.logger.error("Unexpected exception: {}", e); + ctx.setStatus(500); + } + } + } + + private void deleteClient(RequestContext ctx) { + String clientId = ctx.getPathParam(0); + SdkClientEntity client = clients.get(clientId); + if (client == null) { + ctx.setStatus(404); + } else { + client.close(); + } + } + + private T readJson(RequestContext ctx, Class paramsClass) { + return gson.fromJson(ctx.getRequest().getBody(), paramsClass); + } + + private void writeJson(RequestContext ctx, Object data) { + String json = gson.toJson(data); + Handlers.bodyJson(json).apply(ctx); + } +} diff --git a/contract-tests/service/src/main/resources/logback.xml b/contract-tests/service/src/main/resources/logback.xml new file mode 100644 index 000000000..3a604ca9e --- /dev/null +++ b/contract-tests/service/src/main/resources/logback.xml @@ -0,0 +1,20 @@ + + + + + + %d{yyyy-MM-dd HH:mm:ss} [%logger] %level: %msg%n + + + + + + + + + + + + + + diff --git a/contract-tests/settings.gradle b/contract-tests/settings.gradle new file mode 100644 index 000000000..5c2bf5d85 --- /dev/null +++ b/contract-tests/settings.gradle @@ -0,0 +1,3 @@ +include ":service" +include ":sdk" +project(":sdk").projectDir = new File("..") From 5673244e91a8a8c23253daa0f55c20c23767e3fa Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Fri, 28 Jan 2022 19:33:55 -0600 Subject: [PATCH 618/673] Merge Big Segments feature branch for 5.7.0 release (#316) Includes Big Segments implementation and contract test support for the new behavior. --- build.gradle | 2 +- contract-tests/service/build.gradle | 2 + .../java/sdktest/BigSegmentStoreFixture.java | 52 ++++ .../java/sdktest/CallbackRepresentations.java | 17 ++ .../main/java/sdktest/CallbackService.java | 57 ++++ .../main/java/sdktest/Representations.java | 14 + .../main/java/sdktest/SdkClientEntity.java | 34 ++- .../src/main/java/sdktest/TestService.java | 8 +- .../BigSegmentStoreStatusProviderImpl.java | 30 ++ .../sdk/server/BigSegmentStoreWrapper.java | 167 +++++++++++ .../launchdarkly/sdk/server/Components.java | 42 ++- .../launchdarkly/sdk/server/DataModel.java | 22 +- .../launchdarkly/sdk/server/Evaluator.java | 80 ++++-- .../sdk/server/EventBroadcasterImpl.java | 8 +- .../com/launchdarkly/sdk/server/LDClient.java | 41 ++- .../com/launchdarkly/sdk/server/LDConfig.java | 42 +++ .../com/launchdarkly/sdk/server/Loggers.java | 6 + .../BigSegmentsConfigurationBuilder.java | 170 ++++++++++++ .../server/interfaces/BigSegmentStore.java | 45 +++ .../interfaces/BigSegmentStoreFactory.java | 19 ++ .../BigSegmentStoreStatusProvider.java | 135 +++++++++ .../interfaces/BigSegmentStoreTypes.java | 225 +++++++++++++++ .../interfaces/BigSegmentsConfiguration.java | 93 +++++++ .../server/interfaces/LDClientInterface.java | 12 + ...BigSegmentStoreStatusProviderImplTest.java | 60 ++++ .../server/BigSegmentStoreWrapperTest.java | 260 ++++++++++++++++++ .../server/DataModelSerializationTest.java | 14 +- .../sdk/server/DataModelTest.java | 6 +- .../sdk/server/EvaluatorBigSegmentTest.java | 149 ++++++++++ .../server/EvaluatorPreprocessingTest.java | 2 +- .../sdk/server/EvaluatorTestUtil.java | 106 +++---- .../sdk/server/LDClientBigSegmentsTest.java | 114 ++++++++ .../sdk/server/LDClientListenersTest.java | 63 +++++ .../launchdarkly/sdk/server/LDConfigTest.java | 14 +- .../sdk/server/ModelBuilders.java | 18 +- .../com/launchdarkly/sdk/server/TestUtil.java | 76 +++++ .../integrations/BigSegmentStoreTestBase.java | 177 ++++++++++++ .../BigSegmentStoreTestBaseTest.java | 89 ++++++ .../BigSegmentsConfigurationBuilderTest.java | 87 ++++++ .../BigSegmentMembershipBuilderTest.java | 125 +++++++++ 40 files changed, 2577 insertions(+), 106 deletions(-) create mode 100644 contract-tests/service/src/main/java/sdktest/BigSegmentStoreFixture.java create mode 100644 contract-tests/service/src/main/java/sdktest/CallbackRepresentations.java create mode 100644 contract-tests/service/src/main/java/sdktest/CallbackService.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/BigSegmentStoreStatusProviderImpl.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/BigSegmentStoreWrapper.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/integrations/BigSegmentsConfigurationBuilder.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/interfaces/BigSegmentStore.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/interfaces/BigSegmentStoreFactory.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/interfaces/BigSegmentStoreStatusProvider.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/interfaces/BigSegmentStoreTypes.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/interfaces/BigSegmentsConfiguration.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/BigSegmentStoreStatusProviderImplTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/BigSegmentStoreWrapperTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/EvaluatorBigSegmentTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/LDClientBigSegmentsTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/integrations/BigSegmentStoreTestBase.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/integrations/BigSegmentStoreTestBaseTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/integrations/BigSegmentsConfigurationBuilderTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/interfaces/BigSegmentMembershipBuilderTest.java diff --git a/build.gradle b/build.gradle index afefab719..e57b2f1ff 100644 --- a/build.gradle +++ b/build.gradle @@ -72,7 +72,7 @@ ext.versions = [ "gson": "2.8.9", "guava": "30.1-jre", "jackson": "2.11.2", - "launchdarklyJavaSdkCommon": "1.2.2", + "launchdarklyJavaSdkCommon": "1.3.0", "okhttp": "4.8.1", // specify this for the SDK build instead of relying on the transitive dependency from okhttp-eventsource "okhttpEventsource": "2.3.2", "slf4j": "1.7.21", diff --git a/contract-tests/service/build.gradle b/contract-tests/service/build.gradle index 8e62273e0..4ae987dcf 100644 --- a/contract-tests/service/build.gradle +++ b/contract-tests/service/build.gradle @@ -23,6 +23,7 @@ application { ext.versions = [ "gson": "2.7", "logback": "1.1.3", + "okhttp": "4.5.0", "testHelpers": "1.1.0" ] @@ -34,5 +35,6 @@ dependencies { implementation project(":sdk") implementation "ch.qos.logback:logback-classic:${versions.logback}" implementation "com.google.code.gson:gson:${versions.gson}" + implementation "com.squareup.okhttp3:okhttp:${versions.okhttp}" implementation "com.launchdarkly:test-helpers:${versions.testHelpers}" } diff --git a/contract-tests/service/src/main/java/sdktest/BigSegmentStoreFixture.java b/contract-tests/service/src/main/java/sdktest/BigSegmentStoreFixture.java new file mode 100644 index 000000000..c542c4156 --- /dev/null +++ b/contract-tests/service/src/main/java/sdktest/BigSegmentStoreFixture.java @@ -0,0 +1,52 @@ +package sdktest; + +import com.launchdarkly.sdk.server.interfaces.BigSegmentStore; +import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreFactory; +import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreTypes.Membership; +import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreTypes.StoreMetadata; +import com.launchdarkly.sdk.server.interfaces.ClientContext; + +import java.io.IOException; + +import sdktest.CallbackRepresentations.BigSegmentStoreGetMembershipParams; +import sdktest.CallbackRepresentations.BigSegmentStoreGetMembershipResponse; +import sdktest.CallbackRepresentations.BigSegmentStoreGetMetadataResponse; + +public class BigSegmentStoreFixture implements BigSegmentStore, BigSegmentStoreFactory { + private final CallbackService service; + + public BigSegmentStoreFixture(CallbackService service) { + this.service = service; + } + + @Override + public void close() throws IOException { + service.close(); + } + + @Override + public Membership getMembership(String userHash) { + BigSegmentStoreGetMembershipParams params = new BigSegmentStoreGetMembershipParams(); + params.userHash = userHash; + BigSegmentStoreGetMembershipResponse resp = + service.post("/getMembership", params, BigSegmentStoreGetMembershipResponse.class); + return new Membership() { + @Override + public Boolean checkMembership(String segmentRef) { + return resp.values == null ? null : resp.values.get(segmentRef); + } + }; + } + + @Override + public StoreMetadata getMetadata() { + BigSegmentStoreGetMetadataResponse resp = + service.post("/getMetadata", null, BigSegmentStoreGetMetadataResponse.class); + return new StoreMetadata(resp.lastUpToDate); + } + + @Override + public BigSegmentStore createBigSegmentStore(ClientContext context) { + return this; + } +} diff --git a/contract-tests/service/src/main/java/sdktest/CallbackRepresentations.java b/contract-tests/service/src/main/java/sdktest/CallbackRepresentations.java new file mode 100644 index 000000000..aea243f54 --- /dev/null +++ b/contract-tests/service/src/main/java/sdktest/CallbackRepresentations.java @@ -0,0 +1,17 @@ +package sdktest; + +import java.util.Map; + +public abstract class CallbackRepresentations { + public static class BigSegmentStoreGetMetadataResponse { + Long lastUpToDate; + } + + public static class BigSegmentStoreGetMembershipParams { + String userHash; + } + + public static class BigSegmentStoreGetMembershipResponse { + Map values; + } +} diff --git a/contract-tests/service/src/main/java/sdktest/CallbackService.java b/contract-tests/service/src/main/java/sdktest/CallbackService.java new file mode 100644 index 000000000..df8b24765 --- /dev/null +++ b/contract-tests/service/src/main/java/sdktest/CallbackService.java @@ -0,0 +1,57 @@ +package sdktest; + +import java.net.URI; + +import okhttp3.MediaType; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; + +public class CallbackService { + private final URI baseUri; + + public CallbackService(URI baseUri) { + this.baseUri = baseUri; + } + + public void close() { + try { + Request request = new Request.Builder().url(baseUri.toURL()).method("DELETE", null).build(); + Response response = TestService.client.newCall(request).execute(); + assertOk(response, ""); + } catch (Exception e) { + throw new RuntimeException(e); // all errors are unexpected here + } + } + + public T post(String path, Object params, Class responseClass) { + try { + String uri = baseUri.toString() + path; + RequestBody body = RequestBody.create( + TestService.gson.toJson(params == null ? "{}" : params), + MediaType.parse("application/json")); + Request request = new Request.Builder().url(uri). + method("POST", body).build(); + Response response = TestService.client.newCall(request).execute(); + assertOk(response, path); + if (responseClass == null) { + return null; + } + return TestService.gson.fromJson(response.body().string(), responseClass); + } catch (Exception e) { + throw new RuntimeException(e); // all errors are unexpected here + } + } + + private void assertOk(Response response, String path) { + if (!response.isSuccessful()) { + String body = ""; + if (response.body() != null) { + try { + body = ": " + response.body().string(); + } catch (Exception e) {} + } + throw new RuntimeException("HTTP error " + response.code() + " from callback to " + baseUri + path + body); + } + } +} diff --git a/contract-tests/service/src/main/java/sdktest/Representations.java b/contract-tests/service/src/main/java/sdktest/Representations.java index 13de9568e..60cf2b2a4 100644 --- a/contract-tests/service/src/main/java/sdktest/Representations.java +++ b/contract-tests/service/src/main/java/sdktest/Representations.java @@ -24,6 +24,7 @@ public static class SdkConfigParams { boolean initCanFail; SdkConfigStreamParams streaming; SdkConfigEventParams events; + SdkConfigBigSegmentsParams bigSegments; } public static class SdkConfigStreamParams { @@ -41,6 +42,14 @@ public static class SdkConfigEventParams { boolean inlineUsers; } + public static class SdkConfigBigSegmentsParams { + URI callbackUri; + Integer userCacheSize; + Long userCacheTimeMs; + Long statusPollIntervalMs; + Long staleAfterMs; + } + public static class CommandParams { String command; EvaluateFlagParams evaluate; @@ -92,4 +101,9 @@ public static class AliasEventParams { LDUser user; LDUser previousUser; } + + public static class GetBigSegmentsStoreStatusResponse { + boolean available; + boolean stale; + } } diff --git a/contract-tests/service/src/main/java/sdktest/SdkClientEntity.java b/contract-tests/service/src/main/java/sdktest/SdkClientEntity.java index 946c57b24..a3f43fd88 100644 --- a/contract-tests/service/src/main/java/sdktest/SdkClientEntity.java +++ b/contract-tests/service/src/main/java/sdktest/SdkClientEntity.java @@ -8,8 +8,10 @@ import com.launchdarkly.sdk.server.FlagsStateOption; import com.launchdarkly.sdk.server.LDClient; import com.launchdarkly.sdk.server.LDConfig; +import com.launchdarkly.sdk.server.integrations.BigSegmentsConfigurationBuilder; import com.launchdarkly.sdk.server.integrations.EventProcessorBuilder; import com.launchdarkly.sdk.server.integrations.StreamingDataSourceBuilder; +import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreStatusProvider; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -26,6 +28,7 @@ import sdktest.Representations.EvaluateAllFlagsResponse; import sdktest.Representations.EvaluateFlagParams; import sdktest.Representations.EvaluateFlagResponse; +import sdktest.Representations.GetBigSegmentsStoreStatusResponse; import sdktest.Representations.IdentifyEventParams; import sdktest.Representations.SdkConfigParams; @@ -63,6 +66,12 @@ public Object doCommand(CommandParams params) throws TestService.BadRequestExcep case "flushEvents": client.flush(); return null; + case "getBigSegmentStoreStatus": + BigSegmentStoreStatusProvider.Status status = client.getBigSegmentStoreStatusProvider().getStatus(); + GetBigSegmentsStoreStatusResponse resp = new GetBigSegmentsStoreStatusResponse(); + resp.available = status.isAvailable(); + resp.stale = status.isStale(); + return resp; default: throw new TestService.BadRequestException("unknown command: " + params.command); } @@ -175,9 +184,11 @@ public void close() { private LDConfig buildSdkConfig(SdkConfigParams params) { LDConfig.Builder builder = new LDConfig.Builder(); + if (params.startWaitTimeMs != null) { builder.startWait(Duration.ofMillis(params.startWaitTimeMs.longValue())); } + if (params.streaming != null) { StreamingDataSourceBuilder dataSource = Components.streamingDataSource() .baseURI(params.streaming.baseUri); @@ -186,8 +197,8 @@ private LDConfig buildSdkConfig(SdkConfigParams params) { } builder.dataSource(dataSource); } - if (params.events == null) - { + + if (params.events == null) { builder.events(Components.noEvents()); } else { EventProcessorBuilder eb = Components.sendEvents() @@ -206,6 +217,25 @@ private LDConfig buildSdkConfig(SdkConfigParams params) { builder.events(eb); builder.diagnosticOptOut(!params.events.enableDiagnostics); } + + if (params.bigSegments != null) { + BigSegmentsConfigurationBuilder bsb = Components.bigSegments( + new BigSegmentStoreFixture(new CallbackService(params.bigSegments.callbackUri))); + if (params.bigSegments.staleAfterMs != null) { + bsb.staleAfter(Duration.ofMillis(params.bigSegments.staleAfterMs)); + } + if (params.bigSegments.statusPollIntervalMs != null) { + bsb.statusPollInterval(Duration.ofMillis(params.bigSegments.statusPollIntervalMs)); + } + if (params.bigSegments.userCacheSize != null) { + bsb.userCacheSize(params.bigSegments.userCacheSize); + } + if (params.bigSegments.userCacheTimeMs != null) { + bsb.userCacheTime(Duration.ofMillis(params.bigSegments.userCacheTimeMs)); + } + builder.bigSegments(bsb); + } + return builder.build(); } } diff --git a/contract-tests/service/src/main/java/sdktest/TestService.java b/contract-tests/service/src/main/java/sdktest/TestService.java index b4ecbe8e2..af6647879 100644 --- a/contract-tests/service/src/main/java/sdktest/TestService.java +++ b/contract-tests/service/src/main/java/sdktest/TestService.java @@ -12,6 +12,8 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.regex.Pattern; +import okhttp3.OkHttpClient; + import sdktest.Representations.CommandParams; import sdktest.Representations.CreateInstanceParams; import sdktest.Representations.Status; @@ -23,14 +25,18 @@ public class TestService { "strongly-typed", "all-flags-client-side-only", "all-flags-details-only-for-tracked-flags", - "all-flags-with-reasons" + "all-flags-with-reasons", + "big-segments" }; static final Gson gson = new GsonBuilder().serializeNulls().create(); + static final OkHttpClient client = new OkHttpClient(); + private final Map clients = new ConcurrentHashMap(); private final AtomicInteger clientCounter = new AtomicInteger(0); + @SuppressWarnings("serial") public static class BadRequestException extends Exception { public BadRequestException(String message) { super(message); diff --git a/src/main/java/com/launchdarkly/sdk/server/BigSegmentStoreStatusProviderImpl.java b/src/main/java/com/launchdarkly/sdk/server/BigSegmentStoreStatusProviderImpl.java new file mode 100644 index 000000000..11c3a0fc5 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/BigSegmentStoreStatusProviderImpl.java @@ -0,0 +1,30 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreStatusProvider; + +final class BigSegmentStoreStatusProviderImpl implements BigSegmentStoreStatusProvider { + private final EventBroadcasterImpl statusNotifier; + private final BigSegmentStoreWrapper storeWrapper; + + BigSegmentStoreStatusProviderImpl( + EventBroadcasterImpl bigSegmentStatusNotifier, + BigSegmentStoreWrapper storeWrapper) { + this.storeWrapper = storeWrapper; + this.statusNotifier = bigSegmentStatusNotifier; + } + + @Override + public Status getStatus() { + return storeWrapper == null ? new Status(false, false) : storeWrapper.getStatus(); + } + + @Override + public void addStatusListener(StatusListener listener) { + statusNotifier.register(listener); + } + + @Override + public void removeStatusListener(StatusListener listener) { + statusNotifier.unregister(listener); + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/BigSegmentStoreWrapper.java b/src/main/java/com/launchdarkly/sdk/server/BigSegmentStoreWrapper.java new file mode 100644 index 000000000..bfd01ddac --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/BigSegmentStoreWrapper.java @@ -0,0 +1,167 @@ +package com.launchdarkly.sdk.server; + +import static com.launchdarkly.sdk.server.interfaces.BigSegmentStoreTypes.createMembershipFromSegmentRefs; + +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import com.launchdarkly.sdk.EvaluationReason.BigSegmentsStatus; +import com.launchdarkly.sdk.server.interfaces.BigSegmentStore; +import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreStatusProvider.Status; +import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreStatusProvider.StatusListener; +import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreTypes.Membership; +import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreTypes.StoreMetadata; +import com.launchdarkly.sdk.server.interfaces.BigSegmentsConfiguration; + +import org.apache.commons.codec.digest.DigestUtils; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.slf4j.Logger; + +import java.io.Closeable; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Base64; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +class BigSegmentStoreWrapper implements Closeable { + private static final Logger logger = Loggers.BIG_SEGMENTS; + + private final BigSegmentStore store; + private final Duration staleAfter; + private final ScheduledFuture pollFuture; + private final LoadingCache cache; + private final EventBroadcasterImpl statusProvider; + + private final Object statusLock = new Object(); + private Status lastStatus; + + BigSegmentStoreWrapper(BigSegmentsConfiguration config, + EventBroadcasterImpl statusProvider, + ScheduledExecutorService sharedExecutor) { + this.store = config.getStore(); + this.staleAfter = config.getStaleAfter(); + this.statusProvider = statusProvider; + + CacheLoader loader = new CacheLoader() { + @Override + public Membership load(@NonNull String key) { + Membership membership = queryMembership(key); + return membership == null ? createMembershipFromSegmentRefs(null, null) : membership; + } + }; + this.cache = CacheBuilder.newBuilder() + .maximumSize(config.getUserCacheSize()) + .expireAfterWrite(config.getUserCacheTime()) + .build(loader); + + this.pollFuture = sharedExecutor.scheduleAtFixedRate(this::pollStoreAndUpdateStatus, + 0, + config.getStatusPollInterval().toMillis(), + TimeUnit.MILLISECONDS); + } + + @Override + public void close() throws IOException { + pollFuture.cancel(true); + cache.invalidateAll(); + store.close(); + } + + /** + * Called by the evaluator when it needs to get the Big Segment membership state for a user. + *

      + * If there is a cached membership state for the user, it returns the cached state. Otherwise, + * it converts the user key into the hash string used by the BigSegmentStore, queries the store, + * and caches the result. The returned status value indicates whether the query succeeded, and + * whether the result (regardless of whether it was from a new query or the cache) should be + * considered "stale". + * + * @param userKey the (unhashed) user key + * @return the query result + */ + BigSegmentsQueryResult getUserMembership(String userKey) { + BigSegmentsQueryResult ret = new BigSegmentsQueryResult(); + try { + ret.membership = cache.get(userKey); + ret.status = getStatus().isStale() ? BigSegmentsStatus.STALE : BigSegmentsStatus.HEALTHY; + } catch (Exception e) { + logger.error("Big Segment store returned error: {}", e.toString()); + logger.debug(e.toString(), e); + ret.membership = null; + ret.status = BigSegmentsStatus.STORE_ERROR; + } + return ret; + } + + private Membership queryMembership(String userKey) { + String hash = hashForUserKey(userKey); + logger.debug("Querying Big Segment state for user hash {}", hash); + return store.getMembership(hash); + } + + /** + * Returns a BigSegmentStoreStatus describing whether the store seems to be available (that is, + * the last query to it did not return an error) and whether it is stale (that is, the last known + * update time is too far in the past). + *

      + * If we have not yet obtained that information (the poll task has not executed yet), then this + * method immediately does a metadata query and waits for it to succeed or fail. This means that + * if an application using Big Segments evaluates a feature flag immediately after creating the + * SDK client, before the first status poll has happened, that evaluation may block for however + * long it takes to query the store. + * + * @return the store status + */ + Status getStatus() { + Status ret; + synchronized (statusLock) { + ret = lastStatus; + } + if (ret != null) { + return ret; + } + return pollStoreAndUpdateStatus(); + } + + Status pollStoreAndUpdateStatus() { + boolean storeAvailable = false; + boolean storeStale = false; + logger.debug("Querying Big Segment store metadata"); + try { + StoreMetadata metadata = store.getMetadata(); + storeAvailable = true; + storeStale = metadata == null || isStale(metadata.getLastUpToDate()); + } catch (Exception e) { + logger.error("Big Segment store status query returned error: {}", e.toString()); + logger.debug(e.toString(), e); + } + Status newStatus = new Status(storeAvailable, storeStale); + Status oldStatus; + synchronized (this.statusLock) { + oldStatus = this.lastStatus; + this.lastStatus = newStatus; + } + if (!newStatus.equals(oldStatus)) { + logger.debug("Big Segment store status changed from {} to {}", oldStatus, newStatus); + statusProvider.broadcast(newStatus); + } + return newStatus; + } + + private boolean isStale(long updateTime) { + return staleAfter.minusMillis(System.currentTimeMillis() - updateTime).isNegative(); + } + + static String hashForUserKey(String userKey) { + byte[] encodedDigest = DigestUtils.sha256(userKey.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(encodedDigest); + } + + static class BigSegmentsQueryResult { + Membership membership; + BigSegmentsStatus status; + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/Components.java b/src/main/java/com/launchdarkly/sdk/server/Components.java index 9b15bdfa3..bede125f5 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Components.java +++ b/src/main/java/com/launchdarkly/sdk/server/Components.java @@ -1,5 +1,7 @@ package com.launchdarkly.sdk.server; +import static com.launchdarkly.sdk.server.ComponentsImpl.NULL_EVENT_PROCESSOR_FACTORY; + import com.launchdarkly.sdk.server.ComponentsImpl.EventProcessorBuilderImpl; import com.launchdarkly.sdk.server.ComponentsImpl.HttpBasicAuthentication; import com.launchdarkly.sdk.server.ComponentsImpl.HttpConfigurationBuilderImpl; @@ -9,20 +11,20 @@ import com.launchdarkly.sdk.server.ComponentsImpl.PersistentDataStoreBuilderImpl; import com.launchdarkly.sdk.server.ComponentsImpl.PollingDataSourceBuilderImpl; import com.launchdarkly.sdk.server.ComponentsImpl.StreamingDataSourceBuilderImpl; +import com.launchdarkly.sdk.server.integrations.BigSegmentsConfigurationBuilder; import com.launchdarkly.sdk.server.integrations.EventProcessorBuilder; import com.launchdarkly.sdk.server.integrations.HttpConfigurationBuilder; import com.launchdarkly.sdk.server.integrations.LoggingConfigurationBuilder; import com.launchdarkly.sdk.server.integrations.PersistentDataStoreBuilder; import com.launchdarkly.sdk.server.integrations.PollingDataSourceBuilder; import com.launchdarkly.sdk.server.integrations.StreamingDataSourceBuilder; +import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreFactory; import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; import com.launchdarkly.sdk.server.interfaces.HttpAuthentication; import com.launchdarkly.sdk.server.interfaces.PersistentDataStoreFactory; -import static com.launchdarkly.sdk.server.ComponentsImpl.NULL_EVENT_PROCESSOR_FACTORY; - /** * Provides configurable factories for the standard implementations of LaunchDarkly component interfaces. *

      @@ -38,7 +40,41 @@ */ public abstract class Components { private Components() {} - + + /** + * Returns a configuration builder for the SDK's Big Segments feature. + *

      + * Big Segments are a specific type of user segments. For more information, read the + * LaunchDarkly documentation. + *

      + * After configuring this object, use + * {@link LDConfig.Builder#bigSegments(BigSegmentsConfigurationBuilder)} to store it in your SDK + * configuration. For example, using the Redis integration: + * + *

      
      +   *     LDConfig config = new LDConfig.Builder()
      +   *         .bigSegments(Components.bigSegments(Redis.dataStore().prefix("app1"))
      +   *             .userCacheSize(2000))
      +   *         .build();
      +   * 
      + * + *

      + * You must always specify the {@code storeFactory} parameter, to tell the SDK what database you + * are using. 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 BigSegmentStoreFactory}. The {@link BigSegmentsConfigurationBuilder} adds configuration + * options for aspects of SDK behavior that are independent of the database. In the example above, + * {@code prefix} is an option specifically for the Redis integration, whereas + * {@code userCacheSize} is an option that can be used for any data store type. + * + * @param storeFactory the factory for the underlying data store + * @return a {@link BigSegmentsConfigurationBuilder} + * @since 5.7.0 + */ + public static BigSegmentsConfigurationBuilder bigSegments(BigSegmentStoreFactory storeFactory) { + return new BigSegmentsConfigurationBuilder(storeFactory); + } + /** * Returns a configuration object for using the default in-memory implementation of a data store. *

      diff --git a/src/main/java/com/launchdarkly/sdk/server/DataModel.java b/src/main/java/com/launchdarkly/sdk/server/DataModel.java index 2b8ac0636..37ac88987 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DataModel.java +++ b/src/main/java/com/launchdarkly/sdk/server/DataModel.java @@ -444,10 +444,20 @@ static final class Segment implements VersionedData, JsonHelpers.PostProcessingD private List rules; private int version; private boolean deleted; + private boolean unbounded; + private Integer generation; Segment() {} - Segment(String key, Set included, Set excluded, String salt, List rules, int version, boolean deleted) { + Segment(String key, + Set included, + Set excluded, + String salt, + List rules, + int version, + boolean deleted, + boolean unbounded, + Integer generation) { this.key = key; this.included = included; this.excluded = excluded; @@ -455,6 +465,8 @@ static final class Segment implements VersionedData, JsonHelpers.PostProcessingD this.rules = rules; this.version = version; this.deleted = deleted; + this.unbounded = unbounded; + this.generation = generation; } public String getKey() { @@ -488,6 +500,14 @@ public boolean isDeleted() { return deleted; } + public boolean isUnbounded() { + return unbounded; + } + + public Integer getGeneration() { + return generation; + } + // Precompute some invariant values for improved efficiency during evaluations - called from JsonHelpers.PostProcessingDeserializableTypeAdapter public void afterDeserialized() { EvaluatorPreprocessing.preprocessSegment(this); diff --git a/src/main/java/com/launchdarkly/sdk/server/Evaluator.java b/src/main/java/com/launchdarkly/sdk/server/Evaluator.java index 8128f8c88..d66818f0c 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Evaluator.java +++ b/src/main/java/com/launchdarkly/sdk/server/Evaluator.java @@ -8,6 +8,7 @@ import com.launchdarkly.sdk.LDValueType; import com.launchdarkly.sdk.EvaluationReason.Kind; import com.launchdarkly.sdk.server.DataModel.WeightedVariation; +import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreTypes; import com.launchdarkly.sdk.server.interfaces.Event; import org.slf4j.Logger; @@ -45,6 +46,7 @@ class Evaluator { static interface Getters { DataModel.FeatureFlag getFlag(String key); DataModel.Segment getSegment(String key); + BigSegmentStoreWrapper.BigSegmentsQueryResult getBigSegments(String key); } /** @@ -107,6 +109,15 @@ Iterable getPrerequisiteEvents() { private void setPrerequisiteEvents(List prerequisiteEvents) { this.prerequisiteEvents = prerequisiteEvents; } + + private void setBigSegmentsStatus(EvaluationReason.BigSegmentsStatus bigSegmentsStatus) { + this.reason = this.reason.withBigSegmentsStatus(bigSegmentsStatus); + } + } + + static class BigSegmentsState { + private BigSegmentStoreTypes.Membership bigSegmentsMembership; + private EvaluationReason.BigSegmentsStatus bigSegmentsStatus; } Evaluator(Getters getters) { @@ -132,24 +143,28 @@ EvalResult evaluate(DataModel.FeatureFlag flag, LDUser user, EventFactory eventF return new EvalResult(null, NO_VARIATION, EvaluationReason.error(EvaluationReason.ErrorKind.USER_NOT_SPECIFIED)); } + BigSegmentsState bigSegmentsState = new BigSegmentsState(); // If the flag doesn't have any prerequisites (which most flags don't) then it cannot generate any feature // request events for prerequisites and we can skip allocating a List. List prerequisiteEvents = flag.getPrerequisites().isEmpty() ? null : new ArrayList(); // note, getPrerequisites() is guaranteed non-null - EvalResult result = evaluateInternal(flag, user, eventFactory, prerequisiteEvents); + EvalResult result = evaluateInternal(flag, user, eventFactory, prerequisiteEvents, bigSegmentsState); if (prerequisiteEvents != null) { result.setPrerequisiteEvents(prerequisiteEvents); } + if (bigSegmentsState.bigSegmentsStatus != null) { + result.setBigSegmentsStatus(bigSegmentsState.bigSegmentsStatus); + } return result; } private EvalResult evaluateInternal(DataModel.FeatureFlag flag, LDUser user, EventFactory eventFactory, - List eventsOut) { + List eventsOut, BigSegmentsState bigSegmentsState) { if (!flag.isOn()) { return getOffValue(flag, EvaluationReason.off()); } - EvaluationReason prereqFailureReason = checkPrerequisites(flag, user, eventFactory, eventsOut); + EvaluationReason prereqFailureReason = checkPrerequisites(flag, user, eventFactory, eventsOut, bigSegmentsState); if (prereqFailureReason != null) { return getOffValue(flag, prereqFailureReason); } @@ -164,7 +179,7 @@ private EvalResult evaluateInternal(DataModel.FeatureFlag flag, LDUser user, Eve List rules = flag.getRules(); // guaranteed non-null for (int i = 0; i < rules.size(); i++) { DataModel.Rule rule = rules.get(i); - if (ruleMatchesUser(flag, rule, user)) { + if (ruleMatchesUser(flag, rule, user, bigSegmentsState)) { EvaluationReason precomputedReason = rule.getRuleMatchReason(); EvaluationReason reason = precomputedReason != null ? precomputedReason : EvaluationReason.ruleMatch(i, rule.getId()); return getValueForVariationOrRollout(flag, rule, user, reason); @@ -177,7 +192,7 @@ private EvalResult evaluateInternal(DataModel.FeatureFlag flag, LDUser user, Eve // 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(DataModel.FeatureFlag flag, LDUser user, EventFactory eventFactory, - List eventsOut) { + List eventsOut, BigSegmentsState bigSegmentsState) { for (DataModel.Prerequisite prereq: flag.getPrerequisites()) { // getPrerequisites() is guaranteed non-null boolean prereqOk = true; DataModel.FeatureFlag prereqFeatureFlag = getters.getFlag(prereq.getKey()); @@ -185,7 +200,7 @@ private EvaluationReason checkPrerequisites(DataModel.FeatureFlag flag, LDUser u logger.error("Could not retrieve prerequisite flag \"{}\" when evaluating \"{}\"", prereq.getKey(), flag.getKey()); prereqOk = false; } else { - EvalResult prereqEvalResult = evaluateInternal(prereqFeatureFlag, user, eventFactory, eventsOut); + EvalResult prereqEvalResult = evaluateInternal(prereqFeatureFlag, user, eventFactory, eventsOut, bigSegmentsState); // 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.getVariationIndex() != prereq.getVariation()) { @@ -272,16 +287,16 @@ private EvaluationReason experimentize(EvaluationReason reason) { return reason; } - private boolean ruleMatchesUser(DataModel.FeatureFlag flag, DataModel.Rule rule, LDUser user) { + private boolean ruleMatchesUser(DataModel.FeatureFlag flag, DataModel.Rule rule, LDUser user, BigSegmentsState bigSegmentsState) { for (DataModel.Clause clause: rule.getClauses()) { // getClauses() is guaranteed non-null - if (!clauseMatchesUser(clause, user)) { + if (!clauseMatchesUser(clause, user, bigSegmentsState)) { return false; } } return true; } - private boolean clauseMatchesUser(DataModel.Clause clause, LDUser user) { + private boolean clauseMatchesUser(DataModel.Clause clause, LDUser user, BigSegmentsState bigSegmentsState) { // In the case of a segment match operator, we check if the user is in any of the segments, // and possibly negate if (clause.getOp() == DataModel.Operator.segmentMatch) { @@ -289,7 +304,7 @@ private boolean clauseMatchesUser(DataModel.Clause clause, LDUser user) { if (j.isString()) { DataModel.Segment segment = getters.getSegment(j.stringValue()); if (segment != null) { - if (segmentMatchesUser(segment, user)) { + if (segmentMatchesUser(segment, user, bigSegmentsState)) { return maybeNegate(clause, true); } } @@ -357,13 +372,42 @@ private boolean maybeNegate(DataModel.Clause clause, boolean b) { return clause.isNegate() ? !b : b; } - private boolean segmentMatchesUser(DataModel.Segment segment, LDUser user) { + private boolean segmentMatchesUser(DataModel.Segment segment, LDUser user, BigSegmentsState bigSegmentsState) { String userKey = user.getKey(); // we've already verified that the key is non-null at the top of evaluate() - if (segment.getIncluded().contains(userKey)) { // getIncluded(), getExcluded(), and getRules() are guaranteed non-null - return true; - } - if (segment.getExcluded().contains(userKey)) { - return false; + if (segment.isUnbounded()) { + if (segment.getGeneration() == null) { + // Big Segment queries can only be done if the generation is known. If it's unset, that + // probably means the data store was populated by an older SDK that doesn't know about the + // generation property and therefore dropped it from the JSON data. We'll treat that as a + // "not configured" condition. + bigSegmentsState.bigSegmentsStatus = EvaluationReason.BigSegmentsStatus.NOT_CONFIGURED; + return false; + } + + // Even if multiple Big Segments are referenced within a single flag evaluation, we only need + // to do this query once, since it returns *all* of the user's segment memberships. + if (bigSegmentsState.bigSegmentsStatus == null) { + BigSegmentStoreWrapper.BigSegmentsQueryResult queryResult = getters.getBigSegments(user.getKey()); + if (queryResult == null) { + // The SDK hasn't been configured to be able to use big segments + bigSegmentsState.bigSegmentsStatus = EvaluationReason.BigSegmentsStatus.NOT_CONFIGURED; + } else { + bigSegmentsState.bigSegmentsStatus = queryResult.status; + bigSegmentsState.bigSegmentsMembership = queryResult.membership; + } + } + Boolean membership = bigSegmentsState.bigSegmentsMembership == null ? + null : bigSegmentsState.bigSegmentsMembership.checkMembership(makeBigSegmentRef(segment)); + if (membership != null) { + return membership; + } + } else { + if (segment.getIncluded().contains(userKey)) { // getIncluded(), getExcluded(), and getRules() are guaranteed non-null + return true; + } + if (segment.getExcluded().contains(userKey)) { + return false; + } } for (DataModel.SegmentRule rule: segment.getRules()) { if (segmentRuleMatchesUser(rule, user, segment.getKey(), segment.getSalt())) { @@ -390,4 +434,8 @@ private boolean segmentRuleMatchesUser(DataModel.SegmentRule segmentRule, LDUser double weight = (double)segmentRule.getWeight() / 100000.0; return bucket < weight; } + + static String makeBigSegmentRef(DataModel.Segment segment) { + return String.format("%s.g%d", segment.getKey(), segment.getGeneration()); + } } diff --git a/src/main/java/com/launchdarkly/sdk/server/EventBroadcasterImpl.java b/src/main/java/com/launchdarkly/sdk/server/EventBroadcasterImpl.java index b417dc180..786afa5c0 100644 --- a/src/main/java/com/launchdarkly/sdk/server/EventBroadcasterImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/EventBroadcasterImpl.java @@ -1,5 +1,6 @@ package com.launchdarkly.sdk.server; +import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; @@ -18,7 +19,7 @@ * @param the listener interface class * @param the event class */ -final class EventBroadcasterImpl { +class EventBroadcasterImpl { private final CopyOnWriteArrayList listeners = new CopyOnWriteArrayList<>(); private final BiConsumer broadcastAction; private final ExecutorService executor; @@ -49,6 +50,11 @@ static EventBroadcasterImpl forFlagChangeEv return new EventBroadcasterImpl<>(DataStoreStatusProvider.StatusListener::dataStoreStatusChanged, executor); } + static EventBroadcasterImpl + forBigSegmentStoreStatus(ExecutorService executor) { + return new EventBroadcasterImpl<>(BigSegmentStoreStatusProvider.StatusListener::bigSegmentStoreStatusChanged, executor); + } + /** * Registers a listener for this type of event. This method is thread-safe. * diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClient.java b/src/main/java/com/launchdarkly/sdk/server/LDClient.java index f39e041cb..3bafc888f 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClient.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClient.java @@ -1,11 +1,19 @@ package com.launchdarkly.sdk.server; +import static com.google.common.base.Preconditions.checkNotNull; +import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; +import static com.launchdarkly.sdk.server.Util.isAsciiHeaderValue; + import com.google.common.util.concurrent.ThreadFactoryBuilder; import com.launchdarkly.sdk.EvaluationDetail; import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.server.integrations.EventProcessorBuilder; +import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreStatusProvider; +import com.launchdarkly.sdk.server.interfaces.BigSegmentsConfiguration; import com.launchdarkly.sdk.server.interfaces.DataSource; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; @@ -37,12 +45,6 @@ import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; -import static com.google.common.base.Preconditions.checkNotNull; -import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; -import static com.launchdarkly.sdk.server.DataModel.FEATURES; -import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; -import static com.launchdarkly.sdk.server.Util.isAsciiHeaderValue; - /** * A client for the LaunchDarkly API. Client instances are thread-safe. Applications should instantiate * a single {@code LDClient} for the lifetime of their application. @@ -56,6 +58,8 @@ public final class LDClient implements LDClientInterface { final EventProcessor eventProcessor; final DataSource dataSource; final DataStore dataStore; + private final BigSegmentStoreStatusProvider bigSegmentStoreStatusProvider; + private final BigSegmentStoreWrapper bigSegmentStoreWrapper; private final DataSourceUpdates dataSourceUpdates; private final DataStoreStatusProviderImpl dataStoreStatusProvider; private final DataSourceStatusProviderImpl dataSourceStatusProvider; @@ -195,6 +199,16 @@ public LDClient(String sdkKey, LDConfig config) { this.eventProcessor = config.eventProcessorFactory.createEventProcessor(context); + EventBroadcasterImpl bigSegmentStoreStatusNotifier = + EventBroadcasterImpl.forBigSegmentStoreStatus(sharedExecutor); + BigSegmentsConfiguration bigSegmentsConfig = config.bigSegmentsConfigBuilder.createBigSegmentsConfiguration(context); + if (bigSegmentsConfig.getStore() != null) { + bigSegmentStoreWrapper = new BigSegmentStoreWrapper(bigSegmentsConfig, bigSegmentStoreStatusNotifier, sharedExecutor); + } else { + bigSegmentStoreWrapper = null; + } + bigSegmentStoreStatusProvider = new BigSegmentStoreStatusProviderImpl(bigSegmentStoreStatusNotifier, bigSegmentStoreWrapper); + EventBroadcasterImpl dataStoreStatusNotifier = EventBroadcasterImpl.forDataStoreStatus(sharedExecutor); DataStoreUpdatesImpl dataStoreUpdates = new DataStoreUpdatesImpl(dataStoreStatusNotifier); @@ -208,6 +222,11 @@ public DataModel.FeatureFlag getFlag(String key) { public DataModel.Segment getSegment(String key) { return LDClient.getSegment(LDClient.this.dataStore, key); } + + public BigSegmentStoreWrapper.BigSegmentsQueryResult getBigSegments(String key) { + BigSegmentStoreWrapper wrapper = LDClient.this.bigSegmentStoreWrapper; + return wrapper == null ? null : wrapper.getUserMembership(key); + } }); this.flagChangeBroadcaster = EventBroadcasterImpl.forFlagChangeEvents(sharedExecutor); @@ -504,7 +523,12 @@ private Evaluator.EvalResult evaluateInternal(String featureKey, LDUser user, LD public FlagTracker getFlagTracker() { return flagTracker; } - + + @Override + public BigSegmentStoreStatusProvider getBigSegmentStoreStatusProvider() { + return bigSegmentStoreStatusProvider; + } + @Override public DataStoreStatusProvider getDataStoreStatusProvider() { return dataStoreStatusProvider; @@ -522,6 +546,9 @@ public void close() throws IOException { this.eventProcessor.close(); this.dataSource.close(); this.dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.OFF, null); + if (this.bigSegmentStoreWrapper != null) { + this.bigSegmentStoreWrapper.close(); + } this.sharedExecutor.shutdownNow(); } diff --git a/src/main/java/com/launchdarkly/sdk/server/LDConfig.java b/src/main/java/com/launchdarkly/sdk/server/LDConfig.java index 0c6a7e09d..2ae272aa1 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDConfig.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDConfig.java @@ -1,5 +1,8 @@ package com.launchdarkly.sdk.server; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.server.integrations.BigSegmentsConfigurationBuilder; +import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreFactory; import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; import com.launchdarkly.sdk.server.interfaces.EventProcessor; @@ -22,6 +25,7 @@ public final class LDConfig { protected static final LDConfig DEFAULT = new Builder().build(); + final BigSegmentsConfigurationBuilder bigSegmentsConfigBuilder; final DataSourceFactory dataSourceFactory; final DataStoreFactory dataStoreFactory; final boolean diagnosticOptOut; @@ -42,6 +46,8 @@ protected LDConfig(Builder builder) { this.eventProcessorFactory = builder.eventProcessorFactory == null ? Components.sendEvents() : builder.eventProcessorFactory; } + this.bigSegmentsConfigBuilder = builder.bigSegmentsConfigBuilder == null ? + Components.bigSegments(null) : builder.bigSegmentsConfigBuilder; this.dataStoreFactory = builder.dataStoreFactory == null ? Components.inMemoryDataStore() : builder.dataStoreFactory; this.diagnosticOptOut = builder.diagnosticOptOut; @@ -66,6 +72,7 @@ protected LDConfig(Builder builder) { * */ public static class Builder { + private BigSegmentsConfigurationBuilder bigSegmentsConfigBuilder = null; private DataSourceFactory dataSourceFactory = null; private DataStoreFactory dataStoreFactory = null; private boolean diagnosticOptOut = false; @@ -81,6 +88,41 @@ public static class Builder { */ public Builder() { } + + /** + * Sets the configuration of the SDK's Big Segments feature. + *

      + * Big Segments are a specific type of user segments. For more information, read the + * LaunchDarkly documentation + * . + *

      + * If you are using this feature, you will normally specify a database implementation that + * matches how the LaunchDarkly Relay Proxy is configured, since the Relay Proxy manages the + * Big Segment data. + *

      + * By default, there is no implementation and Big Segments cannot be evaluated. In this case, + * any flag evaluation that references a Big Segment will behave as if no users are included in + * any Big Segments, and the {@link EvaluationReason} associated with any such flag evaluation + * will have a {@link EvaluationReason.BigSegmentsStatus} of + * {@link EvaluationReason.BigSegmentsStatus#NOT_CONFIGURED}. + * + *

      
      +     *     // This example uses the Redis integration
      +     *     LDConfig config = LDConfig.builder()
      +     *         .bigSegments(Components.bigSegments(Redis.dataStore().prefix("app1"))
      +     *             .userCacheSize(2000))
      +     *         .build();
      +     * 
      + * + * @param bigSegmentsConfigBuilder a configuration builder object returned by + * {@link Components#bigSegments(BigSegmentStoreFactory)}. + * @return the builder + * @since 5.7.0 + */ + public Builder bigSegments(BigSegmentsConfigurationBuilder bigSegmentsConfigBuilder) { + this.bigSegmentsConfigBuilder = bigSegmentsConfigBuilder; + return this; + } /** * Sets the implementation of the component that receives feature flag data from LaunchDarkly, diff --git a/src/main/java/com/launchdarkly/sdk/server/Loggers.java b/src/main/java/com/launchdarkly/sdk/server/Loggers.java index 3571a6366..abcd9bcb3 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Loggers.java +++ b/src/main/java/com/launchdarkly/sdk/server/Loggers.java @@ -21,6 +21,7 @@ abstract class Loggers { private Loggers() {} static final String BASE_LOGGER_NAME = LDClient.class.getName(); + static final String BIG_SEGMENTS_LOGGER_NAME = BASE_LOGGER_NAME + ".BigSegments"; static final String DATA_SOURCE_LOGGER_NAME = BASE_LOGGER_NAME + ".DataSource"; static final String DATA_STORE_LOGGER_NAME = BASE_LOGGER_NAME + ".DataStore"; static final String EVALUATION_LOGGER_NAME = BASE_LOGGER_NAME + ".Evaluation"; @@ -31,6 +32,11 @@ private Loggers() {} */ static final Logger MAIN = LoggerFactory.getLogger(BASE_LOGGER_NAME); + /** + * The logger instance to use for messages related to the Big Segments implementation: "com.launchdarkly.sdk.server.LDClient.BigSegments" + */ + static final Logger BIG_SEGMENTS = LoggerFactory.getLogger(BIG_SEGMENTS_LOGGER_NAME); + /** * The logger instance to use for messages related to polling, streaming, etc.: "com.launchdarkly.sdk.server.LDClient.DataSource" */ diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/BigSegmentsConfigurationBuilder.java b/src/main/java/com/launchdarkly/sdk/server/integrations/BigSegmentsConfigurationBuilder.java new file mode 100644 index 000000000..5b19d9258 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/BigSegmentsConfigurationBuilder.java @@ -0,0 +1,170 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.LDConfig; +import com.launchdarkly.sdk.server.interfaces.BigSegmentStore; +import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreFactory; +import com.launchdarkly.sdk.server.interfaces.BigSegmentsConfiguration; +import com.launchdarkly.sdk.server.interfaces.ClientContext; + +import java.time.Duration; + +/** + * Contains methods for configuring the SDK's Big Segments behavior. + *

      + * Big Segments are a specific type of user segments. For more information, read the + * LaunchDarkly documentation + * . + *

      + * If you want non-default values for any of these properties create a builder with + * {@link Components#bigSegments(BigSegmentStoreFactory)}, change its properties with the methods + * of this class, and pass it to {@link LDConfig.Builder#bigSegments(BigSegmentsConfigurationBuilder)} + *

      
      + *     LDConfig config = new LDConfig.Builder()
      + *         .bigSegments(Components.bigSegments(Redis.dataStore().prefix("app1"))
      + *             .userCacheSize(2000))
      + *         .build();
      + * 
      + * + * @since 5.7.0 + */ +public final class BigSegmentsConfigurationBuilder { + /** + * The default value for {@link #userCacheSize(int)}. + */ + public static final int DEFAULT_USER_CACHE_SIZE = 1000; + + /** + * The default value for {@link #userCacheTime(Duration)}. + */ + public static final Duration DEFAULT_USER_CACHE_TIME = Duration.ofSeconds(5); + + /** + * The default value for {@link #statusPollInterval(Duration)}. + */ + public static final Duration DEFAULT_STATUS_POLL_INTERVAL = Duration.ofSeconds(5); + + /** + * The default value for {@link #staleAfter(Duration)}. + */ + public static final Duration DEFAULT_STALE_AFTER = Duration.ofMinutes(2); + + private final BigSegmentStoreFactory storeFactory; + private int userCacheSize = DEFAULT_USER_CACHE_SIZE; + private Duration userCacheTime = DEFAULT_USER_CACHE_TIME; + private Duration statusPollInterval = DEFAULT_STATUS_POLL_INTERVAL; + private Duration staleAfter = DEFAULT_STALE_AFTER; + + /** + * Creates a new builder for Big Segments configuration. + * + * @param storeFactory the factory implementation for the specific data store type + */ + public BigSegmentsConfigurationBuilder(BigSegmentStoreFactory storeFactory) { + this.storeFactory = storeFactory; + } + + /** + * Sets the maximum number of users whose Big Segment state will be cached by the SDK at any given + * time. + *

      + * To reduce database traffic, the SDK maintains a least-recently-used cache by user key. When a + * feature flag that references a Big Segment is evaluated for some user who is not currently in + * the cache, the SDK queries the database for all Big Segment memberships of that user, and + * stores them together in a single cache entry. If the cache is full, the oldest entry is + * dropped. + *

      + * A higher value for {@code userCacheSize} means that database queries for Big Segments will be + * done less often for recently-referenced users, if the application has many users, at the cost + * of increased memory used by the cache. + *

      + * Cache entries can also expire based on the setting of {@link #userCacheTime(Duration)}. + * + * @param userCacheSize the maximum number of user states to cache + * @return the builder + * @see #DEFAULT_USER_CACHE_SIZE + */ + public BigSegmentsConfigurationBuilder userCacheSize(int userCacheSize) { + this.userCacheSize = Math.max(userCacheSize, 0); + return this; + } + + /** + * Sets the maximum length of time that the Big Segment state for a user will be cached by the + * SDK. + *

      + * See {@link #userCacheSize(int)} for more about this cache. A higher value for + * {@code userCacheTime} means that database queries for the Big Segment state of any given user + * will be done less often, but that changes to segment membership may not be detected as soon. + * + * @param userCacheTime the cache TTL (a value of null, or a negative value will be changed to + * {@link #DEFAULT_USER_CACHE_TIME} + * @return the builder + * @see #DEFAULT_USER_CACHE_TIME + */ + public BigSegmentsConfigurationBuilder userCacheTime(Duration userCacheTime) { + this.userCacheTime = userCacheTime != null && userCacheTime.compareTo(Duration.ZERO) >= 0 + ? userCacheTime : DEFAULT_USER_CACHE_TIME; + return this; + } + + /** + * Sets the interval at which the SDK will poll the Big Segment store to make sure it is available + * and to determine how long ago it was updated. + * + * @param statusPollInterval the status polling interval (a null, zero, or negative value will + * be changed to {@link #DEFAULT_STATUS_POLL_INTERVAL}) + * @return the builder + * @see #DEFAULT_STATUS_POLL_INTERVAL + */ + public BigSegmentsConfigurationBuilder statusPollInterval(Duration statusPollInterval) { + this.statusPollInterval = statusPollInterval != null && statusPollInterval.compareTo(Duration.ZERO) > 0 + ? statusPollInterval : DEFAULT_STATUS_POLL_INTERVAL; + return this; + } + + /** + * Sets the maximum length of time between updates of the Big Segments data before the data is + * considered out of date. + *

      + * Normally, the LaunchDarkly Relay Proxy updates a timestamp in the Big Segments store at + * intervals to confirm that it is still in sync with the LaunchDarkly data, even if there have + * been no changes to the data. If the timestamp falls behind the current time by the amount + * specified by {@code staleAfter}, the SDK assumes that something is not working correctly in + * this process and that the data may not be accurate. + *

      + * While in a stale state, the SDK will still continue using the last known data, but + * {@link com.launchdarkly.sdk.server.interfaces.BigSegmentStoreStatusProvider.Status} will return + * true in its {@code stale} property, and any {@link EvaluationReason} generated from a feature + * flag that references a Big Segment will have a {@link EvaluationReason.BigSegmentsStatus} of + * {@link EvaluationReason.BigSegmentsStatus#STALE}. + * + * @param staleAfter the time limit for marking the data as stale (a null, zero, or negative + * value will be changed to {@link #DEFAULT_STALE_AFTER}) + * @return the builder + * @see #DEFAULT_STALE_AFTER + */ + public BigSegmentsConfigurationBuilder staleAfter(Duration staleAfter) { + this.staleAfter = staleAfter != null && staleAfter.compareTo(Duration.ZERO) > 0 + ? staleAfter : DEFAULT_STALE_AFTER; + return this; + } + + /** + * Called internally by the SDK to create a configuration instance. Applications do not need to + * call this method. + * + * @param context allows access to the client configuration + * @return a {@link BigSegmentsConfiguration} instance + */ + public BigSegmentsConfiguration createBigSegmentsConfiguration(ClientContext context) { + BigSegmentStore store = storeFactory == null ? null : storeFactory.createBigSegmentStore(context); + return new BigSegmentsConfiguration( + store, + userCacheSize, + userCacheTime, + statusPollInterval, + staleAfter); + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/BigSegmentStore.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/BigSegmentStore.java new file mode 100644 index 000000000..e49d31942 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/BigSegmentStore.java @@ -0,0 +1,45 @@ +package com.launchdarkly.sdk.server.interfaces; + +import java.io.Closeable; + +/** + * Interface for a read-only data store that allows querying of user membership in Big Segments. + *

      + * Big Segments are a specific type of user segments. For more information, read the + * LaunchDarkly documentation + * . + * + * @since 5.7.0 + */ +public interface BigSegmentStore extends Closeable { + /** + * Queries the store for a snapshot of the current segment state for a specific user. + *

      + * The {@code userHash} is a base64-encoded string produced by hashing the user key as defined by + * the Big Segments specification; the store implementation does not need to know the details of + * how this is done, because it deals only with already-hashed keys, but the string can be assumed + * to only contain characters that are valid in base64. + *

      + * If the store is working, but no membership state is found for this user, the method may return + * either {@code null} or an empty {@link BigSegmentStoreTypes.Membership}. It should not throw an + * exception unless there is an unexpected database error or the retrieved data is malformed. + * + * @param userHash the hashed user identifier + * @return the user's segment membership state or {@code null} + */ + BigSegmentStoreTypes.Membership getMembership(String userHash); + + /** + * Returns information about the overall state of the store. + *

      + * This method will be called only when the SDK needs the latest state, so it should not be + * cached. + *

      + * If the store is working, but no metadata has been stored in it yet, the method should return + * {@code null}. It should not throw an exception unless there is an unexpected database error or + * the retrieved data is malformed. + * + * @return the store metadata or null + */ + BigSegmentStoreTypes.StoreMetadata getMetadata(); +} diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/BigSegmentStoreFactory.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/BigSegmentStoreFactory.java new file mode 100644 index 000000000..5e6a16cbc --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/BigSegmentStoreFactory.java @@ -0,0 +1,19 @@ +package com.launchdarkly.sdk.server.interfaces; + +/** + * Interface for a factory that creates some implementation of {@link BigSegmentStore}. + * + * @see com.launchdarkly.sdk.server.Components#bigSegments(BigSegmentStoreFactory) + * @see com.launchdarkly.sdk.server.LDConfig.Builder#bigSegments(com.launchdarkly.sdk.server.integrations.BigSegmentsConfigurationBuilder) + * @since 5.7.0 + */ +public interface BigSegmentStoreFactory { + /** + * Called internally by the SDK to create an implementation instance. Applications do not need to + * call this method. + * + * @param context allows access to the client configuration + * @return a {@link BigSegmentStore} instance + */ + BigSegmentStore createBigSegmentStore(ClientContext context); +} diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/BigSegmentStoreStatusProvider.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/BigSegmentStoreStatusProvider.java new file mode 100644 index 000000000..3c43861bf --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/BigSegmentStoreStatusProvider.java @@ -0,0 +1,135 @@ +package com.launchdarkly.sdk.server.interfaces; + +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.server.LDClient; +import com.launchdarkly.sdk.server.integrations.BigSegmentsConfigurationBuilder; + +import java.time.Duration; +import java.util.Objects; + +/** + * An interface for querying the status of a Big Segment store. + *

      + * The Big Segment store is the component that receives information about Big Segments, normally + * from a database populated by the LaunchDarkly Relay Proxy. + *

      + * Big Segments are a specific type of user segments. For more information, read the + * LaunchDarkly documentation + * . + *

      + * An implementation of this interface is returned by + * {@link LDClient#getBigSegmentStoreStatusProvider()}. Application code never needs to implement + * this interface. + * + * @since 5.7.0 + */ +public interface BigSegmentStoreStatusProvider { + /** + * Returns the current status of the store. + * + * @return the latest status; will never be null + */ + Status getStatus(); + + /** + * Subscribes for notifications of status changes. + * + * @param listener the listener to add + */ + void addStatusListener(StatusListener listener); + + /** + * Unsubscribes from notifications of status changes. + * + * @param listener the listener to remove; if no such listener was added, this does nothing + */ + void removeStatusListener(StatusListener listener); + + /** + * Information about the status of a Big Segment store, provided by + * {@link BigSegmentStoreStatusProvider} + *

      + * Big Segments are a specific type of user segments. For more information, read the + * LaunchDarkly documentation + * . + */ + public static final class Status { + private final boolean available; + private final boolean stale; + + /** + * Constructor for a Big Segment status. + * + * @param available whether the Big Segment store is available + * @param stale whether the Big Segment store has not been recently updated + */ + public Status(boolean available, boolean stale) { + this.available = available; + this.stale = stale; + } + + /** + * True if the Big Segment store is able to respond to queries, so that the SDK can evaluate + * whether a user is in a segment or not. + *

      + * If this property is false, the store is not able to make queries (for instance, it may not + * have a valid database connection). In this case, the SDK will treat any reference to a Big + * Segment as if no users are included in that segment. Also, the {@link EvaluationReason} + * associated with any flag evaluation that references a Big Segment when the store is not + * available will have a {@link EvaluationReason.BigSegmentsStatus} of + * {@link EvaluationReason.BigSegmentsStatus#STORE_ERROR}. + * + * @return whether the Big Segment store is able to respond to queries + */ + public boolean isAvailable() { + return available; + } + + /** + * True if the Big Segment store is available, but has not been updated within the amount of + * time specified by + * {@link BigSegmentsConfigurationBuilder#staleAfter(Duration)}. + *

      + * This may indicate that the LaunchDarkly Relay Proxy, which populates the store, has stopped + * running or has become unable to receive fresh data from LaunchDarkly. Any feature flag + * evaluations that reference a Big Segment will be using the last known data, which may be out + * of date. + * + * @return whether the data in the Big Segment store is considered to be stale + */ + public boolean isStale() { + return stale; + } + + @Override + public boolean equals(Object other) { + if (other instanceof Status) { + Status o = (Status)other; + return available == o.available && stale == o.stale; + } + return false; + } + + @Override + public int hashCode() { + return Objects.hash(available, stale); + } + + @Override + public String toString() { + return "Status(Available=" + available + ",Stale=" + stale + ")"; + } + } + + /** + * Interface for receiving Big Segment status change notifications. + */ + public static interface StatusListener { + /** + * Called when any property of the Big Segment store status has changed. + * + * @param newStatus the new status of the Big Segment store + */ + void bigSegmentStoreStatusChanged(Status newStatus); + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/BigSegmentStoreTypes.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/BigSegmentStoreTypes.java new file mode 100644 index 000000000..46f5519c0 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/BigSegmentStoreTypes.java @@ -0,0 +1,225 @@ +package com.launchdarkly.sdk.server.interfaces; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +/** + * Types that are used by the {@link BigSegmentStore} interface. + * + * @since 5.7.0 + */ +public abstract class BigSegmentStoreTypes { + private BigSegmentStoreTypes() { + } + + /** + * A query interface returned by {@link BigSegmentStore#getMembership(String)}. + *

      + * It is associated with a single user, and provides the ability to check whether that user is + * included in or excluded from any number of Big Segments. + *

      + * This is an immutable snapshot of the state for this user at the time + * {@link BigSegmentStore#getMembership(String)} was called. Calling + * {@link #checkMembership(String)} should not cause the state to be queried again. + * Implementations should be safe for concurrent access by multiple threads. + */ + public static interface Membership { + /** + * Tests whether the user is explicitly included or explicitly excluded in the specified + * segment, or neither. + *

      + * The segment is identified by a {@code segmentRef} which is not the same as the segment key: + * it includes the key but also versioning information that the SDK will provide. The store + * implementation should not be concerned with the format of this. + *

      + * If the user is explicitly included (regardless of whether the user is also explicitly + * excluded or not-- that is, inclusion takes priority over exclusion), the method returns a + * {@code true} value. + *

      + * If the user is explicitly excluded, and is not explicitly included, the method returns a + * {@code false} value. + *

      + * If the user's status in the segment is undefined, the method returns {@code null}. + * + * @param segmentRef a string representing the segment query + * @return boolean for explicit inclusion/exclusion, null for unspecified + */ + Boolean checkMembership(String segmentRef); + } + + /** + * Convenience method for creating an implementation of {@link Membership}. + *

      + * This method is intended to be used by Big Segment store implementations; application code does + * not need to use it. + *

      + * Store implementations are free to implement {@link Membership} in any way that they find + * convenient and efficient, depending on what format they obtain values in from the database, but + * this method provides a simple way to do it as long as there are iterables of included and + * excluded segment references. As described in {@link Membership}, a {@code segmentRef} is not + * the same as the key property in the segment data model; it includes the key but also versioning + * information that the SDK will provide. The store implementation should not be concerned with + * the format of this. + *

      + * The returned object's {@link Membership#checkMembership(String)} method will return + * {@code true} for any {@code segmentRef} that is in the included list, + * {@code false} for any {@code segmentRef} that is in the excluded list and not also in the + * included list (that is, inclusions override exclusions), and {@code null} for all others. + *

      + * This method is optimized to return a singleton empty membership object whenever the inclusion + * and exclusions lists are both empty. + *

      + * The returned object implements {@link Object#equals(Object)} in such a way that it correctly + * tests equality when compared to any object returned from this factory method, but it is always + * unequal to any other types of objects. + * + * @param includedSegmentRefs the inclusion list (null is equivalent to an empty iterable) + * @param excludedSegmentRefs the exclusion list (null is equivalent to an empty iterable) + * @return an {@link Membership} + */ + public static Membership createMembershipFromSegmentRefs( + Iterable includedSegmentRefs, + Iterable excludedSegmentRefs) { + MembershipBuilder membershipBuilder = new MembershipBuilder(); + // we must add excludes first so includes will override them + membershipBuilder.addRefs(excludedSegmentRefs, false); + membershipBuilder.addRefs(includedSegmentRefs, true); + return membershipBuilder.build(); + } + + /** + * Values returned by {@link BigSegmentStore#getMetadata()}. + */ + public static final class StoreMetadata { + private final long lastUpToDate; + + /** + * Constructor for a {@link StoreMetadata}. + * + * @param lastUpToDate the Unix millisecond timestamp of the last update + */ + public StoreMetadata(long lastUpToDate) { + this.lastUpToDate = lastUpToDate; + } + + /** + * The timestamp of the last update to the {@link BigSegmentStore}. + * + * @return the last update timestamp as Unix milliseconds + */ + public long getLastUpToDate() { + return this.lastUpToDate; + } + } + + private static class MembershipBuilder { + private boolean nonEmpty; + private String firstValue; + private boolean firstValueIncluded; + private HashMap map; + + void addRefs(Iterable segmentRefs, boolean included) { + if (segmentRefs == null) { + return; + } + for (String s : segmentRefs) { + if (s == null) { + continue; + } + if (nonEmpty) { + if (map == null) { + map = new HashMap<>(); + map.put(firstValue, firstValueIncluded); + } + map.put(s, included); + } else { + firstValue = s; + firstValueIncluded = included; + nonEmpty = true; + } + } + } + + Membership build() { + if (nonEmpty) { + if (map != null) { + return new MapMembership(map); + } + return new SingleValueMembership(firstValue, firstValueIncluded); + } + return EmptyMembership.instance; + } + + private static final class EmptyMembership implements Membership { + static final EmptyMembership instance = new EmptyMembership(); + + @Override + public Boolean checkMembership(String segmentRef) { + return null; + } + + @Override + public boolean equals(Object o) { + return o instanceof EmptyMembership; + } + + @Override + public int hashCode() { + return 0; + } + } + + private static final class SingleValueMembership implements Membership { + private final String segmentRef; + private final boolean included; + + SingleValueMembership(String segmentRef, boolean included) { + this.segmentRef = segmentRef; + this.included = included; + } + + @Override + public Boolean checkMembership(String segmentRef) { + return this.segmentRef.equals(segmentRef) ? included : null; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof SingleValueMembership)) { + return false; + } + SingleValueMembership other = (SingleValueMembership) o; + return segmentRef.equals(other.segmentRef) && included == other.included; + } + + @Override + public int hashCode() { + return segmentRef.hashCode() + (included ? 1 : 0); + } + } + + private static final class MapMembership implements Membership { + private final Map map; + + private MapMembership(Map map) { + this.map = map; + } + + @Override + public Boolean checkMembership(String segmentRef) { + return map.get(segmentRef); + } + + @Override + public boolean equals(Object o) { + return o instanceof MapMembership && map.equals(((MapMembership) o).map); + } + + @Override + public int hashCode() { + return Objects.hash(map); + } + } + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/BigSegmentsConfiguration.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/BigSegmentsConfiguration.java new file mode 100644 index 000000000..c2bd2cfde --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/BigSegmentsConfiguration.java @@ -0,0 +1,93 @@ +package com.launchdarkly.sdk.server.interfaces; + +import com.launchdarkly.sdk.server.integrations.BigSegmentsConfigurationBuilder; + +import java.time.Duration; + +/** + * Encapsulates the SDK's configuration with regard to Big Segments. + *

      + * Big Segments are a specific type of user segments. For more information, read the + * LaunchDarkly documentation + * . + *

      + * See {@link BigSegmentsConfigurationBuilder} for more details on these properties. + * + * @see BigSegmentsConfigurationBuilder + * @since 5.7.0 + */ +public final class BigSegmentsConfiguration { + private final BigSegmentStore bigSegmentStore; + private final int userCacheSize; + private final Duration userCacheTime; + private final Duration statusPollInterval; + private final Duration staleAfter; + + /** + * Creates a new {@link BigSegmentsConfiguration} instance with the specified values. + *

      + * See {@link BigSegmentsConfigurationBuilder} for more information on the configuration fields. + * + * @param bigSegmentStore the Big Segments store instance + * @param userCacheSize the user cache size + * @param userCacheTime the user cache time + * @param statusPollInterval the status poll interval + * @param staleAfter the interval after which store data is considered stale + */ + public BigSegmentsConfiguration(BigSegmentStore bigSegmentStore, + int userCacheSize, + Duration userCacheTime, + Duration statusPollInterval, + Duration staleAfter) { + this.bigSegmentStore = bigSegmentStore; + this.userCacheSize = userCacheSize; + this.userCacheTime = userCacheTime; + this.statusPollInterval = statusPollInterval; + this.staleAfter = staleAfter; + } + + /** + * Gets the data store instance that is used for Big Segments data. + * + * @return the configured Big Segment store + */ + public BigSegmentStore getStore() { + return this.bigSegmentStore; + } + + /** + * Gets the value set by {@link BigSegmentsConfigurationBuilder#userCacheSize(int)} + * + * @return the configured user cache size limit + */ + public int getUserCacheSize() { + return this.userCacheSize; + } + + /** + * Gets the value set by {@link BigSegmentsConfigurationBuilder#userCacheTime(Duration)} + * + * @return the configured user cache time duration + */ + public Duration getUserCacheTime() { + return this.userCacheTime; + } + + /** + * Gets the value set by {@link BigSegmentsConfigurationBuilder#statusPollInterval(Duration)} + * + * @return the configured status poll interval + */ + public Duration getStatusPollInterval() { + return this.statusPollInterval; + } + + /** + * Gets the value set by {@link BigSegmentsConfigurationBuilder#staleAfter(Duration)} + * + * @return the configured stale after interval + */ + public Duration getStaleAfter() { + return this.staleAfter; + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/LDClientInterface.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/LDClientInterface.java index 2996ed66a..08c5df11e 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/LDClientInterface.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/LDClientInterface.java @@ -236,6 +236,18 @@ public interface LDClientInterface extends Closeable { */ FlagTracker getFlagTracker(); + /** + * Returns an interface for tracking the status of the Big Segment store. + *

      + * The returned object has methods for checking whether the Big Segment store is (as far as the + * SDK knows) currently operational and tracking changes in this status. See + * {@link BigSegmentStoreStatusProvider} for more about this functionality. + * + * @return a {@link BigSegmentStoreStatusProvider} + * @since 5.7.0 + */ + BigSegmentStoreStatusProvider getBigSegmentStoreStatusProvider(); + /** * Returns an interface for tracking the status of the data source. *

      diff --git a/src/test/java/com/launchdarkly/sdk/server/BigSegmentStoreStatusProviderImplTest.java b/src/test/java/com/launchdarkly/sdk/server/BigSegmentStoreStatusProviderImplTest.java new file mode 100644 index 000000000..7d0f96540 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/BigSegmentStoreStatusProviderImplTest.java @@ -0,0 +1,60 @@ +package com.launchdarkly.sdk.server; + +import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.same; +import static org.junit.Assert.assertEquals; + +import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreStatusProvider.Status; +import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreStatusProvider.StatusListener; + +import org.easymock.EasyMockSupport; +import org.junit.Before; +import org.junit.Test; + +@SuppressWarnings("javadoc") +public class BigSegmentStoreStatusProviderImplTest extends EasyMockSupport { + + // We don't need to extensively test status broadcasting behavior, just that the implementation + // delegates to the BigSegmentStoreWrapper and EventBroadcasterImpl. + + private StatusListener mockStatusListener; + private EventBroadcasterImpl mockEventBroadcaster; + + @Before + @SuppressWarnings("unchecked") + public void setup() { + mockEventBroadcaster = strictMock(EventBroadcasterImpl.class); + mockStatusListener = strictMock(StatusListener.class); + } + + @Test + public void statusUnavailableWithNullWrapper() { + replayAll(); + BigSegmentStoreStatusProviderImpl statusProvider = new BigSegmentStoreStatusProviderImpl(mockEventBroadcaster, null); + assertEquals(statusProvider.getStatus(), new Status(false, false)); + verifyAll(); + } + + @Test + public void statusDelegatedToWrapper() { + BigSegmentStoreWrapper storeWrapper = strictMock(BigSegmentStoreWrapper.class); + expect(storeWrapper.getStatus()).andReturn(new Status(true, false)).once(); + replayAll(); + + BigSegmentStoreStatusProviderImpl statusProvider = new BigSegmentStoreStatusProviderImpl(mockEventBroadcaster, storeWrapper); + assertEquals(statusProvider.getStatus(), new Status(true, false)); + verifyAll(); + } + + @Test + public void listenersDelegatedToEventBroadcaster() { + mockEventBroadcaster.register(same(mockStatusListener)); + mockEventBroadcaster.unregister(same(mockStatusListener)); + replayAll(); + + BigSegmentStoreStatusProviderImpl statusProvider = new BigSegmentStoreStatusProviderImpl(mockEventBroadcaster, null); + statusProvider.addStatusListener(mockStatusListener); + statusProvider.removeStatusListener(mockStatusListener); + verifyAll(); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/BigSegmentStoreWrapperTest.java b/src/test/java/com/launchdarkly/sdk/server/BigSegmentStoreWrapperTest.java new file mode 100644 index 000000000..1b5a8bb38 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/BigSegmentStoreWrapperTest.java @@ -0,0 +1,260 @@ +package com.launchdarkly.sdk.server; + +import static com.launchdarkly.sdk.server.TestComponents.clientContext; +import static com.launchdarkly.sdk.server.TestComponents.sharedExecutor; +import static com.launchdarkly.sdk.server.interfaces.BigSegmentStoreTypes.createMembershipFromSegmentRefs; +import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.isA; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import com.launchdarkly.sdk.EvaluationReason.BigSegmentsStatus; +import com.launchdarkly.sdk.server.BigSegmentStoreWrapper.BigSegmentsQueryResult; +import com.launchdarkly.sdk.server.interfaces.BigSegmentStore; +import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreFactory; +import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreStatusProvider; +import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreStatusProvider.Status; +import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreStatusProvider.StatusListener; +import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreTypes.Membership; +import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreTypes.StoreMetadata; +import com.launchdarkly.sdk.server.interfaces.BigSegmentsConfiguration; +import com.launchdarkly.sdk.server.interfaces.ClientContext; + +import org.easymock.EasyMockSupport; +import org.junit.Before; +import org.junit.Test; + +import java.time.Duration; +import java.util.Collections; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +@SuppressWarnings("javadoc") +public class BigSegmentStoreWrapperTest extends EasyMockSupport { + private static final String SDK_KEY = "sdk-key"; + + private AtomicBoolean storeUnavailable; + private AtomicReference storeMetadata; + private BigSegmentStore storeMock; + private BigSegmentStoreFactory storeFactoryMock; + private EventBroadcasterImpl eventBroadcaster; + + @Before + public void setup() { + eventBroadcaster = EventBroadcasterImpl.forBigSegmentStoreStatus(sharedExecutor); + storeUnavailable = new AtomicBoolean(false); + storeMetadata = new AtomicReference<>(null); + storeMock = niceMock(BigSegmentStore.class); + expect(storeMock.getMetadata()).andAnswer(() -> { + if (storeUnavailable.get()) { + throw new RuntimeException("sorry"); + } + return storeMetadata.get(); + }).anyTimes(); + storeFactoryMock = strictMock(BigSegmentStoreFactory.class); + expect(storeFactoryMock.createBigSegmentStore(isA(ClientContext.class))).andReturn(storeMock); + } + + private void setStoreMembership(String userKey, Membership membership) { + expect(storeMock.getMembership(BigSegmentStoreWrapper.hashForUserKey(userKey))).andReturn(membership); + } + + @Test + public void membershipQueryWithUncachedResultAndHealthyStatus() throws Exception { + Membership expectedMembership = createMembershipFromSegmentRefs(Collections.singleton("key1"), Collections.singleton("key2")); + + String userKey = "userkey"; + setStoreMembership(userKey, expectedMembership); + replayAll(); + + storeMetadata.set(new StoreMetadata(System.currentTimeMillis())); + BigSegmentsConfiguration bsConfig = Components.bigSegments(storeFactoryMock) + .staleAfter(Duration.ofDays(1)) + .createBigSegmentsConfiguration(clientContext(SDK_KEY, new LDConfig.Builder().build())); + try (BigSegmentStoreWrapper wrapper = new BigSegmentStoreWrapper(bsConfig, eventBroadcaster, sharedExecutor)) { + BigSegmentsQueryResult res = wrapper.getUserMembership(userKey); + assertEquals(expectedMembership, res.membership); + assertEquals(BigSegmentsStatus.HEALTHY, res.status); + } + } + + @Test + public void membershipQueryReturnsNull() throws Exception { + String userKey = "userkey"; + setStoreMembership(userKey, null); + replayAll(); + + storeMetadata.set(new StoreMetadata(System.currentTimeMillis())); + BigSegmentsConfiguration bsConfig = Components.bigSegments(storeFactoryMock) + .staleAfter(Duration.ofDays(1)) + .createBigSegmentsConfiguration(clientContext(SDK_KEY, new LDConfig.Builder().build())); + try (BigSegmentStoreWrapper wrapper = new BigSegmentStoreWrapper(bsConfig, eventBroadcaster, sharedExecutor)) { + BigSegmentsQueryResult res = wrapper.getUserMembership(userKey); + assertEquals(createMembershipFromSegmentRefs(null, null), res.membership); + assertEquals(BigSegmentsStatus.HEALTHY, res.status); + } + } + + @Test + public void membershipQueryWithCachedResultAndHealthyStatus() throws Exception { + Membership expectedMembership = createMembershipFromSegmentRefs(Collections.singleton("key1"), Collections.singleton("key2")); + String userKey = "userkey"; + setStoreMembership(userKey, expectedMembership); + + replayAll(); + + storeMetadata.set(new StoreMetadata(System.currentTimeMillis())); + BigSegmentsConfiguration bsConfig = Components.bigSegments(storeFactoryMock) + .staleAfter(Duration.ofDays(1)) + .createBigSegmentsConfiguration(clientContext(SDK_KEY, new LDConfig.Builder().build())); + try (BigSegmentStoreWrapper wrapper = new BigSegmentStoreWrapper(bsConfig, eventBroadcaster, sharedExecutor)) { + BigSegmentsQueryResult res1 = wrapper.getUserMembership(userKey); + assertEquals(expectedMembership, res1.membership); + assertEquals(BigSegmentsStatus.HEALTHY, res1.status); + + BigSegmentsQueryResult res2 = wrapper.getUserMembership(userKey); + assertEquals(expectedMembership, res2.membership); + assertEquals(BigSegmentsStatus.HEALTHY, res2.status); + } + } + + @Test + public void membershipQueryWithStaleStatus() throws Exception { + Membership expectedMembership = createMembershipFromSegmentRefs(Collections.singleton("key1"), Collections.singleton("key2")); + String userKey = "userkey"; + setStoreMembership(userKey, expectedMembership); + + replayAll(); + + storeMetadata.set(new StoreMetadata(System.currentTimeMillis() - 1000)); + BigSegmentsConfiguration bsConfig = Components.bigSegments(storeFactoryMock) + .staleAfter(Duration.ofMillis(500)) + .createBigSegmentsConfiguration(clientContext(SDK_KEY, new LDConfig.Builder().build())); + try (BigSegmentStoreWrapper wrapper = new BigSegmentStoreWrapper(bsConfig, eventBroadcaster, sharedExecutor)) { + BigSegmentsQueryResult res = wrapper.getUserMembership(userKey); + assertEquals(expectedMembership, res.membership); + assertEquals(BigSegmentsStatus.STALE, res.status); + } + } + + @Test + public void membershipQueryWithStaleStatusDueToNoStoreMetadata() throws Exception { + Membership expectedMembership = createMembershipFromSegmentRefs(Collections.singleton("key1"), Collections.singleton("key2")); + String userKey = "userkey"; + setStoreMembership(userKey, expectedMembership); + + replayAll(); + + storeMetadata.set(null); + BigSegmentsConfiguration bsConfig = Components.bigSegments(storeFactoryMock) + .staleAfter(Duration.ofMillis(500)) + .createBigSegmentsConfiguration(clientContext(SDK_KEY, new LDConfig.Builder().build())); + try (BigSegmentStoreWrapper wrapper = new BigSegmentStoreWrapper(bsConfig, eventBroadcaster, sharedExecutor)) { + BigSegmentsQueryResult res = wrapper.getUserMembership(userKey); + assertEquals(expectedMembership, res.membership); + assertEquals(BigSegmentsStatus.STALE, res.status); + } + } + + @Test + public void leastRecentUserIsEvictedFromCache() throws Exception { + String userKey1 = "userkey1", userKey2 = "userkey2", userKey3 = "userkey3"; + Membership expectedMembership1 = createMembershipFromSegmentRefs(Collections.singleton("seg1"), null); + Membership expectedMembership2 = createMembershipFromSegmentRefs(Collections.singleton("seg2"), null); + Membership expectedMembership3 = createMembershipFromSegmentRefs(Collections.singleton("seg3"), null); + setStoreMembership(userKey1, expectedMembership1); + setStoreMembership(userKey2, expectedMembership2); + setStoreMembership(userKey3, expectedMembership3); + setStoreMembership(userKey1, expectedMembership1); + + replayAll(); + + storeMetadata.set(new StoreMetadata(System.currentTimeMillis())); + BigSegmentsConfiguration bsConfig = Components.bigSegments(storeFactoryMock) + .userCacheSize(2) + .staleAfter(Duration.ofDays(1)) + .createBigSegmentsConfiguration(clientContext(SDK_KEY, new LDConfig.Builder().build())); + try (BigSegmentStoreWrapper wrapper = new BigSegmentStoreWrapper(bsConfig, eventBroadcaster, sharedExecutor)) { + BigSegmentsQueryResult res1 = wrapper.getUserMembership(userKey1); + assertEquals(expectedMembership1, res1.membership); + assertEquals(BigSegmentsStatus.HEALTHY, res1.status); + + BigSegmentsQueryResult res2 = wrapper.getUserMembership(userKey2); + assertEquals(expectedMembership2, res2.membership); + assertEquals(BigSegmentsStatus.HEALTHY, res2.status); + + BigSegmentsQueryResult res3 = wrapper.getUserMembership(userKey3); + assertEquals(expectedMembership3, res3.membership); + assertEquals(BigSegmentsStatus.HEALTHY, res3.status); + + BigSegmentsQueryResult res2a = wrapper.getUserMembership(userKey2); + assertEquals(expectedMembership2, res2a.membership); + assertEquals(BigSegmentsStatus.HEALTHY, res2a.status); + + BigSegmentsQueryResult res3a = wrapper.getUserMembership(userKey3); + assertEquals(expectedMembership3, res3a.membership); + assertEquals(BigSegmentsStatus.HEALTHY, res3a.status); + + BigSegmentsQueryResult res1a = wrapper.getUserMembership(userKey1); + assertEquals(expectedMembership1, res1a.membership); + assertEquals(BigSegmentsStatus.HEALTHY, res1a.status); + } + } + + @Test + public void pollingDetectsStoreUnavailability() throws Exception { + replayAll(); + + storeMetadata.set(new StoreMetadata(System.currentTimeMillis())); + BigSegmentsConfiguration bsConfig = Components.bigSegments(storeFactoryMock) + .statusPollInterval(Duration.ofMillis(10)) + .staleAfter(Duration.ofDays(1)) + .createBigSegmentsConfiguration(clientContext(SDK_KEY, new LDConfig.Builder().build())); + try (BigSegmentStoreWrapper wrapper = new BigSegmentStoreWrapper(bsConfig, eventBroadcaster, sharedExecutor)) { + assertTrue(wrapper.getStatus().isAvailable()); + + BlockingQueue statuses = new LinkedBlockingQueue<>(); + eventBroadcaster.register(statuses::add); + + storeUnavailable.set(true); + Status status1 = statuses.take(); + assertFalse(status1.isAvailable()); + assertEquals(status1, wrapper.getStatus()); + + storeUnavailable.set(false); + Status status2 = statuses.take(); + assertTrue(status2.isAvailable()); + assertEquals(status2, wrapper.getStatus()); + } + } + + @Test + public void pollingDetectsStaleStatus() throws Exception { + replayAll(); + + storeMetadata.set(new StoreMetadata(System.currentTimeMillis() + 5000)); + BigSegmentsConfiguration bsConfig = Components.bigSegments(storeFactoryMock) + .statusPollInterval(Duration.ofMillis(10)) + .staleAfter(Duration.ofMillis(200)) + .createBigSegmentsConfiguration(clientContext(SDK_KEY, new LDConfig.Builder().build())); + try (BigSegmentStoreWrapper wrapper = new BigSegmentStoreWrapper(bsConfig, eventBroadcaster, sharedExecutor)) { + assertFalse(wrapper.getStatus().isStale()); + + BlockingQueue statuses = new LinkedBlockingQueue<>(); + eventBroadcaster.register(statuses::add); + + storeMetadata.set(new StoreMetadata(System.currentTimeMillis() - 200)); + Status status1 = statuses.take(); + assertTrue(status1.isStale()); + assertEquals(status1, wrapper.getStatus()); + + storeMetadata.set(new StoreMetadata(System.currentTimeMillis() + 5000)); + Status status2 = statuses.take(); + assertFalse(status2.isStale()); + assertEquals(status2, wrapper.getStatus()); + } + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java b/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java index f9df59679..4a5afddb3 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java @@ -153,6 +153,8 @@ public void segmentIsDeserializedWithMinimalProperties() { assertEquals(0, segment.getExcluded().size()); assertNotNull(segment.getRules()); assertEquals(0, segment.getRules().size()); + assertFalse(segment.isUnbounded()); + assertNull(segment.getGeneration()); } @Test @@ -254,6 +256,10 @@ public void explicitNullsAreToleratedForNullableValues() { .build(), segment -> assertEquals(Collections.emptyList(), segment.getRules().get(0).getClauses()) ); + assertSegmentFromJson( + baseBuilder("segment-key").put("generation", LDValue.ofNull()).build(), + segment -> assertNull(segment.getGeneration()) + ); // Nulls in clause values are not useful since the clause can never match, but they're valid JSON; // we should normalize them to LDValue.ofNull() to avoid potential NPEs down the line @@ -428,7 +434,7 @@ private LDValue segmentWithAllPropertiesJson() { .put("included", LDValue.buildArray().add("key1").add("key2").build()) .put("excluded", LDValue.buildArray().add("key3").add("key4").build()) .put("salt", "123") - .put("rules", LDValue.buildArray() + .put("rules", LDValue.buildArray() .add(LDValue.buildObject() .put("weight", 50000) .put("bucketBy", "email") @@ -444,6 +450,9 @@ private LDValue segmentWithAllPropertiesJson() { .add(LDValue.buildObject() .build()) .build()) + .put("unbounded", true) + .put("generation", 10) + // Extra fields should be ignored .put("fallthrough", LDValue.buildObject() .put("variation", 1) .build()) @@ -470,6 +479,9 @@ private void assertSegmentHasAllProperties(Segment segment) { assertEquals(Operator.in, c0.getOp()); assertEquals(ImmutableList.of(LDValue.of("Lucy"), LDValue.of("Mina")), c0.getValues()); assertTrue(c0.isNegate()); + + assertTrue(segment.isUnbounded()); + assertEquals((Integer)10, segment.getGeneration()); // Check for just one example of preprocessing, to verify that preprocessing has happened in // general for this segment - the details are covered in EvaluatorPreprocessingTest. diff --git a/src/test/java/com/launchdarkly/sdk/server/DataModelTest.java b/src/test/java/com/launchdarkly/sdk/server/DataModelTest.java index 489701db2..712a2d871 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataModelTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataModelTest.java @@ -61,19 +61,19 @@ public void clauseValuesListCanNeverBeNull() { @Test public void segmentIncludedCanNeverBeNull() { - Segment s = new Segment("key", null, null, null, null, 0, false); + Segment s = new Segment("key", null, null, null, null, 0, false, false, null); assertEquals(ImmutableSet.of(), s.getIncluded()); } @Test public void segmentExcludedCanNeverBeNull() { - Segment s = new Segment("key", null, null, null, null, 0, false); + Segment s = new Segment("key", null, null, null, null, 0, false, false, null); assertEquals(ImmutableSet.of(), s.getExcluded()); } @Test public void segmentRulesListCanNeverBeNull() { - Segment s = new Segment("key", null, null, null, null, 0, false); + Segment s = new Segment("key", null, null, null, null, 0, false, false, null); assertEquals(ImmutableList.of(), s.getRules()); } diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorBigSegmentTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorBigSegmentTest.java new file mode 100644 index 000000000..0ce40e8f8 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorBigSegmentTest.java @@ -0,0 +1,149 @@ +package com.launchdarkly.sdk.server; + +import static com.launchdarkly.sdk.server.Evaluator.makeBigSegmentRef; +import static com.launchdarkly.sdk.server.EvaluatorTestUtil.evaluatorBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.booleanFlagWithClauses; +import static com.launchdarkly.sdk.server.ModelBuilders.clauseMatchingSegment; +import static com.launchdarkly.sdk.server.ModelBuilders.clauseMatchingUser; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.ruleBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.segmentBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.segmentRuleBuilder; +import static com.launchdarkly.sdk.server.interfaces.BigSegmentStoreTypes.createMembershipFromSegmentRefs; +import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.replay; +import static org.easymock.EasyMock.strictMock; +import static org.junit.Assert.assertEquals; + +import com.launchdarkly.sdk.EvaluationReason.BigSegmentsStatus; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.Evaluator.EvalResult; + +import org.junit.Test; + +import java.util.Collections; + +@SuppressWarnings("javadoc") +public class EvaluatorBigSegmentTest { + private static final LDUser testUser = new LDUser("userkey"); + + @Test + public void bigSegmentWithNoProviderIsNotMatched() { + DataModel.Segment segment = segmentBuilder("segmentkey").unbounded(true).generation(1) + .included(testUser.getKey()) // Included should be ignored for a big segment + .build(); + DataModel.FeatureFlag flag = booleanFlagWithClauses("key", clauseMatchingSegment(segment)); + Evaluator evaluator = evaluatorBuilder().withStoredSegments(segment).withBigSegmentQueryResult(testUser.getKey(), null).build(); + + EvalResult result = evaluator.evaluate(flag, testUser, EventFactory.DEFAULT); + assertEquals(LDValue.of(false), result.getValue()); + assertEquals(BigSegmentsStatus.NOT_CONFIGURED, result.getReason().getBigSegmentsStatus()); + } + + @Test + public void bigSegmentWithNoGenerationIsNotMatched() { + // Segment without generation + DataModel.Segment segment = segmentBuilder("segmentkey").unbounded(true).build(); + DataModel.FeatureFlag flag = booleanFlagWithClauses("key", clauseMatchingSegment(segment)); + Evaluator evaluator = evaluatorBuilder().withStoredSegments(segment).build(); + + EvalResult result = evaluator.evaluate(flag, testUser, EventFactory.DEFAULT); + assertEquals(LDValue.of(false), result.getValue()); + assertEquals(BigSegmentsStatus.NOT_CONFIGURED, result.getReason().getBigSegmentsStatus()); + } + + @Test + public void matchedWithInclude() { + DataModel.Segment segment = segmentBuilder("segmentkey").unbounded(true).generation(2).build(); + DataModel.FeatureFlag flag = booleanFlagWithClauses("key", clauseMatchingSegment(segment)); + BigSegmentStoreWrapper.BigSegmentsQueryResult queryResult = new BigSegmentStoreWrapper.BigSegmentsQueryResult(); + queryResult.status = BigSegmentsStatus.HEALTHY; + queryResult.membership = createMembershipFromSegmentRefs(Collections.singleton(makeBigSegmentRef(segment)), null); + Evaluator evaluator = evaluatorBuilder().withStoredSegments(segment).withBigSegmentQueryResult(testUser.getKey(), queryResult).build(); + + EvalResult result = evaluator.evaluate(flag, testUser, EventFactory.DEFAULT); + assertEquals(LDValue.of(true), result.getValue()); + assertEquals(BigSegmentsStatus.HEALTHY, result.getReason().getBigSegmentsStatus()); + } + + @Test + public void matchedWithRule() { + DataModel.Clause clause = clauseMatchingUser(testUser); + DataModel.SegmentRule segmentRule = segmentRuleBuilder().clauses(clause).build(); + DataModel.Segment segment = segmentBuilder("segmentkey").unbounded(true).generation(2) + .rules(segmentRule) + .build(); + DataModel.FeatureFlag flag = booleanFlagWithClauses("key", clauseMatchingSegment(segment)); + BigSegmentStoreWrapper.BigSegmentsQueryResult queryResult = new BigSegmentStoreWrapper.BigSegmentsQueryResult(); + queryResult.status = BigSegmentsStatus.HEALTHY; + queryResult.membership = createMembershipFromSegmentRefs(null, null); + Evaluator evaluator = evaluatorBuilder().withStoredSegments(segment).withBigSegmentQueryResult(testUser.getKey(), queryResult).build(); + + EvalResult result = evaluator.evaluate(flag, testUser, EventFactory.DEFAULT); + assertEquals(LDValue.of(true), result.getValue()); + assertEquals(BigSegmentsStatus.HEALTHY, result.getReason().getBigSegmentsStatus()); + } + + @Test + public void unmatchedByExcludeRegardlessOfRule() { + DataModel.Clause clause = clauseMatchingUser(testUser); + DataModel.SegmentRule segmentRule = segmentRuleBuilder().clauses(clause).build(); + DataModel.Segment segment = segmentBuilder("segmentkey").unbounded(true).generation(2) + .rules(segmentRule) + .build(); + DataModel.FeatureFlag flag = booleanFlagWithClauses("key", clauseMatchingSegment(segment)); + BigSegmentStoreWrapper.BigSegmentsQueryResult queryResult = new BigSegmentStoreWrapper.BigSegmentsQueryResult(); + queryResult.status = BigSegmentsStatus.HEALTHY; + queryResult.membership = createMembershipFromSegmentRefs(null, Collections.singleton(makeBigSegmentRef(segment))); + Evaluator evaluator = evaluatorBuilder().withStoredSegments(segment).withBigSegmentQueryResult(testUser.getKey(), queryResult).build(); + + EvalResult result = evaluator.evaluate(flag, testUser, EventFactory.DEFAULT); + assertEquals(LDValue.of(false), result.getValue()); + assertEquals(BigSegmentsStatus.HEALTHY, result.getReason().getBigSegmentsStatus()); + } + + @Test + public void bigSegmentStatusIsReturnedFromProvider() { + DataModel.Segment segment = segmentBuilder("segmentkey").unbounded(true).generation(2).build(); + DataModel.FeatureFlag flag = booleanFlagWithClauses("key", clauseMatchingSegment(segment)); + BigSegmentStoreWrapper.BigSegmentsQueryResult queryResult = new BigSegmentStoreWrapper.BigSegmentsQueryResult(); + queryResult.status = BigSegmentsStatus.STALE; + queryResult.membership = createMembershipFromSegmentRefs(Collections.singleton(makeBigSegmentRef(segment)), null); + Evaluator evaluator = evaluatorBuilder().withStoredSegments(segment).withBigSegmentQueryResult(testUser.getKey(), queryResult).build(); + + EvalResult result = evaluator.evaluate(flag, testUser, EventFactory.DEFAULT); + assertEquals(LDValue.of(true), result.getValue()); + assertEquals(BigSegmentsStatus.STALE, result.getReason().getBigSegmentsStatus()); + } + + @Test + public void bigSegmentStateIsQueriedOnlyOncePerUserEvenIfFlagReferencesMultipleSegments() { + DataModel.Segment segment1 = segmentBuilder("segmentkey1").unbounded(true).generation(2).build(); + DataModel.Segment segment2 = segmentBuilder("segmentkey2").unbounded(true).generation(3).build(); + DataModel.FeatureFlag flag = flagBuilder("key") + .on(true) + .fallthroughVariation(0) + .variations(false, true) + .rules( + ruleBuilder().variation(1).clauses(clauseMatchingSegment(segment1)).build(), + ruleBuilder().variation(1).clauses(clauseMatchingSegment(segment2)).build() + ) + .build(); + + BigSegmentStoreWrapper.BigSegmentsQueryResult queryResult = new BigSegmentStoreWrapper.BigSegmentsQueryResult(); + queryResult.status = BigSegmentsStatus.HEALTHY; + queryResult.membership = createMembershipFromSegmentRefs(Collections.singleton(makeBigSegmentRef(segment2)), null); + + Evaluator.Getters mockGetters = strictMock(Evaluator.Getters.class); + expect(mockGetters.getSegment(segment1.getKey())).andReturn(segment1); + expect(mockGetters.getBigSegments(testUser.getKey())).andReturn(queryResult); + expect(mockGetters.getSegment(segment2.getKey())).andReturn(segment2); + replay(mockGetters); + + Evaluator evaluator = new Evaluator(mockGetters); + EvalResult result = evaluator.evaluate(flag, testUser, EventFactory.DEFAULT); + assertEquals(LDValue.of(true), result.getValue()); + assertEquals(BigSegmentsStatus.HEALTHY, result.getReason().getBigSegmentsStatus()); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorPreprocessingTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorPreprocessingTest.java index a09a1d8d5..beafff7fa 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EvaluatorPreprocessingTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorPreprocessingTest.java @@ -211,7 +211,7 @@ public void preprocessSegmentPreprocessesClausesInRules() { false ); SegmentRule rule = new SegmentRule(ImmutableList.of(c), null, null); - Segment s = new Segment("key", null, null, null, ImmutableList.of(rule), 0, false); + Segment s = new Segment("key", null, null, null, ImmutableList.of(rule), 0, false, false, null); assertNull(s.getRules().get(0).getClauses().get(0).getPreprocessed()); diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorTestUtil.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTestUtil.java index 6a0cc5e22..af105256a 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EvaluatorTestUtil.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTestUtil.java @@ -1,5 +1,9 @@ package com.launchdarkly.sdk.server; +import com.launchdarkly.sdk.server.BigSegmentStoreWrapper.BigSegmentsQueryResult; + +import java.util.HashMap; + @SuppressWarnings("javadoc") public abstract class EvaluatorTestUtil { public static Evaluator BASE_EVALUATOR = evaluatorBuilder().build(); @@ -9,93 +13,61 @@ public static EvaluatorBuilder evaluatorBuilder() { } public static class EvaluatorBuilder { - private Evaluator.Getters getters; - - EvaluatorBuilder() { - getters = new Evaluator.Getters() { + HashMap flagMap = new HashMap<>(); + HashMap segmentMap = new HashMap<>(); + HashMap bigSegmentMap = new HashMap<>(); + + public Evaluator build() { + return new Evaluator(new Evaluator.Getters() { public DataModel.FeatureFlag getFlag(String key) { - throw new IllegalStateException("Evaluator unexpectedly tried to query flag: " + key); + if (!flagMap.containsKey(key)) { + throw new IllegalStateException("Evaluator unexpectedly tried to query flag: " + key); + } + return flagMap.get(key); } public DataModel.Segment getSegment(String key) { - throw new IllegalStateException("Evaluator unexpectedly tried to query segment: " + key); + if (!segmentMap.containsKey(key)) { + throw new IllegalStateException("Evaluator unexpectedly tried to query segment: " + key); + } + return segmentMap.get(key); } - }; - } - - public Evaluator build() { - return new Evaluator(getters); + + public BigSegmentsQueryResult getBigSegments(String key) { + if (!bigSegmentMap.containsKey(key)) { + throw new IllegalStateException("Evaluator unexpectedly tried to query Big Segment: " + key); + } + return bigSegmentMap.get(key); + } + }); } public EvaluatorBuilder withStoredFlags(final DataModel.FeatureFlag... flags) { - final Evaluator.Getters baseGetters = getters; - getters = new Evaluator.Getters() { - public DataModel.FeatureFlag getFlag(String key) { - for (DataModel.FeatureFlag f: flags) { - if (f.getKey().equals(key)) { - return f; - } - } - return baseGetters.getFlag(key); - } - - public DataModel.Segment getSegment(String key) { - return baseGetters.getSegment(key); - } - }; + for (DataModel.FeatureFlag f: flags) { + flagMap.put(f.getKey(), f); + } return this; } public EvaluatorBuilder withNonexistentFlag(final String nonexistentFlagKey) { - final Evaluator.Getters baseGetters = getters; - getters = new Evaluator.Getters() { - public DataModel.FeatureFlag getFlag(String key) { - if (key.equals(nonexistentFlagKey)) { - return null; - } - return baseGetters.getFlag(key); - } - - public DataModel.Segment getSegment(String key) { - return baseGetters.getSegment(key); - } - }; + flagMap.put(nonexistentFlagKey, null); return this; } public EvaluatorBuilder withStoredSegments(final DataModel.Segment... segments) { - final Evaluator.Getters baseGetters = getters; - getters = new Evaluator.Getters() { - public DataModel.FeatureFlag getFlag(String key) { - return baseGetters.getFlag(key); - } - - public DataModel.Segment getSegment(String key) { - for (DataModel.Segment s: segments) { - if (s.getKey().equals(key)) { - return s; - } - } - return baseGetters.getSegment(key); - } - }; + for (DataModel.Segment s: segments) { + segmentMap.put(s.getKey(), s); + } return this; } public EvaluatorBuilder withNonexistentSegment(final String nonexistentSegmentKey) { - final Evaluator.Getters baseGetters = getters; - getters = new Evaluator.Getters() { - public DataModel.FeatureFlag getFlag(String key) { - return baseGetters.getFlag(key); - } - - public DataModel.Segment getSegment(String key) { - if (key.equals(nonexistentSegmentKey)) { - return null; - } - return baseGetters.getSegment(key); - } - }; + segmentMap.put(nonexistentSegmentKey, null); + return this; + } + + public EvaluatorBuilder withBigSegmentQueryResult(final String userKey, BigSegmentsQueryResult queryResult) { + bigSegmentMap.put(userKey, queryResult); return this; } } diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientBigSegmentsTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientBigSegmentsTest.java new file mode 100644 index 000000000..1a0de9e3e --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientBigSegmentsTest.java @@ -0,0 +1,114 @@ +package com.launchdarkly.sdk.server; + +import static com.launchdarkly.sdk.server.BigSegmentStoreWrapper.hashForUserKey; +import static com.launchdarkly.sdk.server.Evaluator.makeBigSegmentRef; +import static com.launchdarkly.sdk.server.ModelBuilders.booleanFlagWithClauses; +import static com.launchdarkly.sdk.server.ModelBuilders.clauseMatchingSegment; +import static com.launchdarkly.sdk.server.ModelBuilders.segmentBuilder; +import static com.launchdarkly.sdk.server.TestComponents.initedDataStore; +import static com.launchdarkly.sdk.server.TestComponents.specificDataStore; +import static com.launchdarkly.sdk.server.TestUtil.upsertFlag; +import static com.launchdarkly.sdk.server.TestUtil.upsertSegment; +import static com.launchdarkly.sdk.server.interfaces.BigSegmentStoreTypes.createMembershipFromSegmentRefs; +import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.isA; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.EvaluationReason.BigSegmentsStatus; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.server.DataModel.FeatureFlag; +import com.launchdarkly.sdk.server.DataModel.Segment; +import com.launchdarkly.sdk.server.interfaces.BigSegmentStore; +import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreFactory; +import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreTypes.Membership; +import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreTypes.StoreMetadata; +import com.launchdarkly.sdk.server.interfaces.ClientContext; +import com.launchdarkly.sdk.server.interfaces.DataStore; + +import org.easymock.EasyMockSupport; +import org.junit.Before; +import org.junit.Test; + +import java.util.Collections; + +@SuppressWarnings("javadoc") +public class LDClientBigSegmentsTest extends EasyMockSupport { + private final LDUser user = new LDUser("userkey"); + private final Segment bigSegment = segmentBuilder("segmentkey").unbounded(true).generation(1).build(); + private final FeatureFlag flag = booleanFlagWithClauses("flagkey", clauseMatchingSegment(bigSegment)); + + private LDConfig.Builder configBuilder; + private BigSegmentStore storeMock; + private BigSegmentStoreFactory storeFactoryMock; + + @Before + public void setup() { + DataStore dataStore = initedDataStore(); + upsertFlag(dataStore, flag); + upsertSegment(dataStore, bigSegment); + + storeMock = niceMock(BigSegmentStore.class); + storeFactoryMock = strictMock(BigSegmentStoreFactory.class); + expect(storeFactoryMock.createBigSegmentStore(isA(ClientContext.class))).andReturn(storeMock); + + configBuilder = new LDConfig.Builder() + .dataSource(Components.externalUpdatesOnly()) + .dataStore(specificDataStore(dataStore)) + .events(Components.noEvents()); + } + + @Test + public void userNotFound() throws Exception { + expect(storeMock.getMetadata()).andAnswer(() -> new StoreMetadata(System.currentTimeMillis())).anyTimes(); + expect(storeMock.getMembership(hashForUserKey(user.getKey()))).andReturn(null); + replayAll(); + + LDConfig config = configBuilder.bigSegments(Components.bigSegments(storeFactoryMock)).build(); + try (LDClient client = new LDClient("SDK_KEY", config)) { + EvaluationDetail result = client.boolVariationDetail("flagkey", user, false); + assertFalse(result.getValue()); + assertEquals(BigSegmentsStatus.HEALTHY, result.getReason().getBigSegmentsStatus()); + } + } + + @Test + public void userFound() throws Exception { + Membership membership = createMembershipFromSegmentRefs(Collections.singleton(makeBigSegmentRef(bigSegment)), null); + expect(storeMock.getMetadata()).andAnswer(() -> new StoreMetadata(System.currentTimeMillis())).anyTimes(); + expect(storeMock.getMembership(hashForUserKey(user.getKey()))).andReturn(membership); + replayAll(); + + LDConfig config = configBuilder.bigSegments(Components.bigSegments(storeFactoryMock)).build(); + try (LDClient client = new LDClient("SDK_KEY", config)) { + EvaluationDetail result = client.boolVariationDetail("flagkey", user, false); + assertTrue(result.getValue()); + assertEquals(BigSegmentsStatus.HEALTHY, result.getReason().getBigSegmentsStatus()); + } + } + + @Test + public void storeError() throws Exception { + expect(storeMock.getMetadata()).andAnswer(() -> new StoreMetadata(System.currentTimeMillis())).anyTimes(); + expect(storeMock.getMembership(hashForUserKey(user.getKey()))).andThrow(new RuntimeException("sorry")); + replayAll(); + + LDConfig config = configBuilder.bigSegments(Components.bigSegments(storeFactoryMock)).build(); + try (LDClient client = new LDClient("SDK_KEY", config)) { + EvaluationDetail result = client.boolVariationDetail("flagkey", user, false); + assertFalse(result.getValue()); + assertEquals(BigSegmentsStatus.STORE_ERROR, result.getReason().getBigSegmentsStatus()); + } + } + + @Test + public void storeNotConfigured() throws Exception { + try (LDClient client = new LDClient("SDK_KEY", configBuilder.build())) { + EvaluationDetail result = client.boolVariationDetail("flagkey", user, false); + assertFalse(result.getValue()); + assertEquals(BigSegmentsStatus.NOT_CONFIGURED, result.getReason().getBigSegmentsStatus()); + } + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java index 811c74342..6a776ea26 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java @@ -5,6 +5,11 @@ import com.launchdarkly.sdk.server.TestComponents.DataStoreFactoryThatExposesUpdater; import com.launchdarkly.sdk.server.integrations.MockPersistentDataStore; import com.launchdarkly.sdk.server.integrations.TestData; +import com.launchdarkly.sdk.server.interfaces.BigSegmentStore; +import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreFactory; +import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreStatusProvider; +import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreTypes; +import com.launchdarkly.sdk.server.interfaces.ClientContext; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; @@ -15,20 +20,27 @@ import org.easymock.EasyMockSupport; import org.junit.Test; +import java.time.Duration; import java.time.Instant; import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import static com.launchdarkly.sdk.server.TestComponents.specificPersistentDataStore; import static com.launchdarkly.testhelpers.ConcurrentHelpers.assertNoMoreValues; import static com.launchdarkly.testhelpers.ConcurrentHelpers.awaitValue; +import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.isA; +import static org.easymock.EasyMock.replay; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.greaterThanOrEqualTo; import static org.hamcrest.Matchers.nullValue; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; /** * This file contains tests for all of the event broadcaster/listener functionality in the client, plus @@ -273,4 +285,55 @@ public void eventsAreDispatchedOnTaskThread() throws Exception { assertThat(handlerThread.getName(), containsString("LaunchDarkly-tasks")); } } + + @Test + public void bigSegmentStoreStatusReturnsUnavailableStatusWhenNotConfigured() throws Exception { + LDConfig config = new LDConfig.Builder() + .dataSource(Components.externalUpdatesOnly()) + .events(Components.noEvents()) + .build(); + try (LDClient client = new LDClient(SDK_KEY, config)) { + BigSegmentStoreStatusProvider.Status status = client.getBigSegmentStoreStatusProvider().getStatus(); + assertFalse(status.isAvailable()); + assertFalse(status.isStale()); + } + } + + @Test + public void bigSegmentStoreStatusProviderSendsStatusUpdates() throws Exception { + AtomicBoolean storeAvailable = new AtomicBoolean(true); + BigSegmentStore storeMock = niceMock(BigSegmentStore.class); + expect(storeMock.getMetadata()).andAnswer(() -> { + if (storeAvailable.get()) { + return new BigSegmentStoreTypes.StoreMetadata(System.currentTimeMillis()); + } + throw new RuntimeException("sorry"); + }).anyTimes(); + + BigSegmentStoreFactory storeFactoryMock = strictMock(BigSegmentStoreFactory.class); + expect(storeFactoryMock.createBigSegmentStore(isA(ClientContext.class))).andReturn(storeMock); + + replay(storeFactoryMock, storeMock); + + LDConfig config = new LDConfig.Builder() + .bigSegments( + Components.bigSegments(storeFactoryMock).statusPollInterval(Duration.ofMillis(10)) + ) + .dataSource(Components.externalUpdatesOnly()) + .events(Components.noEvents()) + .build(); + + try (LDClient client = new LDClient(SDK_KEY, config)) { + BigSegmentStoreStatusProvider.Status status1 = client.getBigSegmentStoreStatusProvider().getStatus(); + assertTrue(status1.isAvailable()); + + BlockingQueue statuses = new LinkedBlockingQueue<>(); + client.getBigSegmentStoreStatusProvider().addStatusListener(statuses::add); + + storeAvailable.set(false); + BigSegmentStoreStatusProvider.Status status = statuses.take(); + assertFalse(status.isAvailable()); + assertEquals(status, client.getBigSegmentStoreStatusProvider().getStatus()); + } + } } diff --git a/src/test/java/com/launchdarkly/sdk/server/LDConfigTest.java b/src/test/java/com/launchdarkly/sdk/server/LDConfigTest.java index 0d908b10a..0afba1f6b 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDConfigTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDConfigTest.java @@ -1,6 +1,9 @@ package com.launchdarkly.sdk.server; +import static com.launchdarkly.sdk.server.TestComponents.clientContext; + import com.google.common.collect.ImmutableMap; +import com.launchdarkly.sdk.server.integrations.BigSegmentsConfigurationBuilder; import com.launchdarkly.sdk.server.integrations.HttpConfigurationBuilder; import com.launchdarkly.sdk.server.integrations.LoggingConfigurationBuilder; import com.launchdarkly.sdk.server.interfaces.BasicConfiguration; @@ -28,6 +31,8 @@ public class LDConfigTest { @Test public void defaults() { LDConfig config = new LDConfig.Builder().build(); + assertNotNull(config.bigSegmentsConfigBuilder); + assertNull(config.bigSegmentsConfigBuilder.createBigSegmentsConfiguration(clientContext("", config)).getStore()); assertNotNull(config.dataSourceFactory); assertEquals(Components.streamingDataSource().getClass(), config.dataSourceFactory.getClass()); assertNotNull(config.dataStoreFactory); @@ -49,6 +54,13 @@ public void defaults() { assertEquals(LDConfig.DEFAULT_START_WAIT, config.startWait); assertEquals(Thread.MIN_PRIORITY, config.threadPriority); } + + @Test + public void bigSegmentsConfigFactory() { + BigSegmentsConfigurationBuilder f = Components.bigSegments(null); + LDConfig config = new LDConfig.Builder().bigSegments(f).build(); + assertSame(f, config.bigSegmentsConfigBuilder); + } @Test public void dataSourceFactory() { @@ -140,4 +152,4 @@ public void testHttpDefaults() { assertNull(hc.getTrustManager()); assertEquals(ImmutableMap.copyOf(defaults.getDefaultHeaders()), ImmutableMap.copyOf(hc.getDefaultHeaders())); } -} \ No newline at end of file +} diff --git a/src/test/java/com/launchdarkly/sdk/server/ModelBuilders.java b/src/test/java/com/launchdarkly/sdk/server/ModelBuilders.java index 6d03d9777..ef38b83c9 100644 --- a/src/test/java/com/launchdarkly/sdk/server/ModelBuilders.java +++ b/src/test/java/com/launchdarkly/sdk/server/ModelBuilders.java @@ -67,6 +67,10 @@ public static DataModel.Clause clauseMatchingUser(LDUser user) { public static DataModel.Clause clauseNotMatchingUser(LDUser user) { return clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of("not-" + user.getKey())); } + + public static DataModel.Clause clauseMatchingSegment(Segment segment) { + return clause(null, DataModel.Operator.segmentMatch, LDValue.of(segment.getKey())); + } public static DataModel.Target target(int variation, String... userKeys) { return new DataModel.Target(ImmutableSet.copyOf(userKeys), variation); @@ -290,6 +294,8 @@ public static class SegmentBuilder { private List rules = new ArrayList<>(); private int version = 0; private boolean deleted; + private boolean unbounded; + private Integer generation; private SegmentBuilder(String key) { this.key = key; @@ -306,7 +312,7 @@ private SegmentBuilder(DataModel.Segment from) { } public DataModel.Segment build() { - Segment s = new DataModel.Segment(key, included, excluded, salt, rules, version, deleted); + Segment s = new DataModel.Segment(key, included, excluded, salt, rules, version, deleted, unbounded, generation); s.afterDeserialized(); return s; } @@ -340,6 +346,16 @@ public SegmentBuilder deleted(boolean deleted) { this.deleted = deleted; return this; } + + public SegmentBuilder unbounded(boolean unbounded) { + this.unbounded = unbounded; + return this; + } + + public SegmentBuilder generation(Integer generation) { + this.generation = generation; + return this; + } } public static class SegmentRuleBuilder { diff --git a/src/test/java/com/launchdarkly/sdk/server/TestUtil.java b/src/test/java/com/launchdarkly/sdk/server/TestUtil.java index de9d52110..4cf89588e 100644 --- a/src/test/java/com/launchdarkly/sdk/server/TestUtil.java +++ b/src/test/java/com/launchdarkly/sdk/server/TestUtil.java @@ -21,6 +21,9 @@ import java.net.InetSocketAddress; import java.net.Socket; import java.net.SocketAddress; +import java.util.function.BiConsumer; +import java.util.function.Function; +import java.util.function.Supplier; import java.util.HashSet; import java.util.Set; import java.util.concurrent.BlockingQueue; @@ -37,6 +40,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; @SuppressWarnings("javadoc") public class TestUtil { @@ -184,4 +188,76 @@ public SocketFactorySingleHost(String host, int port) { return socket; } } + + public static void assertFullyEqual(T a, T b) { + assertEquals(a, b); + assertEquals(b, a); + assertEquals(a.hashCode(), b.hashCode()); + } + + public static void assertFullyUnequal(T a, T b) { + assertNotEquals(a, b); + assertNotEquals(b, a); + } + + public interface BuilderPropertyTester { + void assertDefault(TValue defaultValue); + void assertCanSet(TValue newValue); + void assertSetIsChangedTo(TValue attempted, TValue resulting); + } + + public static class BuilderTestUtil { + private final Supplier constructor; + final Function buildMethod; + + public BuilderTestUtil(Supplier constructor, + Function buildMethod) { + this.constructor = constructor; + this.buildMethod = buildMethod; + } + + public BuilderPropertyTester property( + Function getter, + BiConsumer setter) { + return new BuilderPropertyTestImpl(this, getter, setter); + } + + public TBuilder createBuilder() { + return constructor.get(); + } + } + + static class BuilderPropertyTestImpl + implements BuilderPropertyTester { + private final BuilderTestUtil owner; + private final Function getter; + private final BiConsumer setter; + + public BuilderPropertyTestImpl(BuilderTestUtil owner, + Function getter, + BiConsumer setter) { + this.owner = owner; + this.getter = getter; + this.setter = setter; + } + + public void assertDefault(TValue defaultValue) { + assertValue(owner.createBuilder(), defaultValue); + } + + public void assertCanSet(TValue newValue) { + assertSetIsChangedTo(newValue, newValue); + } + + public void assertSetIsChangedTo(TValue attempted, TValue resulting) { + TBuilder builder = owner.createBuilder(); + setter.accept(builder, attempted); + assertValue(builder, resulting); + } + + private void assertValue(TBuilder b, TValue expected) { + TBuilt built = owner.buildMethod.apply(b); + assertEquals(expected, getter.apply(built)); + } + } } diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/BigSegmentStoreTestBase.java b/src/test/java/com/launchdarkly/sdk/server/integrations/BigSegmentStoreTestBase.java new file mode 100644 index 000000000..e703f46e0 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/BigSegmentStoreTestBase.java @@ -0,0 +1,177 @@ +package com.launchdarkly.sdk.server.integrations; + +import static com.launchdarkly.sdk.server.TestComponents.clientContext; +import static com.launchdarkly.sdk.server.interfaces.BigSegmentStoreTypes.createMembershipFromSegmentRefs; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +import com.launchdarkly.sdk.server.LDConfig; +import com.launchdarkly.sdk.server.interfaces.BigSegmentStore; +import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreFactory; +import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreTypes.Membership; +import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreTypes.StoreMetadata; + +import org.junit.Assert; +import org.junit.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * A configurable test class for all implementations of {@link BigSegmentStore}. + *

      + * Each implementation of {@link BigSegmentStore} should define a test class that is a subclass of + * this class for their implementation type, and run it in the unit tests for their project. + *

      + * The tests are configured for the details specific to the implementation type by overriding the + * abstract methods {@link #makeStore(String)}, {@link #clearData(String)}, + * {@link #setMetadata(String, StoreMetadata)}, and {@link #setSegments(String, String, Iterable, Iterable)}. + */ +@SuppressWarnings("javadoc") +public abstract class BigSegmentStoreTestBase { + private static final String prefix = "testprefix"; + private static final String fakeUserHash = "userhash"; + private static final String segmentRef1 = "key1", segmentRef2 = "key2", segmentRef3 = "key3"; + private static final String[] allSegmentRefs = {segmentRef1, segmentRef2, segmentRef3}; + + private BigSegmentStore makeEmptyStore() throws Exception { + LDConfig config = new LDConfig.Builder().build(); + BigSegmentStore store = makeStore(prefix).createBigSegmentStore(clientContext("sdk-key", config)); + try { + clearData(prefix); + } catch (RuntimeException ex) { + store.close(); + throw ex; + } + return store; + } + + @Test + public void missingMetadata() throws Exception { + try (BigSegmentStore store = makeEmptyStore()) { + assertNull(store.getMetadata()); + } + } + + @Test + public void validMetadata() throws Exception { + try (BigSegmentStore store = makeEmptyStore()) { + StoreMetadata metadata = new StoreMetadata(System.currentTimeMillis()); + setMetadata(prefix, metadata); + + StoreMetadata result = store.getMetadata(); + assertNotNull(result); + assertEquals(metadata.getLastUpToDate(), result.getLastUpToDate()); + } + } + + @Test + public void membershipNotFound() throws Exception { + try (BigSegmentStore store = makeEmptyStore()) { + Membership membership = store.getMembership(fakeUserHash); + + // Either null or an empty membership is allowed + if (membership != null) { + assertEqualMembership(createMembershipFromSegmentRefs(null, null), membership); + } + } + } + + @Test + public void membershipFound() throws Exception { + List membershipsList = Arrays.asList( + new Memberships(Collections.singleton(segmentRef1), null), + new Memberships(Arrays.asList(segmentRef1, segmentRef2), null), + new Memberships(null, Collections.singleton(segmentRef1)), + new Memberships(null, Arrays.asList(segmentRef1, segmentRef2)), + new Memberships(Arrays.asList(segmentRef1, segmentRef2), Arrays.asList(segmentRef2, segmentRef3))); + + for (Memberships memberships : membershipsList) { + try (BigSegmentStore store = makeEmptyStore()) { + setSegments(prefix, fakeUserHash, memberships.inclusions, memberships.exclusions); + Membership membership = store.getMembership(fakeUserHash); + assertEqualMembership(createMembershipFromSegmentRefs(memberships.inclusions, memberships.exclusions), membership); + } + } + } + + private static class Memberships { + final Iterable inclusions; + final Iterable exclusions; + + Memberships(Iterable inclusions, Iterable exclusions) { + this.inclusions = inclusions == null ? Collections.emptyList() : inclusions; + this.exclusions = exclusions == null ? Collections.emptyList() : exclusions; + } + } + + private void assertEqualMembership(Membership expected, Membership actual) { + if (actual.getClass().getCanonicalName() + .startsWith("com.launchdarkly.sdk.server.interfaces.BigSegmentStoreTypes.MembershipBuilder")) { + // The store implementation is using our standard membership types, so we can rely on the + // standard equality test for those + assertEquals(expected, actual); + } else { + // The store implementation has implemented Membership some other way, so we have to check for + // the inclusion or exclusion of specific keys + for (String segmentRef : allSegmentRefs) { + Boolean expectedMembership = expected.checkMembership(segmentRef); + Boolean actualMembership = actual.checkMembership(segmentRef); + if (!Objects.equals(actualMembership, expectedMembership)) { + Assert.fail(String.format("expected membership for %s to be %s but was %s", + segmentRef, + expectedMembership == null ? "null" : expectedMembership.toString(), + actualMembership == null ? "null" : actualMembership.toString())); + } + } + } + } + + /** + * Test classes should override this method to return a configured factory for the subject + * implementation of {@link BigSegmentStore}. + *

      + * If the prefix string is {@code null} or the empty string, it should use the default prefix + * defined by the data store implementation. The factory must include any necessary configuration + * that may be appropriate for the test environment (for instance, pointing it to a database + * instance that has been set up for the tests). + * + * @param prefix the database prefix + * @return the configured factory + */ + protected abstract BigSegmentStoreFactory makeStore(String prefix); + + /** + * Test classes should override this method to clear all data from the underlying data store for + * the specified prefix string. + * + * @param prefix the database prefix + */ + protected abstract void clearData(String prefix); + + /** + * Test classes should override this method to update the store metadata for the given prefix in + * the underlying data store. + * + * @param prefix the database prefix + * @param metadata the data to write to the store + */ + protected abstract void setMetadata(String prefix, StoreMetadata metadata); + + /** + * Test classes should override this method to update the store metadata for the given prefix in + * the underlying data store. + * + * @param prefix the database prefix + * @param userHashKey the hashed user key + * @param includedSegmentRefs segment references to be included + * @param excludedSegmentRefs segment references to be excluded + */ + protected abstract void setSegments(String prefix, + String userHashKey, + Iterable includedSegmentRefs, + Iterable excludedSegmentRefs); +} diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/BigSegmentStoreTestBaseTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/BigSegmentStoreTestBaseTest.java new file mode 100644 index 000000000..6d5fa3225 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/BigSegmentStoreTestBaseTest.java @@ -0,0 +1,89 @@ +package com.launchdarkly.sdk.server.integrations; + +import static com.launchdarkly.sdk.server.interfaces.BigSegmentStoreTypes.createMembershipFromSegmentRefs; + +import com.launchdarkly.sdk.server.interfaces.BigSegmentStore; +import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreFactory; +import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreTypes.StoreMetadata; +import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreTypes.Membership; +import com.launchdarkly.sdk.server.interfaces.ClientContext; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +@SuppressWarnings("javadoc") +public class BigSegmentStoreTestBaseTest extends BigSegmentStoreTestBase { + // This runs BigSegmentStoreTestBase against a mock store implementation that is known to behave + // as expected, to verify that the test suite logic has the correct expectations. + + private static class DataSet { + StoreMetadata metadata = null; + Map memberships = new HashMap<>(); + } + + private final Map allData = new HashMap<>(); + + private DataSet getOrCreateDataSet(String prefix) { + allData.putIfAbsent(prefix, new DataSet()); + return allData.get(prefix); + } + + @Override + protected BigSegmentStoreFactory makeStore(String prefix) { + return new MockStoreFactory(getOrCreateDataSet(prefix)); + } + + @Override + protected void clearData(String prefix) { + DataSet dataSet = getOrCreateDataSet(prefix); + dataSet.metadata = null; + dataSet.memberships.clear(); + } + + @Override + protected void setMetadata(String prefix, StoreMetadata metadata) { + DataSet dataSet = getOrCreateDataSet(prefix); + dataSet.metadata = metadata; + } + + @Override + protected void setSegments(String prefix, String userHashKey, Iterable includedSegmentRefs, Iterable excludedSegmentRefs) { + DataSet dataSet = getOrCreateDataSet(prefix); + dataSet.memberships.put(userHashKey, createMembershipFromSegmentRefs(includedSegmentRefs, excludedSegmentRefs)); + } + + private static final class MockStoreFactory implements BigSegmentStoreFactory { + private final DataSet data; + + private MockStoreFactory(DataSet data) { + this.data = data; + } + + @Override + public BigSegmentStore createBigSegmentStore(ClientContext context) { + return new MockStore(data); + } + } + + private static final class MockStore implements BigSegmentStore { + private final DataSet data; + + private MockStore(DataSet data) { + this.data = data; + } + + @Override + public Membership getMembership(String userHash) { + return data.memberships.get(userHash); + } + + @Override + public StoreMetadata getMetadata() { + return data.metadata; + } + + @Override + public void close() throws IOException { } + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/BigSegmentsConfigurationBuilderTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/BigSegmentsConfigurationBuilderTest.java new file mode 100644 index 000000000..9a48af0e3 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/BigSegmentsConfigurationBuilderTest.java @@ -0,0 +1,87 @@ +package com.launchdarkly.sdk.server.integrations; + +import static com.launchdarkly.sdk.server.TestUtil.BuilderPropertyTester; +import static com.launchdarkly.sdk.server.TestUtil.BuilderTestUtil; +import static org.easymock.EasyMock.createStrictControl; +import static org.easymock.EasyMock.expectLastCall; +import static org.junit.Assert.assertSame; + +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.interfaces.BigSegmentStore; +import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreFactory; +import com.launchdarkly.sdk.server.interfaces.BigSegmentsConfiguration; +import com.launchdarkly.sdk.server.interfaces.ClientContext; + +import org.easymock.IMocksControl; +import org.junit.Test; + +import java.time.Duration; + +@SuppressWarnings("javadoc") +public class BigSegmentsConfigurationBuilderTest { + + private final BuilderTestUtil tester; + + public BigSegmentsConfigurationBuilderTest() { + tester = new BuilderTestUtil<>(() -> Components.bigSegments(null), + b -> b.createBigSegmentsConfiguration(null)); + } + + @Test + public void storeFactory() { + IMocksControl ctrl = createStrictControl(); + ClientContext contextMock = ctrl.createMock(ClientContext.class); + BigSegmentStore storeMock = ctrl.createMock(BigSegmentStore.class); + BigSegmentStoreFactory storeFactoryMock = ctrl.createMock(BigSegmentStoreFactory.class); + + storeFactoryMock.createBigSegmentStore(contextMock); + expectLastCall().andReturn(storeMock); + ctrl.replay(); + + BigSegmentsConfigurationBuilder b = Components.bigSegments(storeFactoryMock); + BigSegmentsConfiguration c = b.createBigSegmentsConfiguration(contextMock); + + assertSame(storeMock, c.getStore()); + ctrl.verify(); + } + + @Test + public void userCacheSize() { + BuilderPropertyTester prop = tester.property(BigSegmentsConfiguration::getUserCacheSize, + BigSegmentsConfigurationBuilder::userCacheSize); + prop.assertDefault(BigSegmentsConfigurationBuilder.DEFAULT_USER_CACHE_SIZE); + prop.assertCanSet(500); + prop.assertCanSet(0); + prop.assertSetIsChangedTo(-1, 0); + } + + @Test + public void userCacheTime() { + BuilderPropertyTester prop = tester.property(BigSegmentsConfiguration::getUserCacheTime, + BigSegmentsConfigurationBuilder::userCacheTime); + prop.assertDefault(BigSegmentsConfigurationBuilder.DEFAULT_USER_CACHE_TIME); + prop.assertCanSet(Duration.ofSeconds(10)); + prop.assertSetIsChangedTo(null, BigSegmentsConfigurationBuilder.DEFAULT_USER_CACHE_TIME); + prop.assertSetIsChangedTo(Duration.ofSeconds(-1), BigSegmentsConfigurationBuilder.DEFAULT_USER_CACHE_TIME); + } + + @Test + public void statusPollInterval() { + BuilderPropertyTester prop = tester.property(BigSegmentsConfiguration::getStatusPollInterval, + BigSegmentsConfigurationBuilder::statusPollInterval); + prop.assertDefault(BigSegmentsConfigurationBuilder.DEFAULT_STATUS_POLL_INTERVAL); + prop.assertCanSet(Duration.ofSeconds(10)); + prop.assertSetIsChangedTo(null, BigSegmentsConfigurationBuilder.DEFAULT_STATUS_POLL_INTERVAL); + prop.assertSetIsChangedTo(Duration.ofSeconds(-1), BigSegmentsConfigurationBuilder.DEFAULT_STATUS_POLL_INTERVAL); + } + + @Test + public void staleAfter() { + BuilderPropertyTester prop = tester.property(BigSegmentsConfiguration::getStaleAfter, + BigSegmentsConfigurationBuilder::staleAfter); + prop.assertDefault(BigSegmentsConfigurationBuilder.DEFAULT_STALE_AFTER); + prop.assertCanSet(Duration.ofSeconds(10)); + prop.assertSetIsChangedTo(null, BigSegmentsConfigurationBuilder.DEFAULT_STALE_AFTER); + prop.assertSetIsChangedTo(Duration.ofSeconds(-1), BigSegmentsConfigurationBuilder.DEFAULT_STALE_AFTER); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/interfaces/BigSegmentMembershipBuilderTest.java b/src/test/java/com/launchdarkly/sdk/server/interfaces/BigSegmentMembershipBuilderTest.java new file mode 100644 index 000000000..82cff2832 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/interfaces/BigSegmentMembershipBuilderTest.java @@ -0,0 +1,125 @@ +package com.launchdarkly.sdk.server.interfaces; + +import static com.launchdarkly.sdk.server.TestUtil.assertFullyEqual; +import static com.launchdarkly.sdk.server.TestUtil.assertFullyUnequal; +import static com.launchdarkly.sdk.server.interfaces.BigSegmentStoreTypes.createMembershipFromSegmentRefs; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreTypes.Membership; + +import org.junit.Test; + +import java.util.Arrays; +import java.util.Collections; + +@SuppressWarnings("javadoc") +public class BigSegmentMembershipBuilderTest { + + // MembershipBuilder is private to BigSegmentStoreTypes, we test it through + // createMembershipFromSegmentRefs + + @Test + public void empty() { + Membership m0 = createMembershipFromSegmentRefs(null, null); + Membership m1 = createMembershipFromSegmentRefs(Collections.emptyList(), null); + Membership m2 = createMembershipFromSegmentRefs(null, Collections.emptyList()); + + assertSame(m0, m1); + assertSame(m0, m2); + assertFullyEqual(m0, m1); + + assertNull(m0.checkMembership("arbitrary")); + } + + @Test + public void singleInclusion() { + Membership m0 = createMembershipFromSegmentRefs(Collections.singleton("key1"), null); + Membership m1 = createMembershipFromSegmentRefs(Collections.singleton("key1"), null); + + assertNotSame(m0, m1); + assertFullyEqual(m0, m1); + + assertTrue(m0.checkMembership("key1")); + assertNull(m0.checkMembership("key2")); + + assertFullyUnequal(m0, createMembershipFromSegmentRefs(null, Collections.singleton("key1"))); + assertFullyUnequal(m0, createMembershipFromSegmentRefs(Collections.singleton("key2"), null)); + assertFullyUnequal(m0, createMembershipFromSegmentRefs(null, null)); + } + + @Test + public void multipleInclusions() { + Membership m0 = createMembershipFromSegmentRefs(Arrays.asList("key1", "key2"), null); + Membership m1 = createMembershipFromSegmentRefs(Arrays.asList("key2", "key1"), null); + + assertNotSame(m0, m1); + assertFullyEqual(m0, m1); + + assertTrue(m0.checkMembership("key1")); + assertTrue(m0.checkMembership("key2")); + assertNull(m0.checkMembership("key3")); + + assertFullyUnequal(m0, createMembershipFromSegmentRefs(Arrays.asList("key1", "key2"), Collections.singleton("key3"))); + assertFullyUnequal(m0, createMembershipFromSegmentRefs(Arrays.asList("key1", "key3"), null)); + assertFullyUnequal(m0, createMembershipFromSegmentRefs(Collections.singleton("key1"), null)); + assertFullyUnequal(m0, createMembershipFromSegmentRefs(null, null)); + } + + @Test + public void singleExclusion() { + Membership m0 = createMembershipFromSegmentRefs(null, Collections.singleton("key1")); + Membership m1 = createMembershipFromSegmentRefs(null, Collections.singleton("key1")); + + assertNotSame(m0, m1); + assertFullyEqual(m0, m1); + + assertFalse(m0.checkMembership("key1")); + assertNull(m0.checkMembership("key2")); + + assertFullyUnequal(m0, createMembershipFromSegmentRefs(Collections.singleton("key1"), null)); + assertFullyUnequal(m0, createMembershipFromSegmentRefs(null, Collections.singleton("key2"))); + assertFullyUnequal(m0, createMembershipFromSegmentRefs(null, null)); + } + + @Test + public void multipleExclusions() { + Membership m0 = createMembershipFromSegmentRefs(null, Arrays.asList("key1", "key2")); + Membership m1 = createMembershipFromSegmentRefs(null, Arrays.asList("key2", "key1")); + + assertNotSame(m0, m1); + assertFullyEqual(m0, m1); + + assertFalse(m0.checkMembership("key1")); + assertFalse(m0.checkMembership("key2")); + assertNull(m0.checkMembership("key3")); + + assertFullyUnequal(m0, createMembershipFromSegmentRefs(Collections.singleton("key3"), Arrays.asList("key1", "key2"))); + assertFullyUnequal(m0, createMembershipFromSegmentRefs(null, Arrays.asList("key1", "key3"))); + assertFullyUnequal(m0, createMembershipFromSegmentRefs(null, Collections.singleton("key1"))); + assertFullyUnequal(m0, createMembershipFromSegmentRefs(null, null)); + } + + @Test + public void inclusionsAndExclusions() { + // key1 is included; key2 is included and excluded, therefore it's included; key3 is excluded + Membership m0 = createMembershipFromSegmentRefs(Arrays.asList("key1", "key2"), Arrays.asList("key2", "key3")); + Membership m1 = createMembershipFromSegmentRefs(Arrays.asList("key2", "key1"), Arrays.asList("key3", "key2")); + + assertNotSame(m0, m1); + assertFullyEqual(m0, m1); + + assertTrue(m0.checkMembership("key1")); + assertTrue(m0.checkMembership("key2")); + assertFalse(m0.checkMembership("key3")); + assertNull(m0.checkMembership("key4")); + + assertFullyUnequal(m0, createMembershipFromSegmentRefs(Arrays.asList("key1", "key2"), Arrays.asList("key2", "key3", "key4"))); + assertFullyUnequal(m0, createMembershipFromSegmentRefs(Arrays.asList("key1", "key2", "key3"), Arrays.asList("key2", "key3"))); + assertFullyUnequal(m0, createMembershipFromSegmentRefs(Collections.singleton("key1"), null)); + assertFullyUnequal(m0, createMembershipFromSegmentRefs(null, null)); + } +} From 8a6814228d85c9e6cc75116f695e60e8fb58422d Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Fri, 4 Feb 2022 13:06:29 -0600 Subject: [PATCH 619/673] Fix for pom including SDK common library as a dependency. (#317) --- build.gradle | 7 ++----- contract-tests/service/build.gradle | 4 +++- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/build.gradle b/build.gradle index e57b2f1ff..d5b1fbc25 100644 --- a/build.gradle +++ b/build.gradle @@ -108,6 +108,7 @@ ext.versions = [ // Jackson in "libraries.optional" because we need to generate OSGi optional import // headers for it. libraries.internal = [ + "com.launchdarkly:launchdarkly-java-sdk-common:${versions.launchdarklyJavaSdkCommon}", "commons-codec:commons-codec:${versions.commonsCodec}", "com.google.code.gson:gson:${versions.gson}", "com.google.guava:guava:${versions.guava}", @@ -116,10 +117,6 @@ libraries.internal = [ "org.yaml:snakeyaml:${versions.snakeyaml}", ] -libraries.common = [ - "com.launchdarkly:launchdarkly-java-sdk-common:${versions.launchdarklyJavaSdkCommon}", -] - // 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. Putting dependencies // here has the following effects: @@ -179,7 +176,7 @@ configurations { dependencies { implementation libraries.internal - api libraries.external, libraries.common + api libraries.external testImplementation libraries.test, libraries.internal, libraries.external optional libraries.optional diff --git a/contract-tests/service/build.gradle b/contract-tests/service/build.gradle index 4ae987dcf..634428f64 100644 --- a/contract-tests/service/build.gradle +++ b/contract-tests/service/build.gradle @@ -24,7 +24,8 @@ ext.versions = [ "gson": "2.7", "logback": "1.1.3", "okhttp": "4.5.0", - "testHelpers": "1.1.0" + "testHelpers": "1.1.0", + "launchdarklyJavaSdkCommon": project(":sdk").versions["launchdarklyJavaSdkCommon"] ] configurations { @@ -33,6 +34,7 @@ configurations { dependencies { implementation project(":sdk") + implementation "com.launchdarkly:launchdarkly-java-sdk-common:${versions.launchdarklyJavaSdkCommon}" implementation "ch.qos.logback:logback-classic:${versions.logback}" implementation "com.google.code.gson:gson:${versions.gson}" implementation "com.squareup.okhttp3:okhttp:${versions.okhttp}" From ee088db2bc1e151daa070a9e33eff287535eb153 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 14 Feb 2022 17:06:41 -0800 Subject: [PATCH 620/673] use new logging API --- .../server/LDClientEvaluationBenchmarks.java | 1 + build.gradle | 2 + .../sdk/server/BigSegmentStoreWrapper.java | 10 +- .../sdk/server/ClientContextImpl.java | 39 ++++++- .../launchdarkly/sdk/server/Components.java | 41 ++++++- .../sdk/server/ComponentsImpl.java | 62 +++++++---- .../sdk/server/DataSourceUpdatesImpl.java | 14 ++- .../sdk/server/DefaultEventProcessor.java | 51 +++++---- .../sdk/server/DefaultEventSender.java | 28 +++-- .../sdk/server/DefaultFeatureRequestor.java | 20 ++-- .../launchdarkly/sdk/server/Evaluator.java | 9 +- .../sdk/server/EventBroadcasterImpl.java | 36 ++++--- .../com/launchdarkly/sdk/server/LDClient.java | 102 ++++++++++-------- .../com/launchdarkly/sdk/server/LDConfig.java | 2 +- .../com/launchdarkly/sdk/server/Loggers.java | 43 +------- .../sdk/server/LoggingConfigurationImpl.java | 23 +++- .../PersistentDataStoreStatusManager.java | 14 +-- .../server/PersistentDataStoreWrapper.java | 19 ++-- .../sdk/server/PollingProcessor.java | 13 +-- .../sdk/server/StreamProcessor.java | 58 +++++++--- .../com/launchdarkly/sdk/server/Util.java | 5 +- .../integrations/FileDataSourceBuilder.java | 10 +- .../integrations/FileDataSourceImpl.java | 25 ++--- .../LoggingConfigurationBuilder.java | 100 +++++++++++++++++ .../sdk/server/interfaces/ClientContext.java | 5 + .../server/interfaces/EventSenderFactory.java | 9 ++ .../interfaces/LoggingConfiguration.java | 6 ++ .../com/launchdarkly/sdk/server/BaseTest.java | 49 +++++++++ ...BigSegmentStoreStatusProviderImplTest.java | 21 ++-- .../server/BigSegmentStoreWrapperTest.java | 46 ++++---- .../DataSourceStatusProviderImplTest.java | 7 +- .../sdk/server/DataSourceUpdatesImplTest.java | 23 ++-- .../DataStoreStatusProviderImplTest.java | 4 +- .../sdk/server/DataStoreUpdatesImplTest.java | 4 +- .../DefaultEventProcessorDiagnosticsTest.java | 4 +- .../sdk/server/DefaultEventProcessorTest.java | 5 +- .../server/DefaultEventProcessorTestBase.java | 18 ++-- .../sdk/server/DefaultEventSenderTest.java | 11 +- .../server/DefaultFeatureRequestorTest.java | 12 +-- .../sdk/server/EvaluatorBigSegmentTest.java | 23 ++-- .../sdk/server/EvaluatorClauseTest.java | 3 +- .../sdk/server/EvaluatorSegmentMatchTest.java | 5 +- .../sdk/server/EvaluatorTest.java | 3 +- .../sdk/server/EvaluatorTestBase.java | 10 ++ .../sdk/server/EvaluatorTestUtil.java | 51 +++++---- .../sdk/server/EventBroadcasterImplTest.java | 6 +- .../sdk/server/FlagTrackerImplTest.java | 6 +- .../sdk/server/LDClientBigSegmentsTest.java | 18 ++-- .../sdk/server/LDClientEndToEndTest.java | 22 ++-- .../sdk/server/LDClientEvaluationTest.java | 24 ++--- .../sdk/server/LDClientEventTest.java | 10 +- .../LDClientExternalUpdatesOnlyTest.java | 18 +--- .../sdk/server/LDClientListenersTest.java | 36 +++---- .../sdk/server/LDClientOfflineTest.java | 12 +-- .../launchdarkly/sdk/server/LDClientTest.java | 93 ++++++++-------- .../PersistentDataStoreWrapperOtherTest.java | 5 +- .../PersistentDataStoreWrapperTest.java | 15 +-- .../sdk/server/PollingProcessorTest.java | 10 +- .../sdk/server/StreamProcessorTest.java | 6 +- .../sdk/server/TestComponents.java | 13 ++- .../launchdarkly/sdk/server/TestHttpUtil.java | 12 +-- .../FileDataSourceAutoUpdateTest.java | 5 +- .../integrations/FileDataSourceTest.java | 5 +- 63 files changed, 863 insertions(+), 499 deletions(-) create mode 100644 src/test/java/com/launchdarkly/sdk/server/BaseTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/EvaluatorTestBase.java 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 a8d1f5e44..8f389caa6 100644 --- a/benchmarks/src/jmh/java/com/launchdarkly/sdk/server/LDClientEvaluationBenchmarks.java +++ b/benchmarks/src/jmh/java/com/launchdarkly/sdk/server/LDClientEvaluationBenchmarks.java @@ -54,6 +54,7 @@ public BenchmarkInputs() { .dataStore(specificDataStore(dataStore)) .events(Components.noEvents()) .dataSource(Components.externalUpdatesOnly()) + .logging(Components.noLogging()) .build(); client = new LDClient(SDK_KEY, config); diff --git a/build.gradle b/build.gradle index d5b1fbc25..167e52f81 100644 --- a/build.gradle +++ b/build.gradle @@ -73,6 +73,7 @@ ext.versions = [ "guava": "30.1-jre", "jackson": "2.11.2", "launchdarklyJavaSdkCommon": "1.3.0", + "launchdarklyLogging": "0.0.1-SNAPSHOT", "okhttp": "4.8.1", // specify this for the SDK build instead of relying on the transitive dependency from okhttp-eventsource "okhttpEventsource": "2.3.2", "slf4j": "1.7.21", @@ -131,6 +132,7 @@ libraries.internal = [ // also as package exports (i.e. it provides them if a newer version is not available // from an import). libraries.external = [ + "com.launchdarkly:launchdarkly-logging:${versions.launchdarklyLogging}", "org.slf4j:slf4j-api:${versions.slf4j}" ] diff --git a/src/main/java/com/launchdarkly/sdk/server/BigSegmentStoreWrapper.java b/src/main/java/com/launchdarkly/sdk/server/BigSegmentStoreWrapper.java index bfd01ddac..3a6d14451 100644 --- a/src/main/java/com/launchdarkly/sdk/server/BigSegmentStoreWrapper.java +++ b/src/main/java/com/launchdarkly/sdk/server/BigSegmentStoreWrapper.java @@ -5,6 +5,7 @@ import com.google.common.cache.CacheBuilder; import com.google.common.cache.CacheLoader; import com.google.common.cache.LoadingCache; +import com.launchdarkly.logging.LDLogger; import com.launchdarkly.sdk.EvaluationReason.BigSegmentsStatus; import com.launchdarkly.sdk.server.interfaces.BigSegmentStore; import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreStatusProvider.Status; @@ -15,7 +16,6 @@ import org.apache.commons.codec.digest.DigestUtils; import org.checkerframework.checker.nullness.qual.NonNull; -import org.slf4j.Logger; import java.io.Closeable; import java.io.IOException; @@ -27,23 +27,23 @@ import java.util.concurrent.TimeUnit; class BigSegmentStoreWrapper implements Closeable { - private static final Logger logger = Loggers.BIG_SEGMENTS; - private final BigSegmentStore store; private final Duration staleAfter; private final ScheduledFuture pollFuture; private final LoadingCache cache; private final EventBroadcasterImpl statusProvider; - + private final LDLogger logger; private final Object statusLock = new Object(); private Status lastStatus; BigSegmentStoreWrapper(BigSegmentsConfiguration config, EventBroadcasterImpl statusProvider, - ScheduledExecutorService sharedExecutor) { + ScheduledExecutorService sharedExecutor, + LDLogger logger) { this.store = config.getStore(); this.staleAfter = config.getStaleAfter(); this.statusProvider = statusProvider; + this.logger = logger; CacheLoader loader = new CacheLoader() { @Override diff --git a/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java b/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java index 1cdf659fe..572fa455f 100644 --- a/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java @@ -1,5 +1,7 @@ package com.launchdarkly.sdk.server; +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.logging.Logs; import com.launchdarkly.sdk.server.interfaces.BasicConfiguration; import com.launchdarkly.sdk.server.interfaces.ClientContext; import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; @@ -19,12 +21,13 @@ * implementation of {@link ClientContext}, which might have been created for instance in application * test code). */ -final class ClientContextImpl implements ClientContext { +final class ClientContextImpl implements ClientContext, ClientContext.WithBaseLogger { private static volatile ScheduledExecutorService fallbackSharedExecutor = null; private final BasicConfiguration basicConfiguration; private final HttpConfiguration httpConfiguration; private final LoggingConfiguration loggingConfiguration; + private final LDLogger baseLogger; final ScheduledExecutorService sharedExecutor; final DiagnosticAccumulator diagnosticAccumulator; final DiagnosticEvent.Init diagnosticInitEvent; @@ -35,7 +38,8 @@ private ClientContextImpl( LoggingConfiguration loggingConfiguration, ScheduledExecutorService sharedExecutor, DiagnosticAccumulator diagnosticAccumulator, - DiagnosticEvent.Init diagnosticInitEvent + DiagnosticEvent.Init diagnosticInitEvent, + LDLogger baseLogger ) { this.basicConfiguration = basicConfiguration; this.httpConfiguration = httpConfiguration; @@ -43,6 +47,7 @@ private ClientContextImpl( this.sharedExecutor = sharedExecutor; this.diagnosticAccumulator = diagnosticAccumulator; this.diagnosticInitEvent = diagnosticInitEvent; + this.baseLogger = baseLogger; } ClientContextImpl( @@ -56,6 +61,19 @@ private ClientContextImpl( this.httpConfiguration = configuration.httpConfigFactory.createHttpConfiguration(basicConfiguration); this.loggingConfiguration = configuration.loggingConfigFactory.createLoggingConfiguration(basicConfiguration); + if (this.loggingConfiguration instanceof LoggingConfiguration.AdapterOptions) { + LoggingConfiguration.AdapterOptions ao = (LoggingConfiguration.AdapterOptions)this.loggingConfiguration; + this.baseLogger = LDLogger.withAdapter(ao.getLogAdapter(), ao.getBaseLoggerName()); + } else { + this.baseLogger = makeDefaultLogger(basicConfiguration); + } + + if (this.httpConfiguration.getProxy() != null) { + this.baseLogger.info("Using proxy: {} {} authentication.", + this.httpConfiguration.getProxy(), + this.httpConfiguration.getProxyAuthentication() == null ? "without" : "with"); + } + this.sharedExecutor = sharedExecutor; if (!configuration.diagnosticOptOut && diagnosticAccumulator != null) { @@ -88,6 +106,20 @@ public LoggingConfiguration getLogging() { return loggingConfiguration; } + @Override + public LDLogger getBaseLogger() { + return baseLogger; + } + + private static LDLogger makeDefaultLogger(BasicConfiguration basicConfiguration) { + LoggingConfiguration lc = Components.logging().createLoggingConfiguration(basicConfiguration); + if (lc instanceof LoggingConfiguration.AdapterOptions) { + LoggingConfiguration.AdapterOptions ao = (LoggingConfiguration.AdapterOptions)lc; + return LDLogger.withAdapter(ao.getLogAdapter(), ao.getBaseLoggerName()); + } + return LDLogger.withAdapter(Logs.none(), ""); + } + /** * This mechanism is a convenience for internal components to access the package-private fields of the * context if it is a ClientContextImpl, and to receive null values for those fields if it is not. @@ -110,7 +142,8 @@ static ClientContextImpl get(ClientContext context) { context.getLogging(), fallbackSharedExecutor, null, - null + null, + makeDefaultLogger(context.getBasic()) ); } } diff --git a/src/main/java/com/launchdarkly/sdk/server/Components.java b/src/main/java/com/launchdarkly/sdk/server/Components.java index bede125f5..d57a1be2a 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Components.java +++ b/src/main/java/com/launchdarkly/sdk/server/Components.java @@ -2,6 +2,8 @@ import static com.launchdarkly.sdk.server.ComponentsImpl.NULL_EVENT_PROCESSOR_FACTORY; +import com.launchdarkly.logging.LDLogAdapter; +import com.launchdarkly.logging.Logs; import com.launchdarkly.sdk.server.ComponentsImpl.EventProcessorBuilderImpl; import com.launchdarkly.sdk.server.ComponentsImpl.HttpBasicAuthentication; import com.launchdarkly.sdk.server.ComponentsImpl.HttpConfigurationBuilderImpl; @@ -313,11 +315,48 @@ public static HttpAuthentication httpBasicAuthentication(String username, String * .build(); * * - * @return a factory object + * @return a configuration builder * @since 5.0.0 * @see LDConfig.Builder#logging(com.launchdarkly.sdk.server.interfaces.LoggingConfigurationFactory) */ public static LoggingConfigurationBuilder logging() { return new LoggingConfigurationBuilderImpl(); } + + /** + * Returns a configuration builder for the SDK's logging configuration. + *

      + * Passing this to {@link LDConfig.Builder#logging(com.launchdarkly.sdk.server.interfaces.LoggingConfigurationFactory)}, + * after setting any desired properties on the builder, applies this configuration to the SDK. + *

      
      +   *     LDConfig config = new LDConfig.Builder()
      +   *         .logging(
      +   *              Components.logging()
      +   *                  .logDataSourceOutageAsErrorAfter(Duration.ofSeconds(120))
      +   *         )
      +   *         .build();
      +   * 
      + * + * @return a configuration builder + * @since 5.8.0 + * @see LDConfig.Builder#logging(com.launchdarkly.sdk.server.interfaces.LoggingConfigurationFactory) + */ + public static LoggingConfigurationBuilder logging(LDLogAdapter logAdapter) { + return logging().adapter(logAdapter); + } + + /** + * Returns a configuration builder that turns off SDK logging. + *

      + * Passing this to {@link LDConfig.Builder#logging(com.launchdarkly.sdk.server.interfaces.LoggingConfigurationFactory)} + * applies this configuration to the SDK. + *

      + * It is equivalent to Components.logging(com.launchdarkly.logging.Logs.none()). + * + * @return a configuration builder + * @since 5.8.0 + */ + public static LoggingConfigurationBuilder noLogging() { + return logging().adapter(Logs.none()); + } } diff --git a/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java b/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java index 33ddaa4fb..421a4d92c 100644 --- a/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java @@ -1,6 +1,11 @@ package com.launchdarkly.sdk.server; import com.google.common.collect.ImmutableMap; +import com.launchdarkly.logging.LDLogAdapter; +import com.launchdarkly.logging.LDLogLevel; +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.logging.LDSLF4J; +import com.launchdarkly.logging.Logs; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.server.DiagnosticEvent.ConfigProperty; import com.launchdarkly.sdk.server.integrations.EventProcessorBuilder; @@ -23,6 +28,7 @@ import com.launchdarkly.sdk.server.interfaces.EventProcessor; import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; import com.launchdarkly.sdk.server.interfaces.EventSender; +import com.launchdarkly.sdk.server.interfaces.EventSenderFactory; import com.launchdarkly.sdk.server.interfaces.HttpAuthentication; import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; import com.launchdarkly.sdk.server.interfaces.LoggingConfiguration; @@ -87,12 +93,13 @@ static final class NullDataSourceFactory implements DataSourceFactory, Diagnosti @Override public DataSource createDataSource(ClientContext context, DataSourceUpdates dataSourceUpdates) { + LDLogger logger = ClientContextImpl.get(context).getBaseLogger(); if (context.getBasic().isOffline()) { // If they have explicitly called offline(true) to disable everything, we'll log this slightly // more specific message. - Loggers.MAIN.info("Starting LaunchDarkly client in offline mode"); + logger.info("Starting LaunchDarkly client in offline mode"); } else { - Loggers.MAIN.info("LaunchDarkly client will not connect to Launchdarkly for feature flag data"); + logger.info("LaunchDarkly client will not connect to Launchdarkly for feature flag data"); } dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.VALID, null); return NullDataSource.INSTANCE; @@ -135,9 +142,8 @@ static final class StreamingDataSourceBuilderImpl extends StreamingDataSourceBui implements DiagnosticDescription { @Override public DataSource createDataSource(ClientContext context, DataSourceUpdates dataSourceUpdates) { - // Note, we log startup messages under the LDClient class to keep logs more readable - - Loggers.DATA_SOURCE.info("Enabling streaming API"); + LDLogger logger = ClientContextImpl.get(context).getBaseLogger().subLogger(Loggers.DATA_SOURCE_LOGGER_NAME); + logger.info("Enabling streaming API"); URI streamUri = baseURI == null ? LDConfig.DEFAULT_STREAM_URI : baseURI; @@ -147,7 +153,8 @@ public DataSource createDataSource(ClientContext context, DataSourceUpdates data context.getBasic().getThreadPriority(), ClientContextImpl.get(context).diagnosticAccumulator, streamUri, - initialReconnectDelay + initialReconnectDelay, + logger ); } @@ -173,20 +180,22 @@ PollingDataSourceBuilderImpl pollIntervalWithNoMinimum(Duration pollInterval) { @Override public DataSource createDataSource(ClientContext context, DataSourceUpdates dataSourceUpdates) { - // Note, we log startup messages under the LDClient class to keep logs more readable + LDLogger logger = ClientContextImpl.get(context).getBaseLogger().subLogger(Loggers.DATA_SOURCE_LOGGER_NAME); - Loggers.DATA_SOURCE.info("Disabling streaming API"); - Loggers.DATA_SOURCE.warn("You should only disable the streaming API if instructed to do so by LaunchDarkly support"); + logger.info("Disabling streaming API"); + logger.warn("You should only disable the streaming API if instructed to do so by LaunchDarkly support"); DefaultFeatureRequestor requestor = new DefaultFeatureRequestor( context.getHttp(), - baseURI == null ? LDConfig.DEFAULT_BASE_URI : baseURI + baseURI == null ? LDConfig.DEFAULT_BASE_URI : baseURI, + logger ); return new PollingProcessor( requestor, dataSourceUpdates, ClientContextImpl.get(context).sharedExecutor, - pollInterval + pollInterval, + logger ); } @@ -207,9 +216,16 @@ static final class EventProcessorBuilderImpl extends EventProcessorBuilder implements DiagnosticDescription { @Override public EventProcessor createEventProcessor(ClientContext context) { + LDLogger logger = ClientContextImpl.get(context).getBaseLogger(); + EventSenderFactory senderFactory = + eventSenderFactory == null ? new DefaultEventSender.Factory() : eventSenderFactory; EventSender eventSender = - (eventSenderFactory == null ? new DefaultEventSender.Factory() : eventSenderFactory) - .createEventSender(context.getBasic(), context.getHttp()); + (senderFactory instanceof EventSenderFactory.WithLogger) ? + ((EventSenderFactory.WithLogger)senderFactory).createEventSender( + context.getBasic(), + context.getHttp(), + logger) : + senderFactory.createEventSender(context.getBasic(), context.getHttp()); return new DefaultEventProcessor( new EventsConfiguration( allAttributesPrivate, @@ -226,7 +242,8 @@ public EventProcessor createEventProcessor(ClientContext context) { ClientContextImpl.get(context).sharedExecutor, context.getBasic().getThreadPriority(), ClientContextImpl.get(context).diagnosticAccumulator, - ClientContextImpl.get(context).diagnosticInitEvent + ClientContextImpl.get(context).diagnosticInitEvent, + logger ); } @@ -259,9 +276,6 @@ public HttpConfiguration createHttpConfiguration(BasicConfiguration basicConfigu } Proxy proxy = proxyHost == null ? null : new Proxy(Proxy.Type.HTTP, new InetSocketAddress(proxyHost, proxyPort)); - if (proxy != null) { - Loggers.MAIN.info("Using proxy: {} {} authentication.", proxy, proxyAuth == null ? "without" : "with"); - } return new HttpConfigurationImpl( connectTimeout, @@ -316,7 +330,8 @@ public DataStore createDataStore(ClientContext context, DataStoreUpdates dataSto staleValuesPolicy, recordCacheStats, dataStoreUpdates, - ClientContextImpl.get(context).sharedExecutor + ClientContextImpl.get(context).sharedExecutor, + ClientContextImpl.get(context).getBaseLogger().subLogger(Loggers.DATA_STORE_LOGGER_NAME) ); } } @@ -324,7 +339,16 @@ public DataStore createDataStore(ClientContext context, DataStoreUpdates dataSto static final class LoggingConfigurationBuilderImpl extends LoggingConfigurationBuilder { @Override public LoggingConfiguration createLoggingConfiguration(BasicConfiguration basicConfiguration) { - return new LoggingConfigurationImpl(logDataSourceOutageAsErrorAfter); + LDLogAdapter adapter = logAdapter == null ? LDSLF4J.adapter() : logAdapter; + LDLogAdapter filteredAdapter; + if (adapter == LDSLF4J.adapter() || adapter == Logs.toJavaUtilLogging()) { + filteredAdapter = adapter; // ignore level setting because these frameworks have their own config + } else { + filteredAdapter = Logs.level(adapter, + minimumLevel == null ? LDLogLevel.INFO : minimumLevel); + } + String name = baseName == null ? Loggers.BASE_LOGGER_NAME : baseName; + return new LoggingConfigurationImpl(name, filteredAdapter, logDataSourceOutageAsErrorAfter); } } } diff --git a/src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java b/src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java index 9d95b2708..e681e5147 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/DataSourceUpdatesImpl.java @@ -3,6 +3,8 @@ import com.google.common.base.Joiner; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.logging.LogValues; import com.launchdarkly.sdk.server.DataModelDependencies.KindAndKey; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorInfo; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorKind; @@ -54,6 +56,7 @@ final class DataSourceUpdatesImpl implements DataSourceUpdates { private final DataStoreStatusProvider dataStoreStatusProvider; private final OutageTracker outageTracker; private final Object stateLock = new Object(); + private final LDLogger logger; private volatile Status currentStatus; private volatile boolean lastStoreUpdateFailed = false; @@ -65,13 +68,15 @@ final class DataSourceUpdatesImpl implements DataSourceUpdates { EventBroadcasterImpl flagChangeEventNotifier, EventBroadcasterImpl dataSourceStatusNotifier, ScheduledExecutorService sharedExecutor, - Duration outageLoggingTimeout + Duration outageLoggingTimeout, + LDLogger baseLogger ) { this.store = store; this.flagChangeEventNotifier = flagChangeEventNotifier; this.dataSourceStatusNotifier = dataSourceStatusNotifier; this.dataStoreStatusProvider = dataStoreStatusProvider; this.outageTracker = new OutageTracker(sharedExecutor, outageLoggingTimeout); + this.logger = baseLogger.subLogger(Loggers.DATA_SOURCE_LOGGER_NAME); currentStatus = new Status(State.INITIALIZING, Instant.now(), null); } @@ -267,10 +272,11 @@ private Set computeChangedItemsForFullDataSet(Map inbox; private final ScheduledExecutorService scheduler; private final AtomicBoolean closed = new AtomicBoolean(false); private final List> scheduledTasks = new ArrayList<>(); private volatile boolean inputCapacityExceeded = false; + private final LDLogger logger; DefaultEventProcessor( EventsConfiguration eventsConfig, ScheduledExecutorService sharedExecutor, int threadPriority, DiagnosticAccumulator diagnosticAccumulator, - DiagnosticEvent.Init diagnosticInitEvent + DiagnosticEvent.Init diagnosticInitEvent, + LDLogger logger ) { inbox = new ArrayBlockingQueue<>(eventsConfig.capacity); scheduler = sharedExecutor; - + this.logger = logger; + dispatcher = new EventDispatcher( eventsConfig, sharedExecutor, @@ -55,7 +56,8 @@ final class DefaultEventProcessor implements EventProcessor { inbox, closed, diagnosticAccumulator, - diagnosticInitEvent + diagnosticInitEvent, + logger ); Runnable flusher = () -> { @@ -205,7 +207,8 @@ static final class EventDispatcher { @VisibleForTesting final DiagnosticAccumulator diagnosticAccumulator; private final ExecutorService sharedExecutor; private final SendDiagnosticTaskFactory sendDiagnosticTaskFactory; - + private final LDLogger logger; + private long deduplicatedUsers = 0; private EventDispatcher( @@ -215,7 +218,8 @@ private EventDispatcher( BlockingQueue inbox, AtomicBoolean closed, DiagnosticAccumulator diagnosticAccumulator, - DiagnosticEvent.Init diagnosticInitEvent + DiagnosticEvent.Init diagnosticInitEvent, + LDLogger logger ) { this.eventsConfig = eventsConfig; this.inbox = inbox; @@ -223,7 +227,8 @@ private EventDispatcher( this.sharedExecutor = sharedExecutor; this.diagnosticAccumulator = diagnosticAccumulator; this.busyFlushWorkersCount = new AtomicInteger(0); - + this.logger = logger; + ThreadFactory threadFactory = new ThreadFactoryBuilder() .setDaemon(true) .setNameFormat("LaunchDarkly-event-delivery-%d") @@ -235,7 +240,7 @@ private EventDispatcher( // all the workers are busy. final BlockingQueue payloadQueue = new ArrayBlockingQueue<>(1); - final EventBuffer outbox = new EventBuffer(eventsConfig.capacity); + final EventBuffer outbox = new EventBuffer(eventsConfig.capacity, logger); final SimpleLRUCache userKeys = new SimpleLRUCache(eventsConfig.userKeysCapacity); Thread mainThread = threadFactory.newThread(() -> { @@ -255,7 +260,8 @@ private EventDispatcher( listener, payloadQueue, busyFlushWorkersCount, - threadFactory + threadFactory, + logger ); flushWorkers.add(task); } @@ -276,7 +282,9 @@ private void onUncaughtException(Thread thread, Throwable e) { // application won't end up blocking on a queue that's no longer being consumed. // COVERAGE: there is no way to make this happen from test code. - logger.error("Event processor thread was terminated by an unrecoverable error. No more analytics events will be sent.", e); + logger.error("Event processor thread was terminated by an unrecoverable error. No more analytics events will be sent. {} {}", + LogValues.exceptionSummary(e), LogValues.exceptionTrace(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 @@ -353,8 +361,8 @@ private void doShutdown() { try { eventsConfig.eventSender.close(); } catch (IOException e) { - logger.error("Unexpected error when closing event sender: {}", e.toString()); - logger.debug(e.toString(), e); + logger.error("Unexpected error when closing event sender: {}", LogValues.exceptionSummary(e)); + logger.debug(LogValues.exceptionTrace(e)); } } @@ -481,11 +489,13 @@ private static final class EventBuffer { final List events = new ArrayList<>(); final EventSummarizer summarizer = new EventSummarizer(); private final int capacity; + private final LDLogger logger; private boolean capacityExceeded = false; private long droppedEventCount = 0; - EventBuffer(int capacity) { + EventBuffer(int capacity, LDLogger logger) { this.capacity = capacity; + this.logger = logger; } void add(Event e) { @@ -549,13 +559,15 @@ private static final class SendEventsTask implements Runnable { private final AtomicBoolean stopping; private final EventOutputFormatter formatter; private final Thread thread; + private final LDLogger logger; SendEventsTask( EventsConfiguration eventsConfig, EventResponseListener responseListener, BlockingQueue payloadQueue, AtomicInteger activeFlushWorkersCount, - ThreadFactory threadFactory + ThreadFactory threadFactory, + LDLogger logger ) { this.eventsConfig = eventsConfig; this.formatter = new EventOutputFormatter(eventsConfig); @@ -563,6 +575,7 @@ private static final class SendEventsTask implements Runnable { this.payloadQueue = payloadQueue; this.activeFlushWorkersCount = activeFlushWorkersCount; this.stopping = new AtomicBoolean(false); + this.logger = logger; thread = threadFactory.newThread(this); thread.setDaemon(true); thread.start(); @@ -587,8 +600,8 @@ public void run() { ); responseListener.handleResponse(result); } catch (Exception e) { - logger.error("Unexpected error in event processor: {}", e.toString()); - logger.debug(e.toString(), e); + logger.error("Unexpected error in event processor: {}", LogValues.exceptionSummary(e)); + logger.debug(LogValues.exceptionTrace(e)); } synchronized (activeFlushWorkersCount) { activeFlushWorkersCount.decrementAndGet(); diff --git a/src/main/java/com/launchdarkly/sdk/server/DefaultEventSender.java b/src/main/java/com/launchdarkly/sdk/server/DefaultEventSender.java index bf7e8afff..56851a597 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DefaultEventSender.java +++ b/src/main/java/com/launchdarkly/sdk/server/DefaultEventSender.java @@ -1,12 +1,12 @@ package com.launchdarkly.sdk.server; +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.logging.Logs; import com.launchdarkly.sdk.server.interfaces.BasicConfiguration; import com.launchdarkly.sdk.server.interfaces.EventSender; import com.launchdarkly.sdk.server.interfaces.EventSenderFactory; import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; -import org.slf4j.Logger; - import java.io.IOException; import java.net.URI; import java.text.ParseException; @@ -32,8 +32,6 @@ import okhttp3.Response; final class DefaultEventSender implements EventSender { - private static final Logger logger = Loggers.EVENTS; - static final Duration DEFAULT_RETRY_DELAY = Duration.ofSeconds(1); private static final String EVENT_SCHEMA_HEADER = "X-LaunchDarkly-Event-Schema"; private static final String EVENT_SCHEMA_VERSION = "3"; @@ -46,14 +44,17 @@ final class DefaultEventSender implements EventSender { private final OkHttpClient httpClient; private final Headers baseHeaders; final Duration retryDelay; // visible for testing - + private final LDLogger logger; + DefaultEventSender( HttpConfiguration httpConfiguration, - Duration retryDelay + Duration retryDelay, + LDLogger logger ) { OkHttpClient.Builder httpBuilder = new OkHttpClient.Builder(); configureHttpClientBuilder(httpConfiguration, httpBuilder); this.httpClient = httpBuilder.build(); + this.logger = logger; this.baseHeaders = getHeadersBuilderFor(httpConfiguration) .add("Content-Type", "application/json") @@ -148,7 +149,7 @@ public Result sendEventData(EventDataKind kind, String data, int eventCount, URI return new Result(false, mustShutDown, null); } - private static final Date parseResponseDate(Response response) { + private final Date parseResponseDate(Response response) { String dateStr = response.header("Date"); if (dateStr != null) { try { @@ -163,10 +164,19 @@ private static final Date parseResponseDate(Response response) { return null; } - static final class Factory implements EventSenderFactory { + static final class Factory implements EventSenderFactory, EventSenderFactory.WithLogger { @Override public EventSender createEventSender(BasicConfiguration basicConfiguration, HttpConfiguration httpConfiguration) { - return new DefaultEventSender(httpConfiguration, DefaultEventSender.DEFAULT_RETRY_DELAY); + return new DefaultEventSender(httpConfiguration, DefaultEventSender.DEFAULT_RETRY_DELAY, + LDLogger.withAdapter(Logs.none(), "")); + } + + @Override + public EventSender createEventSender( + BasicConfiguration basicConfiguration, + HttpConfiguration httpConfiguration, + LDLogger logger) { + return new DefaultEventSender(httpConfiguration, DefaultEventSender.DEFAULT_RETRY_DELAY, logger); } } } diff --git a/src/main/java/com/launchdarkly/sdk/server/DefaultFeatureRequestor.java b/src/main/java/com/launchdarkly/sdk/server/DefaultFeatureRequestor.java index c3c7cc4bd..008a0d4ad 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DefaultFeatureRequestor.java +++ b/src/main/java/com/launchdarkly/sdk/server/DefaultFeatureRequestor.java @@ -1,11 +1,10 @@ package com.launchdarkly.sdk.server; import com.google.common.annotations.VisibleForTesting; +import com.launchdarkly.logging.LDLogger; import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; import com.launchdarkly.sdk.server.interfaces.SerializationException; -import org.slf4j.Logger; - import java.io.IOException; import java.net.URI; import java.nio.file.Files; @@ -26,7 +25,6 @@ * Implementation of getting flag data via a polling request. Used by both streaming and polling components. */ final class DefaultFeatureRequestor implements FeatureRequestor { - private static final Logger logger = Loggers.DATA_SOURCE; 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 @@ -35,10 +33,12 @@ final class DefaultFeatureRequestor implements FeatureRequestor { private final URI pollingUri; private final Headers headers; private final Path cacheDir; + private final LDLogger logger; - DefaultFeatureRequestor(HttpConfiguration httpConfig, URI baseUri) { + DefaultFeatureRequestor(HttpConfiguration httpConfig, URI baseUri, LDLogger logger) { this.baseUri = baseUri; this.pollingUri = concatenateUriPath(baseUri, GET_LATEST_ALL_PATH); + this.logger = logger; OkHttpClient.Builder httpBuilder = new OkHttpClient.Builder(); configureHttpClientBuilder(httpConfig, httpBuilder); @@ -73,7 +73,8 @@ public AllData getAllData(boolean returnDataEvenIfCached) throws IOException, Ht boolean wasCached = response.networkResponse() == null || response.networkResponse().code() == 304; if (wasCached && !returnDataEvenIfCached) { logger.debug("Get flag(s) got cached response, will not parse"); - logger.debug("Cache hit count: " + httpClient.cache().hitCount() + " Cache network Count: " + httpClient.cache().networkCount()); + logger.debug("Cache hit count: {} Cache network count: {} ", + httpClient.cache().hitCount(), httpClient.cache().networkCount()); return null; } @@ -82,10 +83,11 @@ public AllData getAllData(boolean returnDataEvenIfCached) throws IOException, Ht 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()); - logger.debug("Cache hit count: " + httpClient.cache().hitCount() + " Cache network Count: " + httpClient.cache().networkCount()); - logger.debug("Cache response: " + response.cacheResponse()); + logger.debug("Get flag(s) response: {} with body: {}", response, body); + logger.debug("Network response: {}", response.networkResponse()); + logger.debug("Cache hit count: {} Cache network count: {}", + httpClient.cache().hitCount(), httpClient.cache().networkCount()); + logger.debug("Cache response: {}", response.cacheResponse()); return JsonHelpers.deserialize(body, AllData.class); } diff --git a/src/main/java/com/launchdarkly/sdk/server/Evaluator.java b/src/main/java/com/launchdarkly/sdk/server/Evaluator.java index d66818f0c..2f3a4967d 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Evaluator.java +++ b/src/main/java/com/launchdarkly/sdk/server/Evaluator.java @@ -1,6 +1,7 @@ package com.launchdarkly.sdk.server; import com.google.common.collect.ImmutableList; +import com.launchdarkly.logging.LDLogger; import com.launchdarkly.sdk.EvaluationDetail; import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDUser; @@ -11,8 +12,6 @@ import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreTypes; import com.launchdarkly.sdk.server.interfaces.Event; -import org.slf4j.Logger; - import java.util.ArrayList; import java.util.List; import java.util.Set; @@ -27,8 +26,6 @@ * flags, but does not send them. */ class Evaluator { - private final static Logger logger = Loggers.EVALUATION; - /** * This key cannot exist in LaunchDarkly because it contains invalid characters. We use it in tests as a way to * simulate an unexpected RuntimeException during flag evaluations. We check for it by reference equality, so @@ -38,6 +35,7 @@ class Evaluator { static final RuntimeException EXPECTED_EXCEPTION_FROM_INVALID_FLAG = new RuntimeException("deliberate test error"); private final Getters getters; + private final LDLogger logger; /** * An abstraction of getting flags or segments by key. This ensures that Evaluator cannot modify the data store, @@ -120,8 +118,9 @@ static class BigSegmentsState { private EvaluationReason.BigSegmentsStatus bigSegmentsStatus; } - Evaluator(Getters getters) { + Evaluator(Getters getters, LDLogger logger) { this.getters = getters; + this.logger = logger; } /** diff --git a/src/main/java/com/launchdarkly/sdk/server/EventBroadcasterImpl.java b/src/main/java/com/launchdarkly/sdk/server/EventBroadcasterImpl.java index 786afa5c0..c76463f18 100644 --- a/src/main/java/com/launchdarkly/sdk/server/EventBroadcasterImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/EventBroadcasterImpl.java @@ -1,5 +1,7 @@ package com.launchdarkly.sdk.server; +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.logging.LogValues; import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; @@ -23,7 +25,8 @@ class EventBroadcasterImpl { private final CopyOnWriteArrayList listeners = new CopyOnWriteArrayList<>(); private final BiConsumer broadcastAction; private final ExecutorService executor; - + private final LDLogger logger; + /** * Creates an instance. * @@ -31,28 +34,37 @@ class EventBroadcasterImpl { * @param executor the executor to use for running notification tasks on a worker thread; if this * is null (which should only be the case in test code) then broadcasting an event will be a no-op */ - EventBroadcasterImpl(BiConsumer broadcastAction, ExecutorService executor) { + EventBroadcasterImpl( + BiConsumer broadcastAction, + ExecutorService executor, + LDLogger logger + ) { this.broadcastAction = broadcastAction; this.executor = executor; + this.logger = logger; } - static EventBroadcasterImpl forFlagChangeEvents(ExecutorService executor) { - return new EventBroadcasterImpl<>(FlagChangeListener::onFlagChange, executor); + static EventBroadcasterImpl forFlagChangeEvents( + ExecutorService executor, LDLogger logger) { + return new EventBroadcasterImpl<>(FlagChangeListener::onFlagChange, executor, logger); } static EventBroadcasterImpl - forDataSourceStatus(ExecutorService executor) { - return new EventBroadcasterImpl<>(DataSourceStatusProvider.StatusListener::dataSourceStatusChanged, executor); + forDataSourceStatus(ExecutorService executor, LDLogger logger) { + return new EventBroadcasterImpl<>(DataSourceStatusProvider.StatusListener::dataSourceStatusChanged, + executor, logger); } static EventBroadcasterImpl - forDataStoreStatus(ExecutorService executor) { - return new EventBroadcasterImpl<>(DataStoreStatusProvider.StatusListener::dataStoreStatusChanged, executor); + forDataStoreStatus(ExecutorService executor, LDLogger logger) { + return new EventBroadcasterImpl<>(DataStoreStatusProvider.StatusListener::dataStoreStatusChanged, + executor, logger); } static EventBroadcasterImpl - forBigSegmentStoreStatus(ExecutorService executor) { - return new EventBroadcasterImpl<>(BigSegmentStoreStatusProvider.StatusListener::bigSegmentStoreStatusChanged, executor); + forBigSegmentStoreStatus(ExecutorService executor, LDLogger logger) { + return new EventBroadcasterImpl<>(BigSegmentStoreStatusProvider.StatusListener::bigSegmentStoreStatusChanged, + executor, logger); } /** @@ -96,8 +108,8 @@ void broadcast(EventT event) { try { broadcastAction.accept(l, event); } catch (Exception e) { - Loggers.MAIN.warn("Unexpected error from listener ({}): {}", l.getClass(), e.toString()); - Loggers.MAIN.debug(e.toString(), e); + logger.warn("Unexpected error from listener ({}): {}", l.getClass(), LogValues.exceptionSummary(e)); + logger.debug("{}", LogValues.exceptionTrace(e)); } }); } diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClient.java b/src/main/java/com/launchdarkly/sdk/server/LDClient.java index 3bafc888f..ce78a7172 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClient.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClient.java @@ -1,12 +1,8 @@ package com.launchdarkly.sdk.server; -import static com.google.common.base.Preconditions.checkNotNull; -import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; -import static com.launchdarkly.sdk.server.DataModel.FEATURES; -import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; -import static com.launchdarkly.sdk.server.Util.isAsciiHeaderValue; - import com.google.common.util.concurrent.ThreadFactoryBuilder; +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.logging.LogValues; import com.launchdarkly.sdk.EvaluationDetail; import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDUser; @@ -45,6 +41,12 @@ import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; +import static com.google.common.base.Preconditions.checkNotNull; +import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; +import static com.launchdarkly.sdk.server.Util.isAsciiHeaderValue; + /** * A client for the LaunchDarkly API. Client instances are thread-safe. Applications should instantiate * a single {@code LDClient} for the lifetime of their application. @@ -68,6 +70,8 @@ public final class LDClient implements LDClientInterface { private final ScheduledExecutorService sharedExecutor; private final EventFactory eventFactoryDefault; private final EventFactory eventFactoryWithReasons; + private final LDLogger baseLogger; + private final LDLogger evaluationLogger; /** * Creates a new client instance that connects to LaunchDarkly with the default configuration. @@ -196,21 +200,24 @@ public LDClient(String sdkKey, LDConfig config) { sharedExecutor, useDiagnostics ? new DiagnosticAccumulator(new DiagnosticId(sdkKey)) : null ); - + this.baseLogger = context.getBaseLogger(); + this.evaluationLogger = this.baseLogger.subLogger(Loggers.EVALUATION_LOGGER_NAME); + this.eventProcessor = config.eventProcessorFactory.createEventProcessor(context); EventBroadcasterImpl bigSegmentStoreStatusNotifier = - EventBroadcasterImpl.forBigSegmentStoreStatus(sharedExecutor); + EventBroadcasterImpl.forBigSegmentStoreStatus(sharedExecutor, baseLogger); BigSegmentsConfiguration bigSegmentsConfig = config.bigSegmentsConfigBuilder.createBigSegmentsConfiguration(context); if (bigSegmentsConfig.getStore() != null) { - bigSegmentStoreWrapper = new BigSegmentStoreWrapper(bigSegmentsConfig, bigSegmentStoreStatusNotifier, sharedExecutor); + bigSegmentStoreWrapper = new BigSegmentStoreWrapper(bigSegmentsConfig, bigSegmentStoreStatusNotifier, sharedExecutor, + this.baseLogger.subLogger(Loggers.BIG_SEGMENTS_LOGGER_NAME)); } else { bigSegmentStoreWrapper = null; } bigSegmentStoreStatusProvider = new BigSegmentStoreStatusProviderImpl(bigSegmentStoreStatusNotifier, bigSegmentStoreWrapper); EventBroadcasterImpl dataStoreStatusNotifier = - EventBroadcasterImpl.forDataStoreStatus(sharedExecutor); + EventBroadcasterImpl.forDataStoreStatus(sharedExecutor, baseLogger); DataStoreUpdatesImpl dataStoreUpdates = new DataStoreUpdatesImpl(dataStoreStatusNotifier); this.dataStore = config.dataStoreFactory.createDataStore(context, dataStoreUpdates); @@ -227,23 +234,24 @@ public BigSegmentStoreWrapper.BigSegmentsQueryResult getBigSegments(String key) BigSegmentStoreWrapper wrapper = LDClient.this.bigSegmentStoreWrapper; return wrapper == null ? null : wrapper.getUserMembership(key); } - }); + }, evaluationLogger); - this.flagChangeBroadcaster = EventBroadcasterImpl.forFlagChangeEvents(sharedExecutor); + this.flagChangeBroadcaster = EventBroadcasterImpl.forFlagChangeEvents(sharedExecutor, baseLogger); this.flagTracker = new FlagTrackerImpl(flagChangeBroadcaster, (key, user) -> jsonValueVariation(key, user, LDValue.ofNull())); this.dataStoreStatusProvider = new DataStoreStatusProviderImpl(this.dataStore, dataStoreUpdates); EventBroadcasterImpl dataSourceStatusNotifier = - EventBroadcasterImpl.forDataSourceStatus(sharedExecutor); + EventBroadcasterImpl.forDataSourceStatus(sharedExecutor, baseLogger); DataSourceUpdatesImpl dataSourceUpdates = new DataSourceUpdatesImpl( dataStore, dataStoreStatusProvider, flagChangeBroadcaster, dataSourceStatusNotifier, sharedExecutor, - context.getLogging().getLogDataSourceOutageAsErrorAfter() + context.getLogging().getLogDataSourceOutageAsErrorAfter(), + baseLogger ); this.dataSourceUpdates = dataSourceUpdates; this.dataSource = config.dataSourceFactory.createDataSource(context, dataSourceUpdates); @@ -252,18 +260,20 @@ public BigSegmentStoreWrapper.BigSegmentsQueryResult getBigSegments(String key) Future startFuture = dataSource.start(); if (!config.startWait.isZero() && !config.startWait.isNegative()) { if (!(dataSource instanceof ComponentsImpl.NullDataSource)) { - Loggers.MAIN.info("Waiting up to " + config.startWait.toMillis() + " milliseconds for LaunchDarkly client to start..."); + baseLogger.info("Waiting up to {} milliseconds for LaunchDarkly client to start...", + config.startWait.toMillis()); } try { startFuture.get(config.startWait.toMillis(), TimeUnit.MILLISECONDS); } catch (TimeoutException e) { - Loggers.MAIN.error("Timeout encountered waiting for LaunchDarkly client initialization"); + baseLogger.error("Timeout encountered waiting for LaunchDarkly client initialization"); } catch (Exception e) { - Loggers.MAIN.error("Exception encountered waiting for LaunchDarkly client initialization: {}", e.toString()); - Loggers.MAIN.debug(e.toString(), e); + baseLogger.error("Exception encountered waiting for LaunchDarkly client initialization: {}", + LogValues.exceptionSummary(e)); + baseLogger.debug("{}", LogValues.exceptionTrace(e)); } if (!dataSource.isInitialized()) { - Loggers.MAIN.warn("LaunchDarkly client was not successfully initialized"); + baseLogger.warn("LaunchDarkly client was not successfully initialized"); } } } @@ -281,7 +291,7 @@ public void track(String eventName, LDUser user) { @Override public void trackData(String eventName, LDUser user, LDValue data) { if (user == null || user.getKey() == null || user.getKey().isEmpty()) { - Loggers.MAIN.warn("Track called with null user or null/empty user key!"); + baseLogger.warn("Track called with null user or null/empty user key!"); } else { eventProcessor.sendEvent(eventFactoryDefault.newCustomEvent(eventName, user, data, null)); } @@ -290,7 +300,7 @@ public void trackData(String eventName, LDUser user, LDValue data) { @Override public void trackMetric(String eventName, LDUser user, LDValue data, double metricValue) { if (user == null || user.getKey() == null || user.getKey().isEmpty()) { - Loggers.MAIN.warn("Track called with null user or null/empty user key!"); + baseLogger.warn("Track called with null user or null/empty user key!"); } else { eventProcessor.sendEvent(eventFactoryDefault.newCustomEvent(eventName, user, data, metricValue)); } @@ -299,7 +309,7 @@ public void trackMetric(String eventName, LDUser user, LDValue data, double metr @Override public void identify(LDUser user) { if (user == null || user.getKey() == null || user.getKey().isEmpty()) { - Loggers.MAIN.warn("Identify called with null user or null/empty user key!"); + baseLogger.warn("Identify called with null user or null/empty user key!"); } else { eventProcessor.sendEvent(eventFactoryDefault.newIdentifyEvent(user)); } @@ -316,20 +326,20 @@ public FeatureFlagsState allFlagsState(LDUser user, FlagsStateOption... options) FeatureFlagsState.Builder builder = FeatureFlagsState.builder(options); if (isOffline()) { - Loggers.EVALUATION.debug("allFlagsState() was called when client is in offline mode."); + evaluationLogger.debug("allFlagsState() was called when client is in offline mode."); } if (!isInitialized()) { if (dataStore.isInitialized()) { - Loggers.EVALUATION.warn("allFlagsState() was called before client initialized; using last known values from data store"); + evaluationLogger.warn("allFlagsState() was called before client initialized; using last known values from data store"); } else { - Loggers.EVALUATION.warn("allFlagsState() was called before client initialized; data store unavailable, returning no data"); + evaluationLogger.warn("allFlagsState() was called before client initialized; data store unavailable, returning no data"); return builder.valid(false).build(); } } if (user == null || user.getKey() == null) { - Loggers.EVALUATION.warn("allFlagsState() was called with null user or null user key! returning no data"); + evaluationLogger.warn("allFlagsState() was called with null user or null user key! returning no data"); return builder.valid(false).build(); } @@ -338,8 +348,8 @@ public FeatureFlagsState allFlagsState(LDUser user, FlagsStateOption... options) try { flags = dataStore.getAll(FEATURES); } catch (Exception e) { - Loggers.EVALUATION.error("Exception from data store when evaluating all flags: {}", e.toString()); - Loggers.EVALUATION.debug(e.toString(), e); + evaluationLogger.error("Exception from data store when evaluating all flags: {}", e.toString()); + evaluationLogger.debug(e.toString(), e); return builder.valid(false).build(); } @@ -355,8 +365,8 @@ public FeatureFlagsState allFlagsState(LDUser user, FlagsStateOption... options) Evaluator.EvalResult result = evaluator.evaluate(flag, user, eventFactoryDefault); builder.addFlag(flag, result); } catch (Exception e) { - Loggers.EVALUATION.error("Exception caught for feature flag \"{}\" when evaluating all flags: {}", entry.getKey(), e.toString()); - Loggers.EVALUATION.debug(e.toString(), e); + evaluationLogger.error("Exception caught for feature flag \"{}\" when evaluating all flags: {}", entry.getKey(), e.toString()); + evaluationLogger.debug(e.toString(), e); builder.addFlag(flag, new Evaluator.EvalResult(LDValue.ofNull(), NO_VARIATION, EvaluationReason.exception(e))); } } @@ -430,9 +440,9 @@ public EvaluationDetail jsonValueVariationDetail(String featureKey, LDU public boolean isFlagKnown(String featureKey) { if (!isInitialized()) { if (dataStore.isInitialized()) { - Loggers.MAIN.warn("isFlagKnown called before client initialized for feature flag \"{}\"; using last known values from data store", featureKey); + baseLogger.warn("isFlagKnown called before client initialized for feature flag \"{}\"; using last known values from data store", featureKey); } else { - Loggers.MAIN.warn("isFlagKnown called before client initialized for feature flag \"{}\"; data store unavailable, returning false", featureKey); + baseLogger.warn("isFlagKnown called before client initialized for feature flag \"{}\"; data store unavailable, returning false", featureKey); return false; } } @@ -442,8 +452,9 @@ public boolean isFlagKnown(String featureKey) { return true; } } catch (Exception e) { - Loggers.MAIN.error("Encountered exception while calling isFlagKnown for feature flag \"{}\": {}", featureKey, e.toString()); - Loggers.MAIN.debug(e.toString(), e); + baseLogger.error("Encountered exception while calling isFlagKnown for feature flag \"{}\": {}", featureKey, + LogValues.exceptionSummary(e)); + baseLogger.debug("{}", LogValues.exceptionTrace(e)); } return false; @@ -461,9 +472,9 @@ private Evaluator.EvalResult evaluateInternal(String featureKey, LDUser user, LD EventFactory eventFactory) { if (!isInitialized()) { if (dataStore.isInitialized()) { - Loggers.EVALUATION.warn("Evaluation called before client initialized for feature flag \"{}\"; using last known values from data store", featureKey); + evaluationLogger.warn("Evaluation called before client initialized for feature flag \"{}\"; using last known values from data store", featureKey); } else { - Loggers.EVALUATION.warn("Evaluation called before client initialized for feature flag \"{}\"; data store unavailable, returning default value", featureKey); + evaluationLogger.warn("Evaluation called before client initialized for feature flag \"{}\"; data store unavailable, returning default value", featureKey); sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue, EvaluationReason.ErrorKind.CLIENT_NOT_READY)); return errorResult(EvaluationReason.ErrorKind.CLIENT_NOT_READY, defaultValue); @@ -474,19 +485,19 @@ private Evaluator.EvalResult evaluateInternal(String featureKey, LDUser user, LD try { featureFlag = getFlag(dataStore, featureKey); if (featureFlag == null) { - Loggers.EVALUATION.info("Unknown feature flag \"{}\"; returning default value", featureKey); + evaluationLogger.info("Unknown feature flag \"{}\"; returning default value", featureKey); sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue, EvaluationReason.ErrorKind.FLAG_NOT_FOUND)); return errorResult(EvaluationReason.ErrorKind.FLAG_NOT_FOUND, defaultValue); } if (user == null || user.getKey() == null) { - Loggers.EVALUATION.warn("Null user or null user key when evaluating flag \"{}\"; returning default value", featureKey); + evaluationLogger.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)); return errorResult(EvaluationReason.ErrorKind.USER_NOT_SPECIFIED, defaultValue); } if (user.getKey().isEmpty()) { - Loggers.EVALUATION.warn("User key is blank. Flag evaluation will proceed, but the user will not be stored in LaunchDarkly"); + evaluationLogger.warn("User key is blank. Flag evaluation will proceed, but the user will not be stored in LaunchDarkly"); } Evaluator.EvalResult evalResult = evaluator.evaluate(featureFlag, user, eventFactory); for (Event.FeatureRequest event : evalResult.getPrerequisiteEvents()) { @@ -497,7 +508,7 @@ private Evaluator.EvalResult evaluateInternal(String featureKey, LDUser user, LD } else { LDValue value = evalResult.getValue(); // guaranteed not to be an actual Java null, but can be LDValue.ofNull() if (checkType && !value.isNull() && !defaultValue.isNull() && defaultValue.getType() != value.getType()) { - Loggers.EVALUATION.error("Feature flag evaluation expected result as {}, but got {}", defaultValue.getType(), value.getType()); + evaluationLogger.error("Feature flag evaluation expected result as {}, but got {}", defaultValue.getType(), value.getType()); sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue, EvaluationReason.ErrorKind.WRONG_TYPE)); return errorResult(EvaluationReason.ErrorKind.WRONG_TYPE, defaultValue); @@ -506,8 +517,9 @@ private Evaluator.EvalResult evaluateInternal(String featureKey, LDUser user, LD sendFlagRequestEvent(eventFactory.newFeatureRequestEvent(featureFlag, user, evalResult, defaultValue)); return evalResult; } catch (Exception e) { - Loggers.EVALUATION.error("Encountered exception while evaluating feature flag \"{}\": {}", featureKey, e.toString()); - Loggers.EVALUATION.debug(e.toString(), e); + evaluationLogger.error("Encountered exception while evaluating feature flag \"{}\": {}", featureKey, + LogValues.exceptionSummary(e)); + evaluationLogger.debug("{}", LogValues.exceptionTrace(e)); if (featureFlag == null) { sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue, EvaluationReason.ErrorKind.EXCEPTION)); @@ -541,7 +553,7 @@ public DataSourceStatusProvider getDataSourceStatusProvider() { @Override public void close() throws IOException { - Loggers.MAIN.info("Closing LaunchDarkly Client"); + baseLogger.info("Closing LaunchDarkly Client"); this.dataStore.close(); this.eventProcessor.close(); this.dataSource.close(); @@ -573,8 +585,8 @@ public String secureModeHash(LDUser user) { return Hex.encodeHexString(mac.doFinal(user.getKey().getBytes("UTF8"))); } catch (InvalidKeyException | UnsupportedEncodingException | NoSuchAlgorithmException e) { // COVERAGE: there is no way to cause these errors in a unit test. - Loggers.MAIN.error("Could not generate secure mode hash: {}", e.toString()); - Loggers.MAIN.debug(e.toString(), e); + baseLogger.error("Could not generate secure mode hash: {}", LogValues.exceptionSummary(e)); + baseLogger.debug("{}", LogValues.exceptionTrace(e)); } return null; } diff --git a/src/main/java/com/launchdarkly/sdk/server/LDConfig.java b/src/main/java/com/launchdarkly/sdk/server/LDConfig.java index 2ae272aa1..e90ef35f2 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDConfig.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDConfig.java @@ -222,7 +222,7 @@ public Builder logging(LoggingConfigurationFactory factory) { this.loggingConfigFactory = factory; return this; } - + /** * Set whether this client is offline. *

      diff --git a/src/main/java/com/launchdarkly/sdk/server/Loggers.java b/src/main/java/com/launchdarkly/sdk/server/Loggers.java index abcd9bcb3..0881f0d43 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Loggers.java +++ b/src/main/java/com/launchdarkly/sdk/server/Loggers.java @@ -1,8 +1,5 @@ package com.launchdarkly.sdk.server; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - /** * Static logger instances to be shared by implementation code in the main {@code com.launchdarkly.sdk.server} * package. @@ -21,39 +18,9 @@ abstract class Loggers { private Loggers() {} static final String BASE_LOGGER_NAME = LDClient.class.getName(); - static final String BIG_SEGMENTS_LOGGER_NAME = BASE_LOGGER_NAME + ".BigSegments"; - static final String DATA_SOURCE_LOGGER_NAME = BASE_LOGGER_NAME + ".DataSource"; - static final String DATA_STORE_LOGGER_NAME = BASE_LOGGER_NAME + ".DataStore"; - static final String EVALUATION_LOGGER_NAME = BASE_LOGGER_NAME + ".Evaluation"; - static final String EVENTS_LOGGER_NAME = BASE_LOGGER_NAME + ".Events"; - - /** - * The default logger instance to use for SDK messages: "com.launchdarkly.sdk.server.LDClient" - */ - static final Logger MAIN = LoggerFactory.getLogger(BASE_LOGGER_NAME); - - /** - * The logger instance to use for messages related to the Big Segments implementation: "com.launchdarkly.sdk.server.LDClient.BigSegments" - */ - static final Logger BIG_SEGMENTS = LoggerFactory.getLogger(BIG_SEGMENTS_LOGGER_NAME); - - /** - * The logger instance to use for messages related to polling, streaming, etc.: "com.launchdarkly.sdk.server.LDClient.DataSource" - */ - static final Logger DATA_SOURCE = LoggerFactory.getLogger(DATA_SOURCE_LOGGER_NAME); - - /** - * The logger instance to use for messages related to data store problems: "com.launchdarkly.sdk.server.LDClient.DataStore" - */ - static final Logger DATA_STORE = LoggerFactory.getLogger(DATA_STORE_LOGGER_NAME); - - /** - * The logger instance to use for messages related to flag evaluation: "com.launchdarkly.sdk.server.LDClient.Evaluation" - */ - static final Logger EVALUATION = LoggerFactory.getLogger(EVALUATION_LOGGER_NAME); - - /** - * The logger instance to use for messages from the event processor: "com.launchdarkly.sdk.server.LDClient.Events" - */ - static final Logger EVENTS = LoggerFactory.getLogger(EVENTS_LOGGER_NAME); + static final String BIG_SEGMENTS_LOGGER_NAME = "BigSegments"; + static final String DATA_SOURCE_LOGGER_NAME = "DataSource"; + static final String DATA_STORE_LOGGER_NAME = "DataStore"; + static final String EVALUATION_LOGGER_NAME = "Evaluation"; + static final String EVENTS_LOGGER_NAME = "Events"; } diff --git a/src/main/java/com/launchdarkly/sdk/server/LoggingConfigurationImpl.java b/src/main/java/com/launchdarkly/sdk/server/LoggingConfigurationImpl.java index f3ce2c872..cc05792da 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LoggingConfigurationImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/LoggingConfigurationImpl.java @@ -1,15 +1,34 @@ package com.launchdarkly.sdk.server; +import com.launchdarkly.logging.LDLogAdapter; import com.launchdarkly.sdk.server.interfaces.LoggingConfiguration; import java.time.Duration; -final class LoggingConfigurationImpl implements LoggingConfiguration { +final class LoggingConfigurationImpl implements LoggingConfiguration, LoggingConfiguration.AdapterOptions { + private final String baseLoggerName; + private final LDLogAdapter logAdapter; private final Duration logDataSourceOutageAsErrorAfter; - LoggingConfigurationImpl(Duration logDataSourceOutageAsErrorAfter) { + LoggingConfigurationImpl( + String baseLoggerName, + LDLogAdapter logAdapter, + Duration logDataSourceOutageAsErrorAfter + ) { + this.baseLoggerName = baseLoggerName; + this.logAdapter = logAdapter; this.logDataSourceOutageAsErrorAfter = logDataSourceOutageAsErrorAfter; } + + @Override + public String getBaseLoggerName() { + return baseLoggerName; + } + + @Override + public LDLogAdapter getLogAdapter() { + return logAdapter; + } @Override public Duration getLogDataSourceOutageAsErrorAfter() { diff --git a/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreStatusManager.java b/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreStatusManager.java index 211f57468..2d8d4cc0a 100644 --- a/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreStatusManager.java +++ b/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreStatusManager.java @@ -1,10 +1,10 @@ package com.launchdarkly.sdk.server; +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.logging.LogValues; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider.Status; -import org.slf4j.Logger; - import java.io.Closeable; import java.util.concurrent.Callable; import java.util.concurrent.ScheduledExecutorService; @@ -19,13 +19,13 @@ * clarity and also lets us reuse this logic in tests. */ final class PersistentDataStoreStatusManager implements Closeable { - private static final Logger logger = Loggers.DATA_STORE; static final int POLL_INTERVAL_MS = 500; // visible for testing private final Consumer statusUpdater; private final ScheduledExecutorService scheduler; private final Callable statusPollFn; private final boolean refreshOnRecovery; + private final LDLogger logger; private volatile boolean lastAvailable; private volatile ScheduledFuture pollerFuture; @@ -34,13 +34,15 @@ final class PersistentDataStoreStatusManager implements Closeable { boolean availableNow, Callable statusPollFn, Consumer statusUpdater, - ScheduledExecutorService sharedExecutor + ScheduledExecutorService sharedExecutor, + LDLogger logger ) { this.refreshOnRecovery = refreshOnRecovery; this.lastAvailable = availableNow; this.statusPollFn = statusPollFn; this.statusUpdater = statusUpdater; this.scheduler = sharedExecutor; + this.logger = logger; } public void close() { @@ -88,8 +90,8 @@ public void run() { updateAvailability(true); } } catch (Exception e) { - logger.error("Unexpected error from data store status function: {}", e.toString()); - logger.debug(e.toString(), e); + logger.error("Unexpected error from data store status function: {}", LogValues.exceptionSummary(e)); + logger.debug(LogValues.exceptionTrace(e)); } } }; diff --git a/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapper.java b/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapper.java index ab2f27d8a..836070293 100644 --- a/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapper.java +++ b/src/main/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapper.java @@ -8,6 +8,8 @@ import com.google.common.util.concurrent.ListeningExecutorService; import com.google.common.util.concurrent.MoreExecutors; import com.google.common.util.concurrent.UncheckedExecutionException; +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.logging.LogValues; import com.launchdarkly.sdk.server.integrations.PersistentDataStoreBuilder; import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider.CacheStats; @@ -19,8 +21,6 @@ import com.launchdarkly.sdk.server.interfaces.DataStoreUpdates; import com.launchdarkly.sdk.server.interfaces.PersistentDataStore; -import org.slf4j.Logger; - import java.io.IOException; import java.time.Duration; import java.util.AbstractMap; @@ -44,8 +44,6 @@ * This class is only constructed by {@link PersistentDataStoreBuilder}. */ final class PersistentDataStoreWrapper implements DataStore { - private static final Logger logger = Loggers.DATA_STORE; - private final PersistentDataStore core; private final LoadingCache> itemCache; private final LoadingCache> allCache; @@ -55,6 +53,7 @@ final class PersistentDataStoreWrapper implements DataStore { private final Set cachedDataKinds = new HashSet<>(); // this map is used in pollForAvailability() private final AtomicBoolean inited = new AtomicBoolean(false); private final ListeningExecutorService cacheExecutor; + private final LDLogger logger; PersistentDataStoreWrapper( final PersistentDataStore core, @@ -62,9 +61,11 @@ final class PersistentDataStoreWrapper implements DataStore { PersistentDataStoreBuilder.StaleValuesPolicy staleValuesPolicy, boolean recordCacheStats, DataStoreUpdates dataStoreUpdates, - ScheduledExecutorService sharedExecutor + ScheduledExecutorService sharedExecutor, + LDLogger logger ) { this.core = core; + this.logger = logger; if (cacheTtl.isZero()) { itemCache = null; @@ -112,7 +113,8 @@ public Boolean load(String key) throws Exception { true, this::pollAvailabilityAfterOutage, dataStoreUpdates::updateStatus, - sharedExecutor + sharedExecutor, + logger ); } @@ -421,8 +423,9 @@ private boolean pollAvailabilityAfterOutage() { } else { // We failed to write the cached data to the underlying store. In this case, we should not // return to a recovered state, but just try this all again next time the poll task runs. - logger.error("Tried to write cached data to persistent store after a store outage, but failed: {}", e.toString()); - logger.debug(e.toString(), e); + logger.error("Tried to write cached data to persistent store after a store outage, but failed: {}", + LogValues.exceptionSummary(e)); + logger.debug(LogValues.exceptionTrace(e)); return false; } } diff --git a/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java b/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java index 31ad32a39..c34d4557c 100644 --- a/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java @@ -1,6 +1,7 @@ package com.launchdarkly.sdk.server; import com.google.common.annotations.VisibleForTesting; +import com.launchdarkly.logging.LDLogger; import com.launchdarkly.sdk.server.interfaces.DataSource; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorInfo; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorKind; @@ -8,8 +9,6 @@ import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; import com.launchdarkly.sdk.server.interfaces.SerializationException; -import org.slf4j.Logger; - import java.io.IOException; import java.time.Duration; import java.util.concurrent.CompletableFuture; @@ -23,7 +22,6 @@ import static com.launchdarkly.sdk.server.Util.httpErrorDescription; final class PollingProcessor implements DataSource { - private static final Logger logger = Loggers.DATA_SOURCE; private static final String ERROR_CONTEXT_MESSAGE = "on polling request"; private static final String WILL_RETRY_MESSAGE = "will retry at next scheduled poll interval"; @@ -34,18 +32,21 @@ final class PollingProcessor implements DataSource { private final AtomicBoolean initialized = new AtomicBoolean(false); private final CompletableFuture initFuture; private volatile ScheduledFuture task; + private final LDLogger logger; PollingProcessor( FeatureRequestor requestor, DataSourceUpdates dataSourceUpdates, ScheduledExecutorService sharedExecutor, - Duration pollInterval + Duration pollInterval, + LDLogger logger ) { this.requestor = requestor; // note that HTTP configuration is applied to the requestor when it is created this.dataSourceUpdates = dataSourceUpdates; this.scheduler = sharedExecutor; this.pollInterval = pollInterval; this.initFuture = new CompletableFuture<>(); + this.logger = logger; } @Override @@ -71,8 +72,8 @@ public void close() throws IOException { @Override public Future start() { - logger.info("Starting LaunchDarkly polling client with interval: " - + pollInterval.toMillis() + " milliseconds"); + logger.info("Starting LaunchDarkly polling client with interval: {} milliseconds", + pollInterval.toMillis()); synchronized (this) { if (task == null) { diff --git a/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java index b0758eac4..8d98e6113 100644 --- a/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java @@ -8,6 +8,8 @@ import com.launchdarkly.eventsource.EventSource; import com.launchdarkly.eventsource.MessageEvent; import com.launchdarkly.eventsource.UnsuccessfulResponseException; +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.logging.LogValues; import com.launchdarkly.sdk.server.DataModel.VersionedData; import com.launchdarkly.sdk.server.interfaces.DataSource; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorInfo; @@ -21,8 +23,6 @@ import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; import com.launchdarkly.sdk.server.interfaces.SerializationException; -import org.slf4j.Logger; - import java.io.IOException; import java.net.URI; import java.time.Duration; @@ -73,7 +73,6 @@ final class StreamProcessor implements DataSource { private static final String PUT = "put"; private static final String PATCH = "patch"; private static final String DELETE = "delete"; - private static final Logger logger = Loggers.DATA_SOURCE; private static final Duration DEAD_CONNECTION_INTERVAL = Duration.ofSeconds(300); private static final String ERROR_CONTEXT_MESSAGE = "in stream connection"; private static final String WILL_RETRY_MESSAGE = "will retry"; @@ -90,6 +89,7 @@ final class StreamProcessor implements DataSource { private final AtomicBoolean initialized = new AtomicBoolean(false); private volatile long esStarted = 0; private volatile boolean lastStoreUpdateFailed = false; + private final LDLogger logger; ConnectionErrorHandler connectionErrorHandler = createDefaultConnectionErrorHandler(); // exposed for testing @@ -99,7 +99,8 @@ final class StreamProcessor implements DataSource { int threadPriority, DiagnosticAccumulator diagnosticAccumulator, URI streamUri, - Duration initialReconnectDelay + Duration initialReconnectDelay, + LDLogger logger ) { this.dataSourceUpdates = dataSourceUpdates; this.httpConfig = httpConfig; @@ -107,7 +108,8 @@ final class StreamProcessor implements DataSource { this.threadPriority = threadPriority; this.streamUri = streamUri; this.initialReconnectDelay = initialReconnectDelay; - + this.logger = logger; + this.headers = getHeadersBuilderFor(httpConfig) .add("Accept", "text/event-stream") .build(); @@ -179,7 +181,7 @@ public Future start() { EventSource.Builder builder = new EventSource.Builder(handler, endpointUri) .threadPriority(threadPriority) - .loggerBaseName(Loggers.DATA_SOURCE_LOGGER_NAME) + .logger(new EventSourceLoggerAdapter()) .clientBuilderActions(new EventSource.Builder.ClientConfigurer() { public void configure(OkHttpClient.Builder builder) { configureHttpClientBuilder(httpConfig, builder); @@ -255,15 +257,16 @@ public void onMessage(String name, MessageEvent event) throws Exception { break; default: - logger.warn("Unexpected event found in stream: " + name); + logger.warn("Unexpected event found in stream: {}", name); break; } lastStoreUpdateFailed = false; dataSourceUpdates.updateStatus(State.VALID, null); } catch (StreamInputException e) { - logger.error("LaunchDarkly service request failed or received invalid data: {}", e.toString()); - logger.debug(e.toString(), e); - + logger.error("LaunchDarkly service request failed or received invalid data: {}", + LogValues.exceptionSummary(e)); + logger.debug(LogValues.exceptionTrace(e)); + ErrorInfo errorInfo = new ErrorInfo( e.getCause() instanceof IOException ? ErrorKind.NETWORK_ERROR : ErrorKind.INVALID_DATA, 0, @@ -283,8 +286,8 @@ public void onMessage(String name, MessageEvent event) throws Exception { } lastStoreUpdateFailed = true; } catch (Exception e) { - logger.warn("Unexpected error from stream processor: {}", e.toString()); - logger.debug(e.toString(), e); + logger.warn("Unexpected error from stream processor: {}", LogValues.exceptionSummary(e)); + logger.debug(LogValues.exceptionTrace(e)); } } @@ -337,8 +340,8 @@ public void onComment(String comment) { @Override public void onError(Throwable throwable) { - logger.warn("Encountered EventSource error: {}", throwable.toString()); - logger.debug(throwable.toString(), throwable); + logger.warn("Encountered EventSource error: {}", LogValues.exceptionSummary(throwable)); + logger.debug(LogValues.exceptionTrace(throwable)); } } @@ -411,4 +414,31 @@ private static final class DeleteData { @SuppressWarnings("unused") // used by Gson public DeleteData() { } } + + private final class EventSourceLoggerAdapter implements com.launchdarkly.eventsource.Logger { + @Override + public void debug(String format, Object param) { + logger.debug(format, param); + } + + @Override + public void debug(String format, Object param1, Object param2) { + logger.debug(format, param1, param2); + } + + @Override + public void info(String message) { + logger.info(message); + } + + @Override + public void warn(String message) { + logger.warn(message); + } + + @Override + public void error(String message) { + logger.error(message); + } + } } diff --git a/src/main/java/com/launchdarkly/sdk/server/Util.java b/src/main/java/com/launchdarkly/sdk/server/Util.java index fc6a4cc02..8e599b1d5 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Util.java +++ b/src/main/java/com/launchdarkly/sdk/server/Util.java @@ -1,10 +1,9 @@ package com.launchdarkly.sdk.server; +import com.launchdarkly.logging.LDLogger; import com.launchdarkly.sdk.server.interfaces.HttpAuthentication; import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; -import org.slf4j.Logger; - import java.io.IOException; import java.net.URI; import java.nio.file.FileVisitResult; @@ -148,7 +147,7 @@ static boolean isHttpErrorRecoverable(int statusCode) { * @return true if the error is recoverable */ static boolean checkIfErrorIsRecoverableAndLog( - Logger logger, + LDLogger logger, String errorDesc, String errorContext, int statusCode, diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBuilder.java b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBuilder.java index af7838fe0..267ba387b 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceBuilder.java @@ -1,6 +1,8 @@ package com.launchdarkly.sdk.server.integrations; import com.google.common.io.ByteStreams; +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.logging.Logs; import com.launchdarkly.sdk.server.interfaces.ClientContext; import com.launchdarkly.sdk.server.interfaces.DataSource; import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; @@ -122,7 +124,13 @@ public FileDataSourceBuilder duplicateKeysHandling(FileData.DuplicateKeysHandlin */ @Override public DataSource createDataSource(ClientContext context, DataSourceUpdates dataSourceUpdates) { - return new FileDataSourceImpl(dataSourceUpdates, sources, autoUpdate, duplicateKeysHandling); + LDLogger logger; + if (context instanceof ClientContext.WithBaseLogger) { + logger = ((ClientContext.WithBaseLogger)context).getBaseLogger().subLogger("DataSource"); + } else { + logger = LDLogger.withAdapter(Logs.none(), ""); + } + return new FileDataSourceImpl(dataSourceUpdates, sources, autoUpdate, duplicateKeysHandling, logger); } static abstract class SourceInfo { diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java index e1ade6b6f..99fdfa98f 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/FileDataSourceImpl.java @@ -1,8 +1,9 @@ package com.launchdarkly.sdk.server.integrations; import com.google.common.collect.ImmutableList; +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.logging.LogValues; import com.launchdarkly.sdk.LDValue; -import com.launchdarkly.sdk.server.LDClient; import com.launchdarkly.sdk.server.integrations.FileDataSourceBuilder.SourceInfo; import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FileDataException; import com.launchdarkly.sdk.server.integrations.FileDataSourceParsing.FlagFactory; @@ -18,9 +19,6 @@ import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import java.io.ByteArrayInputStream; import java.io.IOException; import java.nio.file.FileSystem; @@ -54,28 +52,29 @@ * optionally whenever files change. */ final class FileDataSourceImpl implements DataSource { - private static final Logger logger = LoggerFactory.getLogger(LDClient.class.getName() + ".DataSource"); - private final DataSourceUpdates dataSourceUpdates; private final DataLoader dataLoader; private final FileData.DuplicateKeysHandling duplicateKeysHandling; private final AtomicBoolean inited = new AtomicBoolean(false); private final FileWatcher fileWatcher; + private final LDLogger logger; FileDataSourceImpl( DataSourceUpdates dataSourceUpdates, List sources, boolean autoUpdate, - FileData.DuplicateKeysHandling duplicateKeysHandling + FileData.DuplicateKeysHandling duplicateKeysHandling, + LDLogger logger ) { this.dataSourceUpdates = dataSourceUpdates; this.dataLoader = new DataLoader(sources); this.duplicateKeysHandling = duplicateKeysHandling; + this.logger = logger; FileWatcher fw = null; if (autoUpdate) { try { - fw = FileWatcher.create(dataLoader.getSources()); + fw = FileWatcher.create(dataLoader.getSources(), logger); } catch (IOException e) { // COVERAGE: there is no way to simulate this condition in a unit test logger.error("Unable to watch files for auto-updating: {}", e.toString()); @@ -139,9 +138,10 @@ private static final class FileWatcher implements Runnable { private final Set watchedFilePaths; private Runnable fileModifiedAction; private final Thread thread; + private final LDLogger logger; private volatile boolean stopped; - private static FileWatcher create(Iterable sources) throws IOException { + private static FileWatcher create(Iterable sources, LDLogger logger) throws IOException { Set directoryPaths = new HashSet<>(); Set absoluteFilePaths = new HashSet<>(); FileSystem fs = FileSystems.getDefault(); @@ -159,12 +159,13 @@ private static FileWatcher create(Iterable sources) throws IOExcepti d.register(ws, ENTRY_CREATE, ENTRY_MODIFY, ENTRY_DELETE); } - return new FileWatcher(ws, absoluteFilePaths); + return new FileWatcher(ws, absoluteFilePaths, logger); } - private FileWatcher(WatchService watchService, Set watchedFilePaths) { + private FileWatcher(WatchService watchService, Set watchedFilePaths, LDLogger logger) { this.watchService = watchService; this.watchedFilePaths = watchedFilePaths; + this.logger = logger; thread = new Thread(this, FileDataSourceImpl.class.getName()); thread.setDaemon(true); @@ -193,7 +194,7 @@ public void run() { fileModifiedAction.run(); } catch (Exception e) { // COVERAGE: there is no way to simulate this condition in a unit test - logger.warn("Unexpected exception when reloading file data: " + e); + logger.warn("Unexpected exception when reloading file data: {}", LogValues.exceptionSummary(e)); } } key.reset(); // if we don't do this, the watch on this key stops working diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/LoggingConfigurationBuilder.java b/src/main/java/com/launchdarkly/sdk/server/integrations/LoggingConfigurationBuilder.java index 9816f8962..7a2df54e7 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/LoggingConfigurationBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/LoggingConfigurationBuilder.java @@ -1,5 +1,7 @@ package com.launchdarkly.sdk.server.integrations; +import com.launchdarkly.logging.LDLogAdapter; +import com.launchdarkly.logging.LDLogLevel; import com.launchdarkly.sdk.server.Components; import com.launchdarkly.sdk.server.interfaces.LoggingConfigurationFactory; @@ -30,7 +32,105 @@ public abstract class LoggingConfigurationBuilder implements LoggingConfiguratio */ public static final Duration DEFAULT_LOG_DATA_SOURCE_OUTAGE_AS_ERROR_AFTER = Duration.ofMinutes(1); + protected String baseName = null; protected Duration logDataSourceOutageAsErrorAfter = DEFAULT_LOG_DATA_SOURCE_OUTAGE_AS_ERROR_AFTER; + protected LDLogAdapter logAdapter = null; + protected LDLogLevel minimumLevel = null; + + /** + * Specifies the implementation of logging to use. + *

      + * The com.aunchdarkly.logging + * API defines the {@link LDLogAdapter} interface to specify where log output should be sent. By default, + * it is set to {@link com.launchdarkly.logging.LDSLF4J#adapter()}, meaning that output will be sent to + * SLF4J and controlled by the SLF4J configuration. You may use + * the {@link com.launchdarkly.logging.Logs} factory methods, or a custom implementation, to handle log + * output differently. For instance, you may specify {@link com.launchdarkly.logging.Logs#basic()} for + * simple console output, or {@link com.launchdarkly.logging.Logs#toJavaUtilLogging()} to use the + * java.util.logging framework. + *

      + * For more about logging adapters, + * see the SDK reference guide + * and the API documentation for + * com.launchdarkly.logging. + *

      + * If you don't need to customize any options other than the adapter, you can call + * {@link Components#logging(LDLogAdapter)} as a shortcut rather than using + * {@link LoggingConfigurationBuilder}. + * + * @param logAdapter an {@link LDLogAdapter} for the desired logging implementation + * @return the builder + * @since 5.8.0 + */ + public LoggingConfigurationBuilder adapter(LDLogAdapter logAdapter) { + this.logAdapter = logAdapter; + return this; + } + + /** + * Specifies a custom base logger name. + *

      + * Logger names are used to give context to the log output, indicating that it is from the + * LaunchDarkly SDK instead of another component, or indicating a more specific area of + * functionality within the SDK. Many logging implementations show the logger name in + * in brackets, for instance: + *

      
      +   *     [com.launchdarkly.sdk.LDClient] INFO: Reconnected to LaunchDarkly stream
      +   * 
      + *

      + * If you are using an adapter for a third-party logging framework such as SLF4J (see + * {@link #adapter(LDLogAdapter)}), most frameworks have a mechanism for filtering log + * output by the logger name. + *

      + * By default, the SDK uses a base logger name of com.launchdarkly.sdk.LDClient. + * Messages will be logged either under this name, or with a suffix to indicate what + * general area of functionality is involved: + *

        + *
      • .DataSource: problems or status messages regarding how the SDK gets + * feature flag data from LaunchDarkly.
      • + *
      • .DataStore: problems or status messages regarding how the SDK stores its + * feature flag data (for instance, if you are using a database).
      • + *
      • .Evaluation: problems in evaluating a feature flag or flags, which were + * caused by invalid flag data or incorrect usage of the SDK rather than for instance a + * database problem.
      • + *
      • codec>.Events problems or status messages regarding the SDK's delivery of + * analytics event data to LaunchDarkly.
      • + *
      + *

      + * Setting {@link #baseLoggerName(String)} to a non-null value overrides the default. The + * SDK still adds the same suffixes to the name, so for instance if you set it to + * "LD", the example message above would show [LD.DataSource]. + * + * @param name the base logger name + * @return the builder + * @since 5.8.0 + */ + public LoggingConfigurationBuilder baseLoggerName(String name) { + this.baseName = name; + return this; + } + + /** + * Specifies the lowest level of logging to enable. + *

      + * This adds a log level filter that is applied regardless of what implementation of logging is + * being used, so that log messages at lower levels are suppressed. For instance, setting the + * minimum level to {@link LDLogLevel#INFO} means that DEBUG-level output is disabled. + *

      + * External logging frameworks may also have their own mechanisms for setting a minimum log level. + * For instance, in SLF4J, this can be set on a per-logger-name basis in the SLF4J configuration + * file. Therefore, if {@link #adapter(LDLogAdapter)} is set to the default destination of SLF4J, + * or to Logs.toJavaUtilLogging(), the setting of {@link #level(LDLogLevel)} is + * ignored. Otherwise, the default minimum level is {@link LDLogLevel#INFO}. + * + * @param minimumLevel the lowest level of logging to enable + * @return the builder + * @since 5.8.0 + */ + public LoggingConfigurationBuilder level(LDLogLevel minimumLevel) { + this.minimumLevel = minimumLevel; + return this; + } /** * Sets the time threshold, if any, after which the SDK will log a data source outage at {@code ERROR} diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/ClientContext.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/ClientContext.java index 558777047..43e83b65d 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/ClientContext.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/ClientContext.java @@ -1,5 +1,7 @@ package com.launchdarkly.sdk.server.interfaces; +import com.launchdarkly.logging.LDLogger; + /** * Context information provided by the {@link com.launchdarkly.sdk.server.LDClient} when creating components. *

      @@ -35,4 +37,7 @@ public interface ClientContext { */ public LoggingConfiguration getLogging(); + public interface WithBaseLogger { + public LDLogger getBaseLogger(); + } } diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/EventSenderFactory.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/EventSenderFactory.java index 3625b0d0b..94f909c3a 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/EventSenderFactory.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/EventSenderFactory.java @@ -1,5 +1,7 @@ package com.launchdarkly.sdk.server.interfaces; +import com.launchdarkly.logging.LDLogger; + /** * Interface for a factory that creates some implementation of {@link EventSender}. * @@ -15,4 +17,11 @@ public interface EventSenderFactory { * @return an {@link EventSender} */ EventSender createEventSender(BasicConfiguration basicConfiguration, HttpConfiguration httpConfiguration); + + public interface WithLogger { + EventSender createEventSender( + BasicConfiguration basicConfiguration, + HttpConfiguration httpConfiguration, + LDLogger logger); + } } diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/LoggingConfiguration.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/LoggingConfiguration.java index 695c02f7d..5e85abd43 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/LoggingConfiguration.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/LoggingConfiguration.java @@ -1,5 +1,6 @@ package com.launchdarkly.sdk.server.interfaces; +import com.launchdarkly.logging.LDLogAdapter; import com.launchdarkly.sdk.server.integrations.LoggingConfigurationBuilder; import java.time.Duration; @@ -20,4 +21,9 @@ public interface LoggingConfiguration { * @see LoggingConfigurationBuilder#logDataSourceOutageAsErrorAfter(java.time.Duration) */ Duration getLogDataSourceOutageAsErrorAfter(); + + public interface AdapterOptions { + String getBaseLoggerName(); + LDLogAdapter getLogAdapter(); + } } diff --git a/src/test/java/com/launchdarkly/sdk/server/BaseTest.java b/src/test/java/com/launchdarkly/sdk/server/BaseTest.java new file mode 100644 index 000000000..132d77a3d --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/BaseTest.java @@ -0,0 +1,49 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.logging.LDLogAdapter; +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.logging.LogCapture; +import com.launchdarkly.logging.Logs; + +import org.junit.Rule; +import org.junit.rules.TestWatcher; +import org.junit.runner.Description; + +public class BaseTest { + @Rule public DumpLogIfTestFails dumpLogIfTestFails; + + protected final LDLogAdapter testLogging; + protected final LDLogger testLogger; + protected final LogCapture logCapture; + + protected BaseTest() { + logCapture = Logs.capture(); + testLogging = logCapture; + testLogger = LDLogger.withAdapter(testLogging, ""); + dumpLogIfTestFails = new DumpLogIfTestFails(); + } + + /** + * Creates a configuration builder with the basic properties that we want for all tests unless + * otherwise specified: do not connect to an external data source, do not send events, and + * redirect all logging to the test logger for the current test (which will be printed to the + * console only if the test fails). + * + * @return a configuraiton builder + */ + protected LDConfig.Builder baseConfig() { + return new LDConfig.Builder() + .dataSource(Components.externalUpdatesOnly()) + .events(Components.noEvents()) + .logging(Components.logging(testLogging)); + } + + class DumpLogIfTestFails extends TestWatcher { + @Override + protected void failed(Throwable e, Description description) { + for (LogCapture.Message message: logCapture.getMessages()) { + System.out.println("LOG {" + description.getDisplayName() + "} >>> " + message.toStringWithTimestamp()); + } + } + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/BigSegmentStoreStatusProviderImplTest.java b/src/test/java/com/launchdarkly/sdk/server/BigSegmentStoreStatusProviderImplTest.java index 7d0f96540..923b1ab83 100644 --- a/src/test/java/com/launchdarkly/sdk/server/BigSegmentStoreStatusProviderImplTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/BigSegmentStoreStatusProviderImplTest.java @@ -12,49 +12,50 @@ import org.junit.Test; @SuppressWarnings("javadoc") -public class BigSegmentStoreStatusProviderImplTest extends EasyMockSupport { +public class BigSegmentStoreStatusProviderImplTest extends BaseTest { // We don't need to extensively test status broadcasting behavior, just that the implementation // delegates to the BigSegmentStoreWrapper and EventBroadcasterImpl. private StatusListener mockStatusListener; private EventBroadcasterImpl mockEventBroadcaster; + private final EasyMockSupport mocks = new EasyMockSupport(); @Before @SuppressWarnings("unchecked") public void setup() { - mockEventBroadcaster = strictMock(EventBroadcasterImpl.class); - mockStatusListener = strictMock(StatusListener.class); + mockEventBroadcaster = mocks.strictMock(EventBroadcasterImpl.class); + mockStatusListener = mocks.strictMock(StatusListener.class); } @Test public void statusUnavailableWithNullWrapper() { - replayAll(); + mocks.replayAll(); BigSegmentStoreStatusProviderImpl statusProvider = new BigSegmentStoreStatusProviderImpl(mockEventBroadcaster, null); assertEquals(statusProvider.getStatus(), new Status(false, false)); - verifyAll(); + mocks.verifyAll(); } @Test public void statusDelegatedToWrapper() { - BigSegmentStoreWrapper storeWrapper = strictMock(BigSegmentStoreWrapper.class); + BigSegmentStoreWrapper storeWrapper = mocks.strictMock(BigSegmentStoreWrapper.class); expect(storeWrapper.getStatus()).andReturn(new Status(true, false)).once(); - replayAll(); + mocks.replayAll(); BigSegmentStoreStatusProviderImpl statusProvider = new BigSegmentStoreStatusProviderImpl(mockEventBroadcaster, storeWrapper); assertEquals(statusProvider.getStatus(), new Status(true, false)); - verifyAll(); + mocks.verifyAll(); } @Test public void listenersDelegatedToEventBroadcaster() { mockEventBroadcaster.register(same(mockStatusListener)); mockEventBroadcaster.unregister(same(mockStatusListener)); - replayAll(); + mocks.replayAll(); BigSegmentStoreStatusProviderImpl statusProvider = new BigSegmentStoreStatusProviderImpl(mockEventBroadcaster, null); statusProvider.addStatusListener(mockStatusListener); statusProvider.removeStatusListener(mockStatusListener); - verifyAll(); + mocks.verifyAll(); } } diff --git a/src/test/java/com/launchdarkly/sdk/server/BigSegmentStoreWrapperTest.java b/src/test/java/com/launchdarkly/sdk/server/BigSegmentStoreWrapperTest.java index 1b5a8bb38..14efcd692 100644 --- a/src/test/java/com/launchdarkly/sdk/server/BigSegmentStoreWrapperTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/BigSegmentStoreWrapperTest.java @@ -1,6 +1,7 @@ package com.launchdarkly.sdk.server; import static com.launchdarkly.sdk.server.TestComponents.clientContext; +import static com.launchdarkly.sdk.server.TestComponents.nullLogger; import static com.launchdarkly.sdk.server.TestComponents.sharedExecutor; import static com.launchdarkly.sdk.server.interfaces.BigSegmentStoreTypes.createMembershipFromSegmentRefs; import static org.easymock.EasyMock.expect; @@ -33,9 +34,10 @@ import java.util.concurrent.atomic.AtomicReference; @SuppressWarnings("javadoc") -public class BigSegmentStoreWrapperTest extends EasyMockSupport { +public class BigSegmentStoreWrapperTest extends BaseTest { private static final String SDK_KEY = "sdk-key"; + private final EasyMockSupport mocks = new EasyMockSupport(); private AtomicBoolean storeUnavailable; private AtomicReference storeMetadata; private BigSegmentStore storeMock; @@ -44,20 +46,24 @@ public class BigSegmentStoreWrapperTest extends EasyMockSupport { @Before public void setup() { - eventBroadcaster = EventBroadcasterImpl.forBigSegmentStoreStatus(sharedExecutor); + eventBroadcaster = EventBroadcasterImpl.forBigSegmentStoreStatus(sharedExecutor, nullLogger); storeUnavailable = new AtomicBoolean(false); storeMetadata = new AtomicReference<>(null); - storeMock = niceMock(BigSegmentStore.class); + storeMock = mocks.niceMock(BigSegmentStore.class); expect(storeMock.getMetadata()).andAnswer(() -> { if (storeUnavailable.get()) { throw new RuntimeException("sorry"); } return storeMetadata.get(); }).anyTimes(); - storeFactoryMock = strictMock(BigSegmentStoreFactory.class); + storeFactoryMock = mocks.strictMock(BigSegmentStoreFactory.class); expect(storeFactoryMock.createBigSegmentStore(isA(ClientContext.class))).andReturn(storeMock); } + private BigSegmentStoreWrapper makeWrapper(BigSegmentsConfiguration bsConfig) { + return new BigSegmentStoreWrapper(bsConfig, eventBroadcaster, sharedExecutor, testLogger); + } + private void setStoreMembership(String userKey, Membership membership) { expect(storeMock.getMembership(BigSegmentStoreWrapper.hashForUserKey(userKey))).andReturn(membership); } @@ -68,13 +74,13 @@ public void membershipQueryWithUncachedResultAndHealthyStatus() throws Exception String userKey = "userkey"; setStoreMembership(userKey, expectedMembership); - replayAll(); + mocks.replayAll(); storeMetadata.set(new StoreMetadata(System.currentTimeMillis())); BigSegmentsConfiguration bsConfig = Components.bigSegments(storeFactoryMock) .staleAfter(Duration.ofDays(1)) .createBigSegmentsConfiguration(clientContext(SDK_KEY, new LDConfig.Builder().build())); - try (BigSegmentStoreWrapper wrapper = new BigSegmentStoreWrapper(bsConfig, eventBroadcaster, sharedExecutor)) { + try (BigSegmentStoreWrapper wrapper = makeWrapper(bsConfig)) { BigSegmentsQueryResult res = wrapper.getUserMembership(userKey); assertEquals(expectedMembership, res.membership); assertEquals(BigSegmentsStatus.HEALTHY, res.status); @@ -85,13 +91,13 @@ public void membershipQueryWithUncachedResultAndHealthyStatus() throws Exception public void membershipQueryReturnsNull() throws Exception { String userKey = "userkey"; setStoreMembership(userKey, null); - replayAll(); + mocks.replayAll(); storeMetadata.set(new StoreMetadata(System.currentTimeMillis())); BigSegmentsConfiguration bsConfig = Components.bigSegments(storeFactoryMock) .staleAfter(Duration.ofDays(1)) .createBigSegmentsConfiguration(clientContext(SDK_KEY, new LDConfig.Builder().build())); - try (BigSegmentStoreWrapper wrapper = new BigSegmentStoreWrapper(bsConfig, eventBroadcaster, sharedExecutor)) { + try (BigSegmentStoreWrapper wrapper = makeWrapper(bsConfig)) { BigSegmentsQueryResult res = wrapper.getUserMembership(userKey); assertEquals(createMembershipFromSegmentRefs(null, null), res.membership); assertEquals(BigSegmentsStatus.HEALTHY, res.status); @@ -104,13 +110,13 @@ public void membershipQueryWithCachedResultAndHealthyStatus() throws Exception { String userKey = "userkey"; setStoreMembership(userKey, expectedMembership); - replayAll(); + mocks.replayAll(); storeMetadata.set(new StoreMetadata(System.currentTimeMillis())); BigSegmentsConfiguration bsConfig = Components.bigSegments(storeFactoryMock) .staleAfter(Duration.ofDays(1)) .createBigSegmentsConfiguration(clientContext(SDK_KEY, new LDConfig.Builder().build())); - try (BigSegmentStoreWrapper wrapper = new BigSegmentStoreWrapper(bsConfig, eventBroadcaster, sharedExecutor)) { + try (BigSegmentStoreWrapper wrapper = makeWrapper(bsConfig)) { BigSegmentsQueryResult res1 = wrapper.getUserMembership(userKey); assertEquals(expectedMembership, res1.membership); assertEquals(BigSegmentsStatus.HEALTHY, res1.status); @@ -127,13 +133,13 @@ public void membershipQueryWithStaleStatus() throws Exception { String userKey = "userkey"; setStoreMembership(userKey, expectedMembership); - replayAll(); + mocks.replayAll(); storeMetadata.set(new StoreMetadata(System.currentTimeMillis() - 1000)); BigSegmentsConfiguration bsConfig = Components.bigSegments(storeFactoryMock) .staleAfter(Duration.ofMillis(500)) .createBigSegmentsConfiguration(clientContext(SDK_KEY, new LDConfig.Builder().build())); - try (BigSegmentStoreWrapper wrapper = new BigSegmentStoreWrapper(bsConfig, eventBroadcaster, sharedExecutor)) { + try (BigSegmentStoreWrapper wrapper = makeWrapper(bsConfig)) { BigSegmentsQueryResult res = wrapper.getUserMembership(userKey); assertEquals(expectedMembership, res.membership); assertEquals(BigSegmentsStatus.STALE, res.status); @@ -146,13 +152,13 @@ public void membershipQueryWithStaleStatusDueToNoStoreMetadata() throws Exceptio String userKey = "userkey"; setStoreMembership(userKey, expectedMembership); - replayAll(); + mocks.replayAll(); storeMetadata.set(null); BigSegmentsConfiguration bsConfig = Components.bigSegments(storeFactoryMock) .staleAfter(Duration.ofMillis(500)) .createBigSegmentsConfiguration(clientContext(SDK_KEY, new LDConfig.Builder().build())); - try (BigSegmentStoreWrapper wrapper = new BigSegmentStoreWrapper(bsConfig, eventBroadcaster, sharedExecutor)) { + try (BigSegmentStoreWrapper wrapper = makeWrapper(bsConfig)) { BigSegmentsQueryResult res = wrapper.getUserMembership(userKey); assertEquals(expectedMembership, res.membership); assertEquals(BigSegmentsStatus.STALE, res.status); @@ -170,14 +176,14 @@ public void leastRecentUserIsEvictedFromCache() throws Exception { setStoreMembership(userKey3, expectedMembership3); setStoreMembership(userKey1, expectedMembership1); - replayAll(); + mocks.replayAll(); storeMetadata.set(new StoreMetadata(System.currentTimeMillis())); BigSegmentsConfiguration bsConfig = Components.bigSegments(storeFactoryMock) .userCacheSize(2) .staleAfter(Duration.ofDays(1)) .createBigSegmentsConfiguration(clientContext(SDK_KEY, new LDConfig.Builder().build())); - try (BigSegmentStoreWrapper wrapper = new BigSegmentStoreWrapper(bsConfig, eventBroadcaster, sharedExecutor)) { + try (BigSegmentStoreWrapper wrapper = makeWrapper(bsConfig)) { BigSegmentsQueryResult res1 = wrapper.getUserMembership(userKey1); assertEquals(expectedMembership1, res1.membership); assertEquals(BigSegmentsStatus.HEALTHY, res1.status); @@ -206,14 +212,14 @@ public void leastRecentUserIsEvictedFromCache() throws Exception { @Test public void pollingDetectsStoreUnavailability() throws Exception { - replayAll(); + mocks.replayAll(); storeMetadata.set(new StoreMetadata(System.currentTimeMillis())); BigSegmentsConfiguration bsConfig = Components.bigSegments(storeFactoryMock) .statusPollInterval(Duration.ofMillis(10)) .staleAfter(Duration.ofDays(1)) .createBigSegmentsConfiguration(clientContext(SDK_KEY, new LDConfig.Builder().build())); - try (BigSegmentStoreWrapper wrapper = new BigSegmentStoreWrapper(bsConfig, eventBroadcaster, sharedExecutor)) { + try (BigSegmentStoreWrapper wrapper = makeWrapper(bsConfig)) { assertTrue(wrapper.getStatus().isAvailable()); BlockingQueue statuses = new LinkedBlockingQueue<>(); @@ -233,14 +239,14 @@ public void pollingDetectsStoreUnavailability() throws Exception { @Test public void pollingDetectsStaleStatus() throws Exception { - replayAll(); + mocks.replayAll(); storeMetadata.set(new StoreMetadata(System.currentTimeMillis() + 5000)); BigSegmentsConfiguration bsConfig = Components.bigSegments(storeFactoryMock) .statusPollInterval(Duration.ofMillis(10)) .staleAfter(Duration.ofMillis(200)) .createBigSegmentsConfiguration(clientContext(SDK_KEY, new LDConfig.Builder().build())); - try (BigSegmentStoreWrapper wrapper = new BigSegmentStoreWrapper(bsConfig, eventBroadcaster, sharedExecutor)) { + try (BigSegmentStoreWrapper wrapper = makeWrapper(bsConfig)) { assertFalse(wrapper.getStatus().isStale()); BlockingQueue statuses = new LinkedBlockingQueue<>(); diff --git a/src/test/java/com/launchdarkly/sdk/server/DataSourceStatusProviderImplTest.java b/src/test/java/com/launchdarkly/sdk/server/DataSourceStatusProviderImplTest.java index f229b7424..d7648a00d 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataSourceStatusProviderImplTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataSourceStatusProviderImplTest.java @@ -24,16 +24,17 @@ import static org.hamcrest.Matchers.sameInstance; @SuppressWarnings("javadoc") -public class DataSourceStatusProviderImplTest { +public class DataSourceStatusProviderImplTest extends BaseTest { private EventBroadcasterImpl broadcaster = - EventBroadcasterImpl.forDataSourceStatus(sharedExecutor); + EventBroadcasterImpl.forDataSourceStatus(sharedExecutor, testLogger); private DataSourceUpdatesImpl updates = new DataSourceUpdatesImpl( TestComponents.inMemoryDataStore(), null, null, broadcaster, sharedExecutor, - null + null, + testLogger ); private DataSourceStatusProviderImpl statusProvider = new DataSourceStatusProviderImpl(broadcaster, updates); diff --git a/src/test/java/com/launchdarkly/sdk/server/DataSourceUpdatesImplTest.java b/src/test/java/com/launchdarkly/sdk/server/DataSourceUpdatesImplTest.java index 319dd8648..25ae6cee5 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataSourceUpdatesImplTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataSourceUpdatesImplTest.java @@ -33,6 +33,7 @@ import static com.launchdarkly.sdk.server.ModelBuilders.ruleBuilder; import static com.launchdarkly.sdk.server.ModelBuilders.segmentBuilder; import static com.launchdarkly.sdk.server.TestComponents.inMemoryDataStore; +import static com.launchdarkly.sdk.server.TestComponents.nullLogger; import static com.launchdarkly.sdk.server.TestComponents.sharedExecutor; import static com.launchdarkly.sdk.server.TestUtil.expectEvents; import static com.launchdarkly.testhelpers.ConcurrentHelpers.assertNoMoreValues; @@ -44,12 +45,13 @@ import static org.hamcrest.Matchers.is; @SuppressWarnings("javadoc") -public class DataSourceUpdatesImplTest extends EasyMockSupport { +public class DataSourceUpdatesImplTest { // Note that these tests must use the actual data model types for flags and segments, rather than the // TestItem type from DataStoreTestTypes, because the dependency behavior is based on the real data model. - private EventBroadcasterImpl flagChangeBroadcaster = - EventBroadcasterImpl.forFlagChangeEvents(TestComponents.sharedExecutor); + private final EventBroadcasterImpl flagChangeBroadcaster = + EventBroadcasterImpl.forFlagChangeEvents(TestComponents.sharedExecutor, nullLogger); + private final EasyMockSupport mocks = new EasyMockSupport(); private DataSourceUpdatesImpl makeInstance(DataStore store) { return makeInstance(store, null); @@ -59,7 +61,7 @@ private DataSourceUpdatesImpl makeInstance( DataStore store, EventBroadcasterImpl statusBroadcaster ) { - return new DataSourceUpdatesImpl(store, null, flagChangeBroadcaster, statusBroadcaster, sharedExecutor, null); + return new DataSourceUpdatesImpl(store, null, flagChangeBroadcaster, statusBroadcaster, sharedExecutor, null, nullLogger); } @Test @@ -336,7 +338,7 @@ public void dataSetIsPassedToDataStoreInCorrectOrder() throws Exception { // The logic for this is already tested in DataModelDependenciesTest, but here we are verifying // that DataSourceUpdatesImpl is actually using DataModelDependencies. Capture> captureData = Capture.newInstance(); - DataStore store = createStrictMock(DataStore.class); + DataStore store = mocks.createStrictMock(DataStore.class); store.init(EasyMock.capture(captureData)); replay(store); @@ -351,7 +353,7 @@ public void dataSetIsPassedToDataStoreInCorrectOrder() throws Exception { @Test public void updateStatusBroadcastsNewStatus() { EventBroadcasterImpl broadcaster = - EventBroadcasterImpl.forDataSourceStatus(sharedExecutor); + EventBroadcasterImpl.forDataSourceStatus(sharedExecutor, nullLogger); DataSourceUpdatesImpl updates = makeInstance(inMemoryDataStore(), broadcaster); BlockingQueue statuses = new LinkedBlockingQueue<>(); @@ -371,7 +373,7 @@ public void updateStatusBroadcastsNewStatus() { @Test public void updateStatusKeepsStateUnchangedIfStateWasInitializingAndNewStateIsInterrupted() { EventBroadcasterImpl broadcaster = - EventBroadcasterImpl.forDataSourceStatus(sharedExecutor); + EventBroadcasterImpl.forDataSourceStatus(sharedExecutor, nullLogger); DataSourceUpdatesImpl updates = makeInstance(inMemoryDataStore(), broadcaster); assertThat(updates.getLastStatus().getState(), is(State.INITIALIZING)); @@ -393,7 +395,7 @@ public void updateStatusKeepsStateUnchangedIfStateWasInitializingAndNewStateIsIn @Test public void updateStatusDoesNothingIfParametersHaveNoNewData() { EventBroadcasterImpl broadcaster = - EventBroadcasterImpl.forDataSourceStatus(sharedExecutor); + EventBroadcasterImpl.forDataSourceStatus(sharedExecutor, nullLogger); DataSourceUpdatesImpl updates = makeInstance(inMemoryDataStore(), broadcaster); BlockingQueue statuses = new LinkedBlockingQueue<>(); @@ -414,9 +416,10 @@ public void outageTimeoutLogging() throws Exception { inMemoryDataStore(), null, flagChangeBroadcaster, - EventBroadcasterImpl.forDataSourceStatus(sharedExecutor), + EventBroadcasterImpl.forDataSourceStatus(sharedExecutor, nullLogger), sharedExecutor, - outageTimeout + outageTimeout, + nullLogger ); updates.onOutageErrorLog = outageErrors::add; diff --git a/src/test/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImplTest.java b/src/test/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImplTest.java index bfcd7878c..de9208ea8 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImplTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataStoreStatusProviderImplTest.java @@ -24,9 +24,9 @@ import static org.hamcrest.Matchers.nullValue; @SuppressWarnings("javadoc") -public class DataStoreStatusProviderImplTest { +public class DataStoreStatusProviderImplTest extends BaseTest { private EventBroadcasterImpl broadcaster = - EventBroadcasterImpl.forDataStoreStatus(sharedExecutor); + EventBroadcasterImpl.forDataStoreStatus(sharedExecutor, testLogger); private MockDataStore store = new MockDataStore(); private DataStoreUpdatesImpl updates = new DataStoreUpdatesImpl(broadcaster); private DataStoreStatusProviderImpl statusProvider = new DataStoreStatusProviderImpl(store, updates); diff --git a/src/test/java/com/launchdarkly/sdk/server/DataStoreUpdatesImplTest.java b/src/test/java/com/launchdarkly/sdk/server/DataStoreUpdatesImplTest.java index 1ed1691ff..b2176ba80 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataStoreUpdatesImplTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataStoreUpdatesImplTest.java @@ -16,9 +16,9 @@ import static org.hamcrest.Matchers.equalTo; @SuppressWarnings("javadoc") -public class DataStoreUpdatesImplTest { +public class DataStoreUpdatesImplTest extends BaseTest { private EventBroadcasterImpl broadcaster = - EventBroadcasterImpl.forDataStoreStatus(sharedExecutor); + EventBroadcasterImpl.forDataStoreStatus(sharedExecutor, testLogger); private final DataStoreUpdatesImpl updates = new DataStoreUpdatesImpl(broadcaster); @Test diff --git a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorDiagnosticsTest.java b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorDiagnosticsTest.java index b018737f7..0b4669aea 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorDiagnosticsTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorDiagnosticsTest.java @@ -133,7 +133,7 @@ public void periodicDiagnosticEventsAreSentAutomatically() throws Exception { EventsConfiguration eventsConfig = makeEventsConfigurationWithBriefDiagnosticInterval(es); try (DefaultEventProcessor ep = new DefaultEventProcessor(eventsConfig, sharedExecutor, Thread.MAX_PRIORITY, - diagnosticAccumulator, initEvent)) { + diagnosticAccumulator, initEvent, testLogger)) { // Ignore the initial diagnostic event es.awaitRequest(); @@ -180,7 +180,7 @@ public void diagnosticEventsStopAfter401Error() throws Exception { EventsConfiguration eventsConfig = makeEventsConfigurationWithBriefDiagnosticInterval(es); try (DefaultEventProcessor ep = new DefaultEventProcessor(eventsConfig, sharedExecutor, Thread.MAX_PRIORITY, - diagnosticAccumulator, initEvent)) { + diagnosticAccumulator, initEvent, testLogger)) { // Ignore the initial diagnostic event es.awaitRequest(); diff --git a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java index 6946627ec..c18ee6d51 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java @@ -110,7 +110,8 @@ public void eventsAreFlushedAutomatically() throws Exception { Duration.ofSeconds(5), null ); - try (DefaultEventProcessor ep = new DefaultEventProcessor(eventsConfig, sharedExecutor, Thread.MAX_PRIORITY, null, null)) { + try (DefaultEventProcessor ep = new DefaultEventProcessor(eventsConfig, sharedExecutor, Thread.MAX_PRIORITY, + null, null, testLogger)) { Event.Custom event1 = EventFactory.DEFAULT.newCustomEvent("event1", user, null, null); Event.Custom event2 = EventFactory.DEFAULT.newCustomEvent("event2", user, null, null); ep.sendEvent(event1); @@ -189,7 +190,7 @@ public void userKeysAreFlushedAutomatically() throws Exception { null ); try (DefaultEventProcessor ep = new DefaultEventProcessor(eventsConfig, sharedExecutor, Thread.MAX_PRIORITY, - null, null)) { + null, null, testLogger)) { Event.Custom event1 = EventFactory.DEFAULT.newCustomEvent("event1", user, null, null); Event.Custom event2 = EventFactory.DEFAULT.newCustomEvent("event2", user, null, null); ep.sendEvent(event1); diff --git a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTestBase.java b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTestBase.java index ddf6a7b5d..d4222c247 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTestBase.java +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTestBase.java @@ -37,7 +37,7 @@ import static org.junit.Assert.assertNotNull; @SuppressWarnings("javadoc") -public abstract class DefaultEventProcessorTestBase { +public abstract class DefaultEventProcessorTestBase extends BaseTest { public static final String SDK_KEY = "SDK_KEY"; public static final URI FAKE_URI = URI.create("http://fake"); public static final LDUser user = new LDUser.Builder("userkey").name("Red").build(); @@ -45,8 +45,6 @@ public abstract class DefaultEventProcessorTestBase { public static final LDValue userJson = LDValue.buildObject().put("key", "userkey").put("name", "Red").build(); public static final LDValue filteredUserJson = LDValue.buildObject().put("key", "userkey") .put("privateAttrs", LDValue.buildArray().add("name").build()).build(); - public static final LDConfig baseLDConfig = new LDConfig.Builder().diagnosticOptOut(true).build(); - public 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. @@ -55,17 +53,21 @@ public static EventProcessorBuilder baseConfig(MockEventSender es) { return sendEvents().eventSender(senderFactory(es)); } - public static DefaultEventProcessor makeEventProcessor(EventProcessorBuilder ec) { - return makeEventProcessor(ec, baseLDConfig); + public DefaultEventProcessor makeEventProcessor(EventProcessorBuilder ec) { + LDConfig config = new LDConfig.Builder().diagnosticOptOut(true) + .logging(Components.logging(testLogging)).build(); + return makeEventProcessor(ec, config); } - public static DefaultEventProcessor makeEventProcessor(EventProcessorBuilder ec, LDConfig config) { + public DefaultEventProcessor makeEventProcessor(EventProcessorBuilder ec, LDConfig config) { return (DefaultEventProcessor)ec.createEventProcessor(clientContext(SDK_KEY, config)); } - public static DefaultEventProcessor makeEventProcessor(EventProcessorBuilder ec, DiagnosticAccumulator diagnosticAccumulator) { + public DefaultEventProcessor makeEventProcessor(EventProcessorBuilder ec, DiagnosticAccumulator diagnosticAccumulator) { + LDConfig config = new LDConfig.Builder().diagnosticOptOut(false) + .logging(Components.logging(testLogging)).build(); return (DefaultEventProcessor)ec.createEventProcessor( - clientContext(SDK_KEY, diagLDConfig, diagnosticAccumulator)); + clientContext(SDK_KEY, config, diagnosticAccumulator)); } public static EventSenderFactory senderFactory(final MockEventSender es) { diff --git a/src/test/java/com/launchdarkly/sdk/server/DefaultEventSenderTest.java b/src/test/java/com/launchdarkly/sdk/server/DefaultEventSenderTest.java index c2f61279c..e66715069 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DefaultEventSenderTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultEventSenderTest.java @@ -35,21 +35,22 @@ import static org.junit.Assert.assertTrue; @SuppressWarnings("javadoc") -public class DefaultEventSenderTest { +public class DefaultEventSenderTest extends BaseTest { 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", Locale.US); private static final Duration BRIEF_RETRY_DELAY = Duration.ofMillis(50); - private static EventSender makeEventSender() { + private EventSender makeEventSender() { return makeEventSender(LDConfig.DEFAULT); } - private static EventSender makeEventSender(LDConfig config) { + private EventSender makeEventSender(LDConfig config) { return new DefaultEventSender( clientContext(SDK_KEY, config).getHttp(), - BRIEF_RETRY_DELAY + BRIEF_RETRY_DELAY, + testLogger ); } @@ -66,7 +67,7 @@ public void factoryCreatesDefaultSenderWithDefaultRetryDelay() throws Exception @Test public void constructorUsesDefaultRetryDelayIfNotSpecified() throws Exception { ClientContext context = clientContext(SDK_KEY, LDConfig.DEFAULT); - try (EventSender es = new DefaultEventSender(context.getHttp(), null)) { + try (EventSender es = new DefaultEventSender(context.getHttp(), null, testLogger)) { assertThat(((DefaultEventSender)es).retryDelay, equalTo(DefaultEventSender.DEFAULT_RETRY_DELAY)); } } diff --git a/src/test/java/com/launchdarkly/sdk/server/DefaultFeatureRequestorTest.java b/src/test/java/com/launchdarkly/sdk/server/DefaultFeatureRequestorTest.java index 2c3f52587..0d75559d4 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DefaultFeatureRequestorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultFeatureRequestorTest.java @@ -21,7 +21,7 @@ import static org.junit.Assert.fail; @SuppressWarnings("javadoc") -public class DefaultFeatureRequestorTest { +public class DefaultFeatureRequestorTest extends BaseTest { private static final String sdkKey = "sdk-key"; private static final String flag1Key = "flag1"; private static final String flag1Json = "{\"key\":\"" + flag1Key + "\"}"; @@ -38,7 +38,7 @@ private DefaultFeatureRequestor makeRequestor(HttpServer server) { } private DefaultFeatureRequestor makeRequestor(HttpServer server, LDConfig config) { - return new DefaultFeatureRequestor(makeHttpConfig(config), server.getUri()); + return new DefaultFeatureRequestor(makeHttpConfig(config), server.getUri(), testLogger); } private HttpConfiguration makeHttpConfig(LDConfig config) { @@ -141,7 +141,7 @@ public void testSpecialHttpConfigurations() throws Exception { TestHttpUtil.testWithSpecialHttpConfigurations(handler, (targetUri, goodHttpConfig) -> { LDConfig config = new LDConfig.Builder().http(goodHttpConfig).build(); - try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(makeHttpConfig(config), targetUri)) { + try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(makeHttpConfig(config), targetUri, testLogger)) { try { FeatureRequestor.AllData data = r.getAllData(false); verifyExpectedData(data); @@ -153,7 +153,7 @@ public void testSpecialHttpConfigurations() throws Exception { (targetUri, badHttpConfig) -> { LDConfig config = new LDConfig.Builder().http(badHttpConfig).build(); - try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(makeHttpConfig(config), targetUri)) { + try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(makeHttpConfig(config), targetUri, testLogger)) { try { r.getAllData(false); fail("expected exception"); @@ -169,7 +169,7 @@ public void baseUriDoesNotNeedTrailingSlash() throws Exception { Handler resp = Handlers.bodyJson(allDataJson); try (HttpServer server = HttpServer.start(resp)) { - try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(makeHttpConfig(LDConfig.DEFAULT), server.getUri())) { + try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(makeHttpConfig(LDConfig.DEFAULT), server.getUri(), testLogger)) { FeatureRequestor.AllData data = r.getAllData(true); RequestInfo req = server.getRecorder().requireRequest(); @@ -188,7 +188,7 @@ public void baseUriCanHaveContextPath() throws Exception { try (HttpServer server = HttpServer.start(resp)) { URI uri = server.getUri().resolve("/context/path"); - try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(makeHttpConfig(LDConfig.DEFAULT), uri)) { + try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(makeHttpConfig(LDConfig.DEFAULT), uri, testLogger)) { FeatureRequestor.AllData data = r.getAllData(true); RequestInfo req = server.getRecorder().requireRequest(); diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorBigSegmentTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorBigSegmentTest.java index 0ce40e8f8..a4193b6dd 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EvaluatorBigSegmentTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorBigSegmentTest.java @@ -1,7 +1,15 @@ package com.launchdarkly.sdk.server; +import com.launchdarkly.sdk.EvaluationReason.BigSegmentsStatus; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.Evaluator.EvalResult; + +import org.junit.Test; + +import java.util.Collections; + import static com.launchdarkly.sdk.server.Evaluator.makeBigSegmentRef; -import static com.launchdarkly.sdk.server.EvaluatorTestUtil.evaluatorBuilder; import static com.launchdarkly.sdk.server.ModelBuilders.booleanFlagWithClauses; import static com.launchdarkly.sdk.server.ModelBuilders.clauseMatchingSegment; import static com.launchdarkly.sdk.server.ModelBuilders.clauseMatchingUser; @@ -15,17 +23,8 @@ import static org.easymock.EasyMock.strictMock; import static org.junit.Assert.assertEquals; -import com.launchdarkly.sdk.EvaluationReason.BigSegmentsStatus; -import com.launchdarkly.sdk.LDUser; -import com.launchdarkly.sdk.LDValue; -import com.launchdarkly.sdk.server.Evaluator.EvalResult; - -import org.junit.Test; - -import java.util.Collections; - @SuppressWarnings("javadoc") -public class EvaluatorBigSegmentTest { +public class EvaluatorBigSegmentTest extends EvaluatorTestBase { private static final LDUser testUser = new LDUser("userkey"); @Test @@ -141,7 +140,7 @@ public void bigSegmentStateIsQueriedOnlyOncePerUserEvenIfFlagReferencesMultipleS expect(mockGetters.getSegment(segment2.getKey())).andReturn(segment2); replay(mockGetters); - Evaluator evaluator = new Evaluator(mockGetters); + Evaluator evaluator = new Evaluator(mockGetters, testLogger); EvalResult result = evaluator.evaluate(flag, testUser, EventFactory.DEFAULT); assertEquals(LDValue.of(true), result.getValue()); assertEquals(BigSegmentsStatus.HEALTHY, result.getReason().getBigSegmentsStatus()); diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorClauseTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorClauseTest.java index 01e24eb22..59802f170 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EvaluatorClauseTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorClauseTest.java @@ -10,7 +10,6 @@ import static com.launchdarkly.sdk.EvaluationDetail.fromValue; import static com.launchdarkly.sdk.server.EvaluatorTestUtil.BASE_EVALUATOR; -import static com.launchdarkly.sdk.server.EvaluatorTestUtil.evaluatorBuilder; import static com.launchdarkly.sdk.server.ModelBuilders.booleanFlagWithClauses; import static com.launchdarkly.sdk.server.ModelBuilders.clause; import static com.launchdarkly.sdk.server.ModelBuilders.fallthroughVariation; @@ -23,7 +22,7 @@ import static org.junit.Assert.assertNotNull; @SuppressWarnings("javadoc") -public class EvaluatorClauseTest { +public class EvaluatorClauseTest extends EvaluatorTestBase { private static void assertMatch(Evaluator eval, DataModel.FeatureFlag flag, LDUser user, boolean expectMatch) { assertEquals(LDValue.of(expectMatch), eval.evaluate(flag, user, EventFactory.DEFAULT).getDetails().getValue()); } diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorSegmentMatchTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorSegmentMatchTest.java index 90b02a9bc..a285f3e93 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EvaluatorSegmentMatchTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorSegmentMatchTest.java @@ -6,7 +6,6 @@ import org.junit.Test; -import static com.launchdarkly.sdk.server.EvaluatorTestUtil.evaluatorBuilder; import static com.launchdarkly.sdk.server.ModelBuilders.booleanFlagWithClauses; import static com.launchdarkly.sdk.server.ModelBuilders.clause; import static com.launchdarkly.sdk.server.ModelBuilders.segmentBuilder; @@ -15,7 +14,7 @@ import static org.junit.Assert.assertTrue; @SuppressWarnings("javadoc") -public class EvaluatorSegmentMatchTest { +public class EvaluatorSegmentMatchTest extends EvaluatorTestBase { private int maxWeight = 100000; @@ -110,7 +109,7 @@ public void nonMatchingRuleWithMultipleClauses() { assertFalse(segmentMatchesUser(s, u)); } - private static boolean segmentMatchesUser(DataModel.Segment segment, LDUser user) { + private boolean segmentMatchesUser(DataModel.Segment segment, LDUser user) { DataModel.Clause clause = clause(null, DataModel.Operator.segmentMatch, LDValue.of(segment.getKey())); DataModel.FeatureFlag flag = booleanFlagWithClauses("flag", clause); Evaluator e = evaluatorBuilder().withStoredSegments(segment).build(); diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java index 6864c9bf3..ef9991ff3 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java @@ -20,7 +20,6 @@ import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; import static com.launchdarkly.sdk.EvaluationDetail.fromValue; import static com.launchdarkly.sdk.server.EvaluatorTestUtil.BASE_EVALUATOR; -import static com.launchdarkly.sdk.server.EvaluatorTestUtil.evaluatorBuilder; import static com.launchdarkly.sdk.server.ModelBuilders.clause; import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; import static com.launchdarkly.sdk.server.ModelBuilders.prerequisite; @@ -34,7 +33,7 @@ import static org.junit.Assert.assertSame; @SuppressWarnings("javadoc") -public class EvaluatorTest { +public class EvaluatorTest extends EvaluatorTestBase { private static final LDUser BASE_USER = new LDUser.Builder("x").build(); // These constants and flag builders define two kinds of flag: one with three variations-- allowing us to diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorTestBase.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTestBase.java new file mode 100644 index 000000000..5d2a7025c --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTestBase.java @@ -0,0 +1,10 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.EvaluatorTestUtil.EvaluatorBuilder; + +@SuppressWarnings("javadoc") +public class EvaluatorTestBase extends BaseTest { + public EvaluatorBuilder evaluatorBuilder() { + return new EvaluatorBuilder(testLogger); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorTestUtil.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTestUtil.java index af105256a..e0c5d44ee 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EvaluatorTestUtil.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTestUtil.java @@ -1,46 +1,53 @@ package com.launchdarkly.sdk.server; +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.logging.Logs; import com.launchdarkly.sdk.server.BigSegmentStoreWrapper.BigSegmentsQueryResult; import java.util.HashMap; @SuppressWarnings("javadoc") public abstract class EvaluatorTestUtil { - public static Evaluator BASE_EVALUATOR = evaluatorBuilder().build(); + public static Evaluator BASE_EVALUATOR = new EvaluatorBuilder().build(); - public static EvaluatorBuilder evaluatorBuilder() { - return new EvaluatorBuilder(); - } - public static class EvaluatorBuilder { HashMap flagMap = new HashMap<>(); HashMap segmentMap = new HashMap<>(); HashMap bigSegmentMap = new HashMap<>(); + private final LDLogger logger; + EvaluatorBuilder() { + this(LDLogger.withAdapter(Logs.none(), "")); + } + + EvaluatorBuilder(LDLogger logger) { + this.logger = logger; + } + public Evaluator build() { return new Evaluator(new Evaluator.Getters() { - public DataModel.FeatureFlag getFlag(String key) { - if (!flagMap.containsKey(key)) { - throw new IllegalStateException("Evaluator unexpectedly tried to query flag: " + key); - } - return flagMap.get(key); + public DataModel.FeatureFlag getFlag(String key) { + if (!flagMap.containsKey(key)) { + throw new IllegalStateException("Evaluator unexpectedly tried to query flag: " + key); } + return flagMap.get(key); + } - public DataModel.Segment getSegment(String key) { - if (!segmentMap.containsKey(key)) { - throw new IllegalStateException("Evaluator unexpectedly tried to query segment: " + key); - } - return segmentMap.get(key); + public DataModel.Segment getSegment(String key) { + if (!segmentMap.containsKey(key)) { + throw new IllegalStateException("Evaluator unexpectedly tried to query segment: " + key); } + return segmentMap.get(key); + } - public BigSegmentsQueryResult getBigSegments(String key) { - if (!bigSegmentMap.containsKey(key)) { - throw new IllegalStateException("Evaluator unexpectedly tried to query Big Segment: " + key); - } - return bigSegmentMap.get(key); + public BigSegmentsQueryResult getBigSegments(String key) { + if (!bigSegmentMap.containsKey(key)) { + throw new IllegalStateException("Evaluator unexpectedly tried to query Big Segment: " + key); } - }); - } + return bigSegmentMap.get(key); + } + }, logger); + } public EvaluatorBuilder withStoredFlags(final DataModel.FeatureFlag... flags) { for (DataModel.FeatureFlag f: flags) { diff --git a/src/test/java/com/launchdarkly/sdk/server/EventBroadcasterImplTest.java b/src/test/java/com/launchdarkly/sdk/server/EventBroadcasterImplTest.java index 721cf87ac..bfe25b1cf 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EventBroadcasterImplTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EventBroadcasterImplTest.java @@ -10,9 +10,9 @@ import static org.hamcrest.Matchers.is; @SuppressWarnings("javadoc") -public class EventBroadcasterImplTest { +public class EventBroadcasterImplTest extends BaseTest { private EventBroadcasterImpl broadcaster = - new EventBroadcasterImpl<>(FakeListener::sendEvent, sharedExecutor); + new EventBroadcasterImpl<>(FakeListener::sendEvent, sharedExecutor, testLogger); @Test public void sendingEventWithNoListenersDoesNotCauseError() { @@ -21,7 +21,7 @@ public void sendingEventWithNoListenersDoesNotCauseError() { @Test public void sendingEventWithNoExecutorDoesNotCauseError() { - new EventBroadcasterImpl<>(FakeListener::sendEvent, null).broadcast(new FakeEvent()); + new EventBroadcasterImpl<>(FakeListener::sendEvent, null, testLogger).broadcast(new FakeEvent()); } @Test diff --git a/src/test/java/com/launchdarkly/sdk/server/FlagTrackerImplTest.java b/src/test/java/com/launchdarkly/sdk/server/FlagTrackerImplTest.java index 0b2d775c1..05a4f36e4 100644 --- a/src/test/java/com/launchdarkly/sdk/server/FlagTrackerImplTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/FlagTrackerImplTest.java @@ -21,13 +21,13 @@ import static org.hamcrest.Matchers.equalTo; @SuppressWarnings("javadoc") -public class FlagTrackerImplTest { +public class FlagTrackerImplTest extends BaseTest { @Test public void flagChangeListeners() throws Exception { String flagKey = "flagkey"; EventBroadcasterImpl broadcaster = - EventBroadcasterImpl.forFlagChangeEvents(TestComponents.sharedExecutor); + EventBroadcasterImpl.forFlagChangeEvents(TestComponents.sharedExecutor, testLogger); FlagTrackerImpl tracker = new FlagTrackerImpl(broadcaster, null); @@ -66,7 +66,7 @@ public void flagValueChangeListener() throws Exception { LDUser user = new LDUser("important-user"); LDUser otherUser = new LDUser("unimportant-user"); EventBroadcasterImpl broadcaster = - EventBroadcasterImpl.forFlagChangeEvents(TestComponents.sharedExecutor); + EventBroadcasterImpl.forFlagChangeEvents(TestComponents.sharedExecutor, testLogger); Map, LDValue> resultMap = new HashMap<>(); FlagTrackerImpl tracker = new FlagTrackerImpl(broadcaster, diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientBigSegmentsTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientBigSegmentsTest.java index 1a0de9e3e..743c850e0 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientBigSegmentsTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientBigSegmentsTest.java @@ -35,7 +35,7 @@ import java.util.Collections; @SuppressWarnings("javadoc") -public class LDClientBigSegmentsTest extends EasyMockSupport { +public class LDClientBigSegmentsTest extends BaseTest { private final LDUser user = new LDUser("userkey"); private final Segment bigSegment = segmentBuilder("segmentkey").unbounded(true).generation(1).build(); private final FeatureFlag flag = booleanFlagWithClauses("flagkey", clauseMatchingSegment(bigSegment)); @@ -43,6 +43,7 @@ public class LDClientBigSegmentsTest extends EasyMockSupport { private LDConfig.Builder configBuilder; private BigSegmentStore storeMock; private BigSegmentStoreFactory storeFactoryMock; + private final EasyMockSupport mocks = new EasyMockSupport(); @Before public void setup() { @@ -50,21 +51,18 @@ public void setup() { upsertFlag(dataStore, flag); upsertSegment(dataStore, bigSegment); - storeMock = niceMock(BigSegmentStore.class); - storeFactoryMock = strictMock(BigSegmentStoreFactory.class); + storeMock = mocks.niceMock(BigSegmentStore.class); + storeFactoryMock = mocks.strictMock(BigSegmentStoreFactory.class); expect(storeFactoryMock.createBigSegmentStore(isA(ClientContext.class))).andReturn(storeMock); - configBuilder = new LDConfig.Builder() - .dataSource(Components.externalUpdatesOnly()) - .dataStore(specificDataStore(dataStore)) - .events(Components.noEvents()); + configBuilder = baseConfig().dataStore(specificDataStore(dataStore)); } @Test public void userNotFound() throws Exception { expect(storeMock.getMetadata()).andAnswer(() -> new StoreMetadata(System.currentTimeMillis())).anyTimes(); expect(storeMock.getMembership(hashForUserKey(user.getKey()))).andReturn(null); - replayAll(); + mocks.replayAll(); LDConfig config = configBuilder.bigSegments(Components.bigSegments(storeFactoryMock)).build(); try (LDClient client = new LDClient("SDK_KEY", config)) { @@ -79,7 +77,7 @@ public void userFound() throws Exception { Membership membership = createMembershipFromSegmentRefs(Collections.singleton(makeBigSegmentRef(bigSegment)), null); expect(storeMock.getMetadata()).andAnswer(() -> new StoreMetadata(System.currentTimeMillis())).anyTimes(); expect(storeMock.getMembership(hashForUserKey(user.getKey()))).andReturn(membership); - replayAll(); + mocks.replayAll(); LDConfig config = configBuilder.bigSegments(Components.bigSegments(storeFactoryMock)).build(); try (LDClient client = new LDClient("SDK_KEY", config)) { @@ -93,7 +91,7 @@ public void userFound() throws Exception { public void storeError() throws Exception { expect(storeMock.getMetadata()).andAnswer(() -> new StoreMetadata(System.currentTimeMillis())).anyTimes(); expect(storeMock.getMembership(hashForUserKey(user.getKey()))).andThrow(new RuntimeException("sorry")); - replayAll(); + mocks.replayAll(); LDConfig config = configBuilder.bigSegments(Components.bigSegments(storeFactoryMock)).build(); try (LDClient client = new LDClient("SDK_KEY", config)) { diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientEndToEndTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientEndToEndTest.java index 37faffa5d..930928af5 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientEndToEndTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientEndToEndTest.java @@ -30,7 +30,7 @@ import static org.junit.Assert.assertTrue; @SuppressWarnings("javadoc") -public class LDClientEndToEndTest { +public class LDClientEndToEndTest extends BaseTest { private static final Gson gson = new Gson(); private static final String sdkKey = "sdk-key"; private static final String flagKey = "flag1"; @@ -61,7 +61,7 @@ private static Handler makeServiceUnavailableResponse() { @Test public void clientStartsInPollingMode() throws Exception { try (HttpServer server = HttpServer.start(makePollingSuccessResponse())) { - LDConfig config = new LDConfig.Builder() + LDConfig config = baseConfig() .dataSource(Components.pollingDataSource().baseURI(server.getUri())) .events(noEvents()) .build(); @@ -81,7 +81,7 @@ public void clientStartsInPollingModeAfterRecoverableError() throws Exception { ); try (HttpServer server = HttpServer.start(errorThenSuccess)) { - LDConfig config = new LDConfig.Builder() + LDConfig config = baseConfig() .dataSource(Components.pollingDataSourceInternal() .pollIntervalWithNoMinimum(Duration.ofMillis(5)) // use small interval because we expect it to retry .baseURI(server.getUri())) @@ -98,7 +98,7 @@ public void clientStartsInPollingModeAfterRecoverableError() throws Exception { @Test public void clientFailsInPollingModeWith401Error() throws Exception { try (HttpServer server = HttpServer.start(makeInvalidSdkKeyResponse())) { - LDConfig config = new LDConfig.Builder() + LDConfig config = baseConfig() .dataSource(Components.pollingDataSourceInternal() .pollIntervalWithNoMinimum(Duration.ofMillis(5)) // use small interval so we'll know if it does not stop permanently .baseURI(server.getUri())) @@ -120,7 +120,7 @@ public void testPollingModeSpecialHttpConfigurations() throws Exception { testWithSpecialHttpConfigurations( makePollingSuccessResponse(), (serverUri, httpConfig) -> - new LDConfig.Builder() + baseConfig() .dataSource(Components.pollingDataSource().baseURI(serverUri)) .events(noEvents()) .http(httpConfig) @@ -131,7 +131,7 @@ public void testPollingModeSpecialHttpConfigurations() throws Exception { @Test public void clientStartsInStreamingMode() throws Exception { try (HttpServer server = HttpServer.start(makeStreamingSuccessResponse())) { - LDConfig config = new LDConfig.Builder() + LDConfig config = baseConfig() .dataSource(Components.streamingDataSource().baseURI(server.getUri())) .events(noEvents()) .build(); @@ -151,7 +151,7 @@ public void clientStartsInStreamingModeAfterRecoverableError() throws Exception ); try (HttpServer server = HttpServer.start(errorThenStream)) { - LDConfig config = new LDConfig.Builder() + LDConfig config = baseConfig() .dataSource(Components.streamingDataSource().baseURI(server.getUri()).initialReconnectDelay(Duration.ZERO)) // use zero reconnect delay so we'll know if it does not stop permanently .events(noEvents()) @@ -171,7 +171,7 @@ public void clientStartsInStreamingModeAfterRecoverableError() throws Exception @Test public void clientFailsInStreamingModeWith401Error() throws Exception { try (HttpServer server = HttpServer.start(makeInvalidSdkKeyResponse())) { - LDConfig config = new LDConfig.Builder() + LDConfig config = baseConfig() .dataSource(Components.streamingDataSource().baseURI(server.getUri()).initialReconnectDelay(Duration.ZERO)) // use zero reconnect delay so we'll know if it does not stop permanently .events(noEvents()) @@ -205,7 +205,7 @@ public void testStreamingModeSpecialHttpConfigurations() throws Exception { testWithSpecialHttpConfigurations( makeStreamingSuccessResponse(), (serverUri, httpConfig) -> - new LDConfig.Builder() + baseConfig() .dataSource(Components.streamingDataSource().baseURI(serverUri)) .events(noEvents()) .http(httpConfig) @@ -218,7 +218,7 @@ public void clientSendsAnalyticsEvent() throws Exception { Handler resp = Handlers.status(202); try (HttpServer server = HttpServer.start(resp)) { - LDConfig config = new LDConfig.Builder() + LDConfig config = baseConfig() .dataSource(externalUpdatesOnly()) .events(Components.sendEvents().baseURI(server.getUri())) .diagnosticOptOut(true) @@ -239,7 +239,7 @@ public void clientSendsDiagnosticEvent() throws Exception { Handler resp = Handlers.status(202); try (HttpServer server = HttpServer.start(resp)) { - LDConfig config = new LDConfig.Builder() + LDConfig config = baseConfig() .dataSource(externalUpdatesOnly()) .events(Components.sendEvents().baseURI(server.getUri())) .build(); diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java index e0fb127f3..5a600cb6a 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java @@ -40,14 +40,14 @@ import static org.junit.Assert.assertTrue; @SuppressWarnings("javadoc") -public class LDClientEvaluationTest { +public class LDClientEvaluationTest extends BaseTest { 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 DataStore dataStore = initedDataStore(); - private LDConfig config = new LDConfig.Builder() + private LDConfig config = baseConfig() .dataStore(specificDataStore(dataStore)) .events(Components.noEvents()) .dataSource(Components.externalUpdatesOnly()) @@ -270,9 +270,8 @@ public void deletedFlagPlaceholderIsTreatedAsUnknownFlag() { @Test public void appropriateErrorIfClientNotInitialized() throws Exception { DataStore badDataStore = new InMemoryDataStore(); - LDConfig badConfig = new LDConfig.Builder() + LDConfig badConfig = baseConfig() .dataStore(specificDataStore(badDataStore)) - .events(Components.noEvents()) .dataSource(specificDataSource(failedDataSource())) .startWait(Duration.ZERO) .build(); @@ -322,10 +321,8 @@ public void appropriateErrorIfValueWrongType() throws Exception { public void appropriateErrorForUnexpectedExceptionFromDataStore() throws Exception { RuntimeException exception = new RuntimeException("sorry"); DataStore badDataStore = dataStoreThatThrowsException(exception); - LDConfig badConfig = new LDConfig.Builder() + LDConfig badConfig = baseConfig() .dataStore(specificDataStore(badDataStore)) - .events(Components.noEvents()) - .dataSource(Components.externalUpdatesOnly()) .build(); try (LDClientInterface badClient = new LDClient("SDK_KEY", badConfig)) { EvaluationDetail expectedResult = EvaluationDetail.fromValue(false, NO_VARIATION, @@ -354,9 +351,8 @@ public void canEvaluateWithNonNullButEmptyUserKey() throws Exception { @Test public void evaluationUsesStoreIfStoreIsInitializedButClientIsNot() throws Exception { upsertFlag(dataStore, flagWithValue("key", LDValue.of("value"))); - LDConfig customConfig = new LDConfig.Builder() + LDConfig customConfig = baseConfig() .dataStore(specificDataStore(dataStore)) - .events(Components.noEvents()) .dataSource(specificDataSource(failedDataSource())) .startWait(Duration.ZERO) .build(); @@ -554,10 +550,8 @@ public void allFlagsStateReturnsEmptyStateForNullUserKey() throws Exception { @Test public void allFlagsStateReturnsEmptyStateIfDataStoreThrowsException() throws Exception { - LDConfig customConfig = new LDConfig.Builder() + LDConfig customConfig = baseConfig() .dataStore(specificDataStore(TestComponents.dataStoreThatThrowsException(new RuntimeException("sorry")))) - .events(Components.noEvents()) - .dataSource(Components.externalUpdatesOnly()) .startWait(Duration.ZERO) .build(); @@ -583,9 +577,8 @@ public void allFlagsStateUsesNullValueForFlagIfEvaluationThrowsException() throw @Test public void allFlagsStateUsesStoreDataIfStoreIsInitializedButClientIsNot() throws Exception { upsertFlag(dataStore, flagWithValue("key", LDValue.of("value"))); - LDConfig customConfig = new LDConfig.Builder() + LDConfig customConfig = baseConfig() .dataStore(specificDataStore(dataStore)) - .events(Components.noEvents()) .dataSource(specificDataSource(failedDataSource())) .startWait(Duration.ZERO) .build(); @@ -601,8 +594,7 @@ public void allFlagsStateUsesStoreDataIfStoreIsInitializedButClientIsNot() throw @Test public void allFlagsStateReturnsEmptyStateIfClientAndStoreAreNotInitialized() throws Exception { - LDConfig customConfig = new LDConfig.Builder() - .events(Components.noEvents()) + LDConfig customConfig = baseConfig() .dataSource(specificDataSource(failedDataSource())) .startWait(Duration.ZERO) .build(); diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientEventTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientEventTest.java index 4408af8b1..174a175d4 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientEventTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientEventTest.java @@ -27,14 +27,14 @@ import static org.junit.Assert.assertTrue; @SuppressWarnings("javadoc") -public class LDClientEventTest { +public class LDClientEventTest extends BaseTest { private static final LDUser user = new LDUser("userkey"); private static final LDUser userWithNullKey = new LDUser.Builder((String)null).build(); private static final LDUser userWithEmptyKey = new LDUser.Builder("").build(); private DataStore dataStore = initedDataStore(); private TestComponents.TestEventProcessor eventSink = new TestComponents.TestEventProcessor(); - private LDConfig config = new LDConfig.Builder() + private LDConfig config = baseConfig() .dataStore(specificDataStore(dataStore)) .events(specificEventProcessor(eventSink)) .dataSource(Components.externalUpdatesOnly()) @@ -514,7 +514,7 @@ public void canFlush() { @Test public void identifyWithEventsDisabledDoesNotCauseError() throws Exception { - LDConfig config = new LDConfig.Builder() + LDConfig config = baseConfig() .events(Components.noEvents()) .dataSource(Components.externalUpdatesOnly()) .build(); @@ -525,7 +525,7 @@ public void identifyWithEventsDisabledDoesNotCauseError() throws Exception { @Test public void trackWithEventsDisabledDoesNotCauseError() throws Exception { - LDConfig config = new LDConfig.Builder() + LDConfig config = baseConfig() .events(Components.noEvents()) .dataSource(Components.externalUpdatesOnly()) .build(); @@ -536,7 +536,7 @@ public void trackWithEventsDisabledDoesNotCauseError() throws Exception { @Test public void flushWithEventsDisabledDoesNotCauseError() throws Exception { - LDConfig config = new LDConfig.Builder() + LDConfig config = baseConfig() .events(Components.noEvents()) .dataSource(Components.externalUpdatesOnly()) .build(); diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientExternalUpdatesOnlyTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientExternalUpdatesOnlyTest.java index 3e086d798..e3e1658aa 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientExternalUpdatesOnlyTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientExternalUpdatesOnlyTest.java @@ -17,10 +17,10 @@ import static org.junit.Assert.assertTrue; @SuppressWarnings("javadoc") -public class LDClientExternalUpdatesOnlyTest { +public class LDClientExternalUpdatesOnlyTest extends BaseTest { @Test public void externalUpdatesOnlyClientHasNullDataSource() throws Exception { - LDConfig config = new LDConfig.Builder() + LDConfig config = baseConfig() .dataSource(Components.externalUpdatesOnly()) .build(); try (LDClient client = new LDClient("SDK_KEY", config)) { @@ -28,19 +28,9 @@ public void externalUpdatesOnlyClientHasNullDataSource() throws Exception { } } - @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() + LDConfig config = baseConfig() .dataSource(Components.externalUpdatesOnly()) .build(); try (LDClient client = new LDClient("SDK_KEY", config)) { @@ -53,7 +43,7 @@ public void externalUpdatesOnlyClientIsInitialized() throws Exception { @Test public void externalUpdatesOnlyClientGetsFlagFromDataStore() throws IOException { DataStore testDataStore = initedDataStore(); - LDConfig config = new LDConfig.Builder() + LDConfig config = baseConfig() .dataSource(Components.externalUpdatesOnly()) .dataStore(specificDataStore(testDataStore)) .build(); diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java index 6a776ea26..9a13e2232 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientListenersTest.java @@ -53,15 +53,15 @@ * together correctly so that they work from an application's point of view. */ @SuppressWarnings("javadoc") -public class LDClientListenersTest extends EasyMockSupport { +public class LDClientListenersTest extends BaseTest { private final static String SDK_KEY = "SDK_KEY"; - + @Test public void clientSendsFlagChangeEvents() throws Exception { String flagKey = "flagkey"; TestData testData = TestData.dataSource(); testData.update(testData.flag(flagKey).on(true)); - LDConfig config = new LDConfig.Builder() + LDConfig config = baseConfig() .dataSource(testData) .events(Components.noEvents()) .build(); @@ -106,7 +106,7 @@ public void clientSendsFlagValueChangeEvents() throws Exception { TestData testData = TestData.dataSource(); testData.update(testData.flag(flagKey).on(false)); - LDConfig config = new LDConfig.Builder() + LDConfig config = baseConfig() .dataSource(testData) .events(Components.noEvents()) .build(); @@ -148,7 +148,7 @@ public void clientSendsFlagValueChangeEvents() throws Exception { @Test public void dataSourceStatusProviderReturnsLatestStatus() throws Exception { TestData testData = TestData.dataSource(); - LDConfig config = new LDConfig.Builder() + LDConfig config = baseConfig() .dataSource(testData) .events(Components.noEvents()) .build(); @@ -174,7 +174,7 @@ public void dataSourceStatusProviderReturnsLatestStatus() throws Exception { @Test public void dataSourceStatusProviderSendsStatusUpdates() throws Exception { TestData testData = TestData.dataSource(); - LDConfig config = new LDConfig.Builder() + LDConfig config = baseConfig() .dataSource(testData) .events(Components.noEvents()) .build(); @@ -196,7 +196,7 @@ public void dataSourceStatusProviderSendsStatusUpdates() throws Exception { @Test public void dataStoreStatusMonitoringIsDisabledForInMemoryStore() throws Exception { - LDConfig config = new LDConfig.Builder() + LDConfig config = baseConfig() .dataSource(Components.externalUpdatesOnly()) .events(Components.noEvents()) .build(); @@ -207,7 +207,7 @@ public void dataStoreStatusMonitoringIsDisabledForInMemoryStore() throws Excepti @Test public void dataStoreStatusMonitoringIsEnabledForPersistentStore() throws Exception { - LDConfig config = new LDConfig.Builder() + LDConfig config = baseConfig() .dataSource(Components.externalUpdatesOnly()) .dataStore( Components.persistentDataStore(specificPersistentDataStore(new MockPersistentDataStore())) @@ -224,7 +224,7 @@ public void dataStoreStatusProviderReturnsLatestStatus() throws Exception { DataStoreFactory underlyingStoreFactory = Components.persistentDataStore( specificPersistentDataStore(new MockPersistentDataStore())); DataStoreFactoryThatExposesUpdater factoryWithUpdater = new DataStoreFactoryThatExposesUpdater(underlyingStoreFactory); - LDConfig config = new LDConfig.Builder() + LDConfig config = baseConfig() .dataSource(Components.externalUpdatesOnly()) .dataStore(factoryWithUpdater) .events(Components.noEvents()) @@ -243,7 +243,7 @@ public void dataStoreStatusProviderSendsStatusUpdates() throws Exception { DataStoreFactory underlyingStoreFactory = Components.persistentDataStore( specificPersistentDataStore(new MockPersistentDataStore())); DataStoreFactoryThatExposesUpdater factoryWithUpdater = new DataStoreFactoryThatExposesUpdater(underlyingStoreFactory); - LDConfig config = new LDConfig.Builder() + LDConfig config = baseConfig() .dataSource(Components.externalUpdatesOnly()) .dataStore(factoryWithUpdater) .events(Components.noEvents()) @@ -266,7 +266,7 @@ public void eventsAreDispatchedOnTaskThread() throws Exception { TestData testData = TestData.dataSource(); testData.update(testData.flag("flagkey").on(true)); - LDConfig config = new LDConfig.Builder() + LDConfig config = baseConfig() .dataSource(testData) .events(Components.noEvents()) .threadPriority(desiredPriority) @@ -288,10 +288,7 @@ public void eventsAreDispatchedOnTaskThread() throws Exception { @Test public void bigSegmentStoreStatusReturnsUnavailableStatusWhenNotConfigured() throws Exception { - LDConfig config = new LDConfig.Builder() - .dataSource(Components.externalUpdatesOnly()) - .events(Components.noEvents()) - .build(); + LDConfig config = baseConfig().build(); try (LDClient client = new LDClient(SDK_KEY, config)) { BigSegmentStoreStatusProvider.Status status = client.getBigSegmentStoreStatusProvider().getStatus(); assertFalse(status.isAvailable()); @@ -301,8 +298,9 @@ public void bigSegmentStoreStatusReturnsUnavailableStatusWhenNotConfigured() thr @Test public void bigSegmentStoreStatusProviderSendsStatusUpdates() throws Exception { + EasyMockSupport mocks = new EasyMockSupport(); AtomicBoolean storeAvailable = new AtomicBoolean(true); - BigSegmentStore storeMock = niceMock(BigSegmentStore.class); + BigSegmentStore storeMock = mocks.niceMock(BigSegmentStore.class); expect(storeMock.getMetadata()).andAnswer(() -> { if (storeAvailable.get()) { return new BigSegmentStoreTypes.StoreMetadata(System.currentTimeMillis()); @@ -310,17 +308,15 @@ public void bigSegmentStoreStatusProviderSendsStatusUpdates() throws Exception { throw new RuntimeException("sorry"); }).anyTimes(); - BigSegmentStoreFactory storeFactoryMock = strictMock(BigSegmentStoreFactory.class); + BigSegmentStoreFactory storeFactoryMock = mocks.strictMock(BigSegmentStoreFactory.class); expect(storeFactoryMock.createBigSegmentStore(isA(ClientContext.class))).andReturn(storeMock); replay(storeFactoryMock, storeMock); - LDConfig config = new LDConfig.Builder() + LDConfig config = baseConfig() .bigSegments( Components.bigSegments(storeFactoryMock).statusPollInterval(Duration.ofMillis(10)) ) - .dataSource(Components.externalUpdatesOnly()) - .events(Components.noEvents()) .build(); try (LDClient client = new LDClient(SDK_KEY, config)) { diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientOfflineTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientOfflineTest.java index 80a7212d5..41679fe15 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientOfflineTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientOfflineTest.java @@ -18,12 +18,12 @@ import static org.junit.Assert.assertTrue; @SuppressWarnings("javadoc") -public class LDClientOfflineTest { +public class LDClientOfflineTest extends BaseTest { private static final LDUser user = new LDUser("user"); @Test public void offlineClientHasNullDataSource() throws IOException { - LDConfig config = new LDConfig.Builder() + LDConfig config = baseConfig() .offline(true) .build(); try (LDClient client = new LDClient("SDK_KEY", config)) { @@ -33,7 +33,7 @@ public void offlineClientHasNullDataSource() throws IOException { @Test public void offlineClientHasNullEventProcessor() throws IOException { - LDConfig config = new LDConfig.Builder() + LDConfig config = baseConfig() .offline(true) .build(); try (LDClient client = new LDClient("SDK_KEY", config)) { @@ -43,7 +43,7 @@ public void offlineClientHasNullEventProcessor() throws IOException { @Test public void offlineClientIsInitialized() throws IOException { - LDConfig config = new LDConfig.Builder() + LDConfig config = baseConfig() .offline(true) .build(); try (LDClient client = new LDClient("SDK_KEY", config)) { @@ -55,7 +55,7 @@ public void offlineClientIsInitialized() throws IOException { @Test public void offlineClientReturnsDefaultValue() throws IOException { - LDConfig config = new LDConfig.Builder() + LDConfig config = baseConfig() .offline(true) .build(); try (LDClient client = new LDClient("SDK_KEY", config)) { @@ -66,7 +66,7 @@ public void offlineClientReturnsDefaultValue() throws IOException { @Test public void offlineClientGetsFlagsStateFromDataStore() throws IOException { DataStore testDataStore = initedDataStore(); - LDConfig config = new LDConfig.Builder() + LDConfig config = baseConfig() .offline(true) .dataStore(specificDataStore(testDataStore)) .build(); diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java index d16083f9f..b6176a597 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java @@ -52,20 +52,21 @@ * See also LDClientEvaluationTest, etc. This file contains mostly tests for the startup logic. */ @SuppressWarnings("javadoc") -public class LDClientTest extends EasyMockSupport { +public class LDClientTest extends BaseTest { private final static String SDK_KEY = "SDK_KEY"; private DataSource dataSource; private EventProcessor eventProcessor; private Future initFuture; private LDClientInterface client; + private final EasyMockSupport mocks = new EasyMockSupport(); @SuppressWarnings("unchecked") @Before public void before() { - dataSource = createStrictMock(DataSource.class); - eventProcessor = createStrictMock(EventProcessor.class); - initFuture = createStrictMock(Future.class); + dataSource = mocks.createStrictMock(DataSource.class); + eventProcessor = mocks.createStrictMock(EventProcessor.class); + initFuture = mocks.createStrictMock(Future.class); } @Test @@ -109,7 +110,7 @@ public void constructorAllowsSdkKeyToBeEmpty() throws Exception { // It may seem counter-intuitive to allow this, but if someone is using the SDK in offline // mode, or with a file data source or a test fixture, they may reasonably assume that it's // OK to pass an empty string since the key won't actually be used. - try (LDClient client = new LDClient(SDK_KEY + "")) {} + try (LDClient client = new LDClient("", baseConfig().build())) {} } @Test @@ -126,6 +127,7 @@ public void clientHasDefaultEventProcessorWithDefaultConfig() throws Exception { LDConfig config = new LDConfig.Builder() .dataSource(Components.externalUpdatesOnly()) .diagnosticOptOut(true) + .logging(Components.logging(testLogging)) .build(); try (LDClient client = new LDClient("SDK_KEY", config)) { assertEquals(DefaultEventProcessor.class, client.eventProcessor.getClass()); @@ -138,6 +140,7 @@ public void clientHasDefaultEventProcessorWithSendEvents() throws Exception { .dataSource(Components.externalUpdatesOnly()) .events(Components.sendEvents()) .diagnosticOptOut(true) + .logging(Components.logging(testLogging)) .build(); try (LDClient client = new LDClient("SDK_KEY", config)) { assertEquals(DefaultEventProcessor.class, client.eventProcessor.getClass()); @@ -149,6 +152,7 @@ public void clientHasNullEventProcessorWithNoEvents() throws Exception { LDConfig config = new LDConfig.Builder() .dataSource(Components.externalUpdatesOnly()) .events(Components.noEvents()) + .logging(Components.logging(testLogging)) .build(); try (LDClient client = new LDClient("SDK_KEY", config)) { assertEquals(ComponentsImpl.NullEventProcessor.class, client.eventProcessor.getClass()); @@ -160,6 +164,7 @@ public void streamingClientHasStreamProcessor() throws Exception { LDConfig config = new LDConfig.Builder() .dataSource(Components.streamingDataSource().baseURI(URI.create("http://fake"))) .events(Components.noEvents()) + .logging(Components.logging(testLogging)) .startWait(Duration.ZERO) .build(); try (LDClient client = new LDClient(SDK_KEY, config)) { @@ -172,6 +177,7 @@ public void pollingClientHasPollingProcessor() throws IOException { LDConfig config = new LDConfig.Builder() .dataSource(Components.pollingDataSource().baseURI(URI.create("http://fake"))) .events(Components.noEvents()) + .logging(Components.logging(testLogging)) .startWait(Duration.ZERO) .build(); try (LDClient client = new LDClient(SDK_KEY, config)) { @@ -181,11 +187,12 @@ public void pollingClientHasPollingProcessor() throws IOException { @Test public void sameDiagnosticAccumulatorPassedToFactoriesWhenSupported() throws IOException { - DataSourceFactory mockDataSourceFactory = createStrictMock(DataSourceFactory.class); + DataSourceFactory mockDataSourceFactory = mocks.createStrictMock(DataSourceFactory.class); LDConfig config = new LDConfig.Builder() .dataSource(mockDataSourceFactory) .events(Components.sendEvents().baseURI(URI.create("fake-host"))) // event processor will try to send a diagnostic event here + .logging(Components.logging(testLogging)) .startWait(Duration.ZERO) .build(); @@ -193,10 +200,10 @@ public void sameDiagnosticAccumulatorPassedToFactoriesWhenSupported() throws IOE expect(mockDataSourceFactory.createDataSource(capture(capturedDataSourceContext), isA(DataSourceUpdates.class))).andReturn(failedDataSource()); - replayAll(); + mocks.replayAll(); try (LDClient client = new LDClient(SDK_KEY, config)) { - verifyAll(); + mocks.verifyAll(); DiagnosticAccumulator acc = ((DefaultEventProcessor)client.eventProcessor).dispatcher.diagnosticAccumulator; assertNotNull(acc); assertSame(acc, ClientContextImpl.get(capturedDataSourceContext.getValue()).diagnosticAccumulator); @@ -205,11 +212,12 @@ public void sameDiagnosticAccumulatorPassedToFactoriesWhenSupported() throws IOE @Test public void nullDiagnosticAccumulatorPassedToFactoriesWhenOptedOut() throws IOException { - DataSourceFactory mockDataSourceFactory = createStrictMock(DataSourceFactory.class); + DataSourceFactory mockDataSourceFactory = mocks.createStrictMock(DataSourceFactory.class); LDConfig config = new LDConfig.Builder() .dataSource(mockDataSourceFactory) .diagnosticOptOut(true) + .logging(Components.logging(testLogging)) .startWait(Duration.ZERO) .build(); @@ -217,10 +225,10 @@ public void nullDiagnosticAccumulatorPassedToFactoriesWhenOptedOut() throws IOEx expect(mockDataSourceFactory.createDataSource(capture(capturedDataSourceContext), isA(DataSourceUpdates.class))).andReturn(failedDataSource()); - replayAll(); + mocks.replayAll(); try (LDClient client = new LDClient(SDK_KEY, config)) { - verifyAll(); + mocks.verifyAll(); assertNull(((DefaultEventProcessor)client.eventProcessor).dispatcher.diagnosticAccumulator); assertNull(ClientContextImpl.get(capturedDataSourceContext.getValue()).diagnosticAccumulator); } @@ -228,15 +236,16 @@ public void nullDiagnosticAccumulatorPassedToFactoriesWhenOptedOut() throws IOEx @Test public void nullDiagnosticAccumulatorPassedToUpdateFactoryWhenEventProcessorDoesNotSupportDiagnostics() throws IOException { - EventProcessor mockEventProcessor = createStrictMock(EventProcessor.class); + EventProcessor mockEventProcessor = mocks.createStrictMock(EventProcessor.class); mockEventProcessor.close(); EasyMock.expectLastCall().anyTimes(); - EventProcessorFactory mockEventProcessorFactory = createStrictMock(EventProcessorFactory.class); - DataSourceFactory mockDataSourceFactory = createStrictMock(DataSourceFactory.class); + EventProcessorFactory mockEventProcessorFactory = mocks.createStrictMock(EventProcessorFactory.class); + DataSourceFactory mockDataSourceFactory = mocks.createStrictMock(DataSourceFactory.class); LDConfig config = new LDConfig.Builder() .events(mockEventProcessorFactory) .dataSource(mockDataSourceFactory) + .logging(Components.logging(testLogging)) .startWait(Duration.ZERO) .build(); @@ -246,10 +255,10 @@ public void nullDiagnosticAccumulatorPassedToUpdateFactoryWhenEventProcessorDoes expect(mockDataSourceFactory.createDataSource(capture(capturedDataSourceContext), isA(DataSourceUpdates.class))).andReturn(failedDataSource()); - replayAll(); + mocks.replayAll(); try (LDClient client = new LDClient(SDK_KEY, config)) { - verifyAll(); + mocks.verifyAll(); assertNull(ClientContextImpl.get(capturedEventContext.getValue()).diagnosticAccumulator); assertNull(ClientContextImpl.get(capturedDataSourceContext.getValue()).diagnosticAccumulator); } @@ -262,12 +271,12 @@ public void noWaitForDataSourceIfWaitMillisIsZero() throws Exception { expect(dataSource.start()).andReturn(initFuture); expect(dataSource.isInitialized()).andReturn(false); - replayAll(); + mocks.replayAll(); client = createMockClient(config); assertFalse(client.isInitialized()); - verifyAll(); + mocks.verifyAll(); } @Test @@ -278,12 +287,12 @@ public void willWaitForDataSourceIfWaitMillisIsGreaterThanZero() throws Exceptio expect(dataSource.start()).andReturn(initFuture); expect(initFuture.get(10L, TimeUnit.MILLISECONDS)).andReturn(null); expect(dataSource.isInitialized()).andReturn(false).anyTimes(); - replayAll(); + mocks.replayAll(); client = createMockClient(config); assertFalse(client.isInitialized()); - verifyAll(); + mocks.verifyAll(); } @Test @@ -293,12 +302,12 @@ public void noWaitForDataSourceIfWaitMillisIsNegative() throws Exception { expect(dataSource.start()).andReturn(initFuture); expect(dataSource.isInitialized()).andReturn(false); - replayAll(); + mocks.replayAll(); client = createMockClient(config); assertFalse(client.isInitialized()); - verifyAll(); + mocks.verifyAll(); } @Test @@ -309,12 +318,12 @@ public void dataSourceCanTimeOut() throws Exception { expect(dataSource.start()).andReturn(initFuture); expect(initFuture.get(10L, TimeUnit.MILLISECONDS)).andThrow(new TimeoutException()); expect(dataSource.isInitialized()).andReturn(false).anyTimes(); - replayAll(); + mocks.replayAll(); client = createMockClient(config); assertFalse(client.isInitialized()); - verifyAll(); + mocks.verifyAll(); } @Test @@ -325,12 +334,12 @@ public void clientCatchesRuntimeExceptionFromDataSource() throws Exception { expect(dataSource.start()).andReturn(initFuture); expect(initFuture.get(10L, TimeUnit.MILLISECONDS)).andThrow(new RuntimeException()); expect(dataSource.isInitialized()).andReturn(false).anyTimes(); - replayAll(); + mocks.replayAll(); client = createMockClient(config); assertFalse(client.isInitialized()); - verifyAll(); + mocks.verifyAll(); } @Test @@ -341,13 +350,13 @@ public void isFlagKnownReturnsTrueForExistingFlag() throws Exception { .dataStore(specificDataStore(testDataStore)); expect(dataSource.start()).andReturn(initFuture); expect(dataSource.isInitialized()).andReturn(true).times(1); - replayAll(); + mocks.replayAll(); client = createMockClient(config); upsertFlag(testDataStore, flagWithValue("key", LDValue.of(1))); assertTrue(client.isFlagKnown("key")); - verifyAll(); + mocks.verifyAll(); } @Test @@ -358,12 +367,12 @@ public void isFlagKnownReturnsFalseForUnknownFlag() throws Exception { .dataStore(specificDataStore(testDataStore)); expect(dataSource.start()).andReturn(initFuture); expect(dataSource.isInitialized()).andReturn(true).times(1); - replayAll(); + mocks.replayAll(); client = createMockClient(config); assertFalse(client.isFlagKnown("key")); - verifyAll(); + mocks.verifyAll(); } @Test @@ -374,13 +383,13 @@ public void isFlagKnownReturnsFalseIfStoreAndClientAreNotInitialized() throws Ex .dataStore(specificDataStore(testDataStore)); expect(dataSource.start()).andReturn(initFuture); expect(dataSource.isInitialized()).andReturn(false).times(1); - replayAll(); + mocks.replayAll(); client = createMockClient(config); upsertFlag(testDataStore, flagWithValue("key", LDValue.of(1))); assertFalse(client.isFlagKnown("key")); - verifyAll(); + mocks.verifyAll(); } @Test @@ -391,13 +400,13 @@ public void isFlagKnownUsesStoreIfStoreIsInitializedButClientIsNot() throws Exce .dataStore(specificDataStore(testDataStore)); expect(dataSource.start()).andReturn(initFuture); expect(dataSource.isInitialized()).andReturn(false).times(1); - replayAll(); + mocks.replayAll(); client = createMockClient(config); upsertFlag(testDataStore, flagWithValue("key", LDValue.of(1))); assertTrue(client.isFlagKnown("key")); - verifyAll(); + mocks.verifyAll(); } @Test @@ -408,7 +417,7 @@ public void isFlagKnownCatchesExceptionFromDataStore() throws Exception { .dataStore(specificDataStore(badStore)); expect(dataSource.start()).andReturn(initFuture); expect(dataSource.isInitialized()).andReturn(false).times(1); - replayAll(); + mocks.replayAll(); client = createMockClient(config); @@ -420,6 +429,7 @@ public void getVersion() throws Exception { LDConfig config = new LDConfig.Builder() .dataSource(Components.externalUpdatesOnly()) .events(Components.noEvents()) + .logging(Components.logging(testLogging)) .build(); try (LDClient client = new LDClient(SDK_KEY, config)) { assertEquals(Version.SDK_VERSION, client.version()); @@ -428,15 +438,12 @@ public void getVersion() throws Exception { @Test public void canGetCacheStatsFromDataStoreStatusProvider() throws Exception { - LDConfig config1 = new LDConfig.Builder() - .dataSource(Components.externalUpdatesOnly()) - .events(Components.noEvents()) - .build(); + LDConfig config1 = baseConfig().build(); try (LDClient client1 = new LDClient(SDK_KEY, config1)) { assertNull(client1.getDataStoreStatusProvider().getCacheStats()); } - LDConfig config2 = new LDConfig.Builder() + LDConfig config2 = baseConfig() .dataStore(Components.persistentDataStore(c -> new MockPersistentDataStore())) .build(); try (LDClient client2 = new LDClient(SDK_KEY, config2)) { @@ -451,7 +458,8 @@ public void testSecureModeHash() throws IOException { LDUser user = new LDUser.Builder("userkey").build(); String expectedHash = "c097a70924341660427c2e487b86efee789210f9e6dafc3b5f50e75bc596ff99"; - client = createMockClient(new LDConfig.Builder().startWait(Duration.ZERO)); + client = createMockClient(new LDConfig.Builder() + .startWait(Duration.ZERO)); assertEquals(expectedHash, client.secureModeHash(user)); assertNull(client.secureModeHash(null)); @@ -461,12 +469,13 @@ public void testSecureModeHash() throws IOException { private void setupMockDataSourceToInitialize(boolean willInitialize) { expect(dataSource.start()).andReturn(initFuture); expect(dataSource.isInitialized()).andReturn(willInitialize); - replayAll(); + mocks.replayAll(); } private LDClient createMockClient(LDConfig.Builder config) { config.dataSource(specificDataSource(dataSource)); config.events(specificEventProcessor(eventProcessor)); + config.logging(Components.logging(testLogging)); return new LDClient(SDK_KEY, config.build()); } } \ No newline at end of file diff --git a/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperOtherTest.java b/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperOtherTest.java index 52296c824..b68f60b00 100644 --- a/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperOtherTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperOtherTest.java @@ -24,7 +24,7 @@ * PersistentDataStoreWrapperTest suite. */ @SuppressWarnings("javadoc") -public class PersistentDataStoreWrapperOtherTest { +public class PersistentDataStoreWrapperOtherTest extends BaseTest { private static final RuntimeException FAKE_ERROR = new RuntimeException("fake error"); private final MockPersistentDataStore core; @@ -40,7 +40,8 @@ private PersistentDataStoreWrapper makeWrapper(Duration cacheTtl, StaleValuesPol policy, false, status -> {}, - sharedExecutor + sharedExecutor, + testLogger ); } diff --git a/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperTest.java b/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperTest.java index 439f73606..26341a334 100644 --- a/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/PersistentDataStoreWrapperTest.java @@ -2,8 +2,6 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; -import com.launchdarkly.sdk.server.PersistentDataStoreStatusManager; -import com.launchdarkly.sdk.server.PersistentDataStoreWrapper; import com.launchdarkly.sdk.server.DataStoreTestTypes.DataBuilder; import com.launchdarkly.sdk.server.DataStoreTestTypes.TestItem; import com.launchdarkly.sdk.server.integrations.MockPersistentDataStore; @@ -43,7 +41,7 @@ @SuppressWarnings("javadoc") @RunWith(Parameterized.class) -public class PersistentDataStoreWrapperTest { +public class PersistentDataStoreWrapperTest extends BaseTest { private static final RuntimeException FAKE_ERROR = new RuntimeException("fake error"); private final TestMode testMode; @@ -110,9 +108,10 @@ public PersistentDataStoreWrapperTest(TestMode testMode) { PersistentDataStoreBuilder.StaleValuesPolicy.EVICT, false, this::updateStatus, - sharedExecutor + sharedExecutor, + testLogger ); - this.statusBroadcaster = EventBroadcasterImpl.forDataStoreStatus(sharedExecutor); + this.statusBroadcaster = EventBroadcasterImpl.forDataStoreStatus(sharedExecutor, testLogger); this.dataStoreUpdates = new DataStoreUpdatesImpl(statusBroadcaster); this.dataStoreStatusProvider = new DataStoreStatusProviderImpl(wrapper, dataStoreUpdates); } @@ -474,7 +473,8 @@ public void initializedCanCacheFalseResult() throws Exception { PersistentDataStoreBuilder.StaleValuesPolicy.EVICT, false, this::updateStatus, - sharedExecutor + sharedExecutor, + testLogger )) { assertThat(wrapper1.isInitialized(), is(false)); assertThat(core.initedQueryCount, equalTo(1)); @@ -508,7 +508,8 @@ public void canGetCacheStats() throws Exception { PersistentDataStoreBuilder.StaleValuesPolicy.EVICT, true, this::updateStatus, - sharedExecutor + sharedExecutor, + testLogger )) { CacheStats stats = w.getCacheStats(); diff --git a/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java index c32bccc48..ee27eabd1 100644 --- a/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java @@ -47,7 +47,7 @@ import static org.junit.Assert.assertTrue; @SuppressWarnings("javadoc") -public class PollingProcessorTest { +public class PollingProcessorTest extends BaseTest { private static final String SDK_KEY = "sdk-key"; private static final Duration LENGTHY_INTERVAL = Duration.ofSeconds(60); private static final Duration BRIEF_INTERVAL = Duration.ofMillis(20); @@ -61,8 +61,8 @@ public void setup() { } private PollingProcessor makeProcessor(URI baseUri, Duration pollInterval) { - FeatureRequestor requestor = new DefaultFeatureRequestor(defaultHttpConfiguration(), baseUri); - return new PollingProcessor(requestor, dataSourceUpdates, sharedExecutor, pollInterval); + FeatureRequestor requestor = new DefaultFeatureRequestor(defaultHttpConfiguration(), baseUri, testLogger); + return new PollingProcessor(requestor, dataSourceUpdates, sharedExecutor, pollInterval, testLogger); } private static class TestPollHandler implements Handler { @@ -95,7 +95,7 @@ public void setError(int status) { @Test public void builderHasDefaultConfiguration() throws Exception { DataSourceFactory f = Components.pollingDataSource(); - try (PollingProcessor pp = (PollingProcessor)f.createDataSource(clientContext(SDK_KEY, LDConfig.DEFAULT), null)) { + try (PollingProcessor pp = (PollingProcessor)f.createDataSource(clientContext(SDK_KEY, baseConfig().build()), null)) { assertThat(((DefaultFeatureRequestor)pp.requestor).baseUri, equalTo(LDConfig.DEFAULT_BASE_URI)); assertThat(pp.pollInterval, equalTo(PollingDataSourceBuilder.DEFAULT_POLL_INTERVAL)); } @@ -107,7 +107,7 @@ public void builderCanSpecifyConfiguration() throws Exception { DataSourceFactory f = Components.pollingDataSource() .baseURI(uri) .pollInterval(LENGTHY_INTERVAL); - try (PollingProcessor pp = (PollingProcessor)f.createDataSource(clientContext(SDK_KEY, LDConfig.DEFAULT), null)) { + try (PollingProcessor pp = (PollingProcessor)f.createDataSource(clientContext(SDK_KEY, baseConfig().build()), null)) { assertThat(((DefaultFeatureRequestor)pp.requestor).baseUri, equalTo(uri)); assertThat(pp.pollInterval, equalTo(LENGTHY_INTERVAL)); } diff --git a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java index 8f2828ae1..8c09c9ca4 100644 --- a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java @@ -62,8 +62,7 @@ import static org.junit.Assert.assertTrue; @SuppressWarnings("javadoc") -public class StreamProcessorTest { - +public class StreamProcessorTest extends BaseTest { private static final String SDK_KEY = "sdk_key"; private static final Duration BRIEF_RECONNECT_DELAY = Duration.ofMillis(10); private static final String FEATURE1_KEY = "feature1"; @@ -754,7 +753,8 @@ private StreamProcessor createStreamProcessor(LDConfig config, URI streamUri, Di Thread.MIN_PRIORITY, acc, streamUri, - BRIEF_RECONNECT_DELAY + BRIEF_RECONNECT_DELAY, + testLogger ); } diff --git a/src/test/java/com/launchdarkly/sdk/server/TestComponents.java b/src/test/java/com/launchdarkly/sdk/server/TestComponents.java index 587cf4abb..13a7a91cc 100644 --- a/src/test/java/com/launchdarkly/sdk/server/TestComponents.java +++ b/src/test/java/com/launchdarkly/sdk/server/TestComponents.java @@ -1,6 +1,8 @@ package com.launchdarkly.sdk.server; import com.google.common.util.concurrent.ThreadFactoryBuilder; +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.logging.Logs; import com.launchdarkly.sdk.UserAttribute; import com.launchdarkly.sdk.server.integrations.EventProcessorBuilder; import com.launchdarkly.sdk.server.interfaces.ClientContext; @@ -48,6 +50,8 @@ public class TestComponents { static ScheduledExecutorService sharedExecutor = newSingleThreadScheduledExecutor( new ThreadFactoryBuilder().setNameFormat("TestComponents-sharedExecutor-%d").build()); + public static LDLogger nullLogger = LDLogger.withAdapter(Logs.none(), ""); + public static ClientContext clientContext(final String sdkKey, final LDConfig config) { return new ClientContextImpl(sdkKey, config, sharedExecutor, null); } @@ -177,15 +181,16 @@ public static class UpsertParams { public MockDataSourceUpdates(DataStore store, DataStoreStatusProvider dataStoreStatusProvider) { this.dataStoreStatusProvider = dataStoreStatusProvider; - this.flagChangeEventBroadcaster = EventBroadcasterImpl.forFlagChangeEvents(sharedExecutor); - this.statusBroadcaster = EventBroadcasterImpl.forDataSourceStatus(sharedExecutor); + this.flagChangeEventBroadcaster = EventBroadcasterImpl.forFlagChangeEvents(sharedExecutor, nullLogger); + this.statusBroadcaster = EventBroadcasterImpl.forDataSourceStatus(sharedExecutor, nullLogger); this.wrappedInstance = new DataSourceUpdatesImpl( store, dataStoreStatusProvider, flagChangeEventBroadcaster, statusBroadcaster, sharedExecutor, - null + null, + nullLogger ); } @@ -350,7 +355,7 @@ public MockDataStoreStatusProvider() { } public MockDataStoreStatusProvider(boolean statusMonitoringEnabled) { - this.statusBroadcaster = EventBroadcasterImpl.forDataStoreStatus(sharedExecutor); + this.statusBroadcaster = EventBroadcasterImpl.forDataStoreStatus(sharedExecutor, nullLogger); this.lastStatus = new AtomicReference<>(new DataStoreStatusProvider.Status(true, false)); this.statusMonitoringEnabled = statusMonitoringEnabled; } diff --git a/src/test/java/com/launchdarkly/sdk/server/TestHttpUtil.java b/src/test/java/com/launchdarkly/sdk/server/TestHttpUtil.java index a8d23b2e9..11c5c7659 100644 --- a/src/test/java/com/launchdarkly/sdk/server/TestHttpUtil.java +++ b/src/test/java/com/launchdarkly/sdk/server/TestHttpUtil.java @@ -6,9 +6,6 @@ import com.launchdarkly.testhelpers.httptest.RequestInfo; import com.launchdarkly.testhelpers.httptest.ServerTLSConfiguration; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import java.io.IOException; import java.net.URI; @@ -18,8 +15,6 @@ import static org.hamcrest.Matchers.nullValue; class TestHttpUtil { - static Logger logger = LoggerFactory.getLogger(TestHttpUtil.class); - // Used for testWithSpecialHttpConfigurations static interface HttpConfigurationTestAction { void accept(URI targetUri, HttpConfigurationFactory httpConfig) throws IOException; @@ -62,7 +57,6 @@ static void testWithSpecialHttpConfigurations( static void testHttpClientDoesNotAllowSelfSignedCertByDefault(Handler handler, HttpConfigurationTestAction testActionShouldFail) { - logger.warn("testHttpClientDoesNotAllowSelfSignedCertByDefault"); try { ServerTLSConfiguration tlsConfig = ServerTLSConfiguration.makeSelfSignedCertificate(); try (HttpServer secureServer = HttpServer.startSecure(tlsConfig, handler)) { @@ -76,7 +70,6 @@ static void testHttpClientDoesNotAllowSelfSignedCertByDefault(Handler handler, static void testHttpClientCanBeConfiguredToAllowSelfSignedCert(Handler handler, HttpConfigurationTestAction testActionShouldSucceed) { - logger.warn("testHttpClientCanBeConfiguredToAllowSelfSignedCert"); try { ServerTLSConfiguration tlsConfig = ServerTLSConfiguration.makeSelfSignedCertificate(); HttpConfigurationFactory httpConfig = Components.httpConfiguration() @@ -91,8 +84,7 @@ static void testHttpClientCanBeConfiguredToAllowSelfSignedCert(Handler handler, } static void testHttpClientCanUseCustomSocketFactory(Handler handler, - HttpConfigurationTestAction testActionShouldSucceed) { - logger.warn("testHttpClientCanUseCustomSocketFactory"); + HttpConfigurationTestAction testActionShouldSucceed) { try { try (HttpServer server = HttpServer.start(handler)) { HttpConfigurationFactory httpConfig = Components.httpConfiguration() @@ -109,7 +101,6 @@ static void testHttpClientCanUseCustomSocketFactory(Handler handler, static void testHttpClientCanUseProxy(Handler handler, HttpConfigurationTestAction testActionShouldSucceed) { - logger.warn("testHttpClientCanUseProxy"); try { try (HttpServer server = HttpServer.start(handler)) { HttpConfigurationFactory httpConfig = Components.httpConfiguration() @@ -126,7 +117,6 @@ static void testHttpClientCanUseProxy(Handler handler, static void testHttpClientCanUseProxyWithBasicAuth(Handler handler, HttpConfigurationTestAction testActionShouldSucceed) { - logger.warn("testHttpClientCanUseProxyWithBasicAuth"); Handler proxyHandler = ctx -> { if (ctx.getRequest().getHeader("Proxy-Authorization") == null) { ctx.setStatus(407); diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceAutoUpdateTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceAutoUpdateTest.java index d60f5cd9f..38b479cb7 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceAutoUpdateTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceAutoUpdateTest.java @@ -1,5 +1,6 @@ package com.launchdarkly.sdk.server.integrations; +import com.launchdarkly.sdk.server.BaseTest; import com.launchdarkly.sdk.server.LDConfig; import com.launchdarkly.sdk.server.TestComponents; import com.launchdarkly.sdk.server.TestComponents.MockDataSourceUpdates; @@ -32,10 +33,10 @@ import static org.junit.Assert.assertTrue; @SuppressWarnings("javadoc") -public class FileDataSourceAutoUpdateTest { +public class FileDataSourceAutoUpdateTest extends BaseTest { private final DataStore store; private MockDataSourceUpdates dataSourceUpdates; - private final LDConfig config = new LDConfig.Builder().build(); + private final LDConfig config = baseConfig().build(); public FileDataSourceAutoUpdateTest() throws Exception { store = inMemoryDataStore(); diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java index 8977a1d3a..e27e96882 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/FileDataSourceTest.java @@ -1,6 +1,7 @@ package com.launchdarkly.sdk.server.integrations; import com.google.common.collect.ImmutableSet; +import com.launchdarkly.sdk.server.BaseTest; import com.launchdarkly.sdk.server.LDConfig; import com.launchdarkly.sdk.server.TestComponents; import com.launchdarkly.sdk.server.TestComponents.MockDataSourceUpdates; @@ -31,12 +32,12 @@ import static org.junit.Assert.assertEquals; @SuppressWarnings("javadoc") -public class FileDataSourceTest { +public class FileDataSourceTest extends BaseTest { private static final Path badFilePath = Paths.get("no-such-file.json"); private final DataStore store; private MockDataSourceUpdates dataSourceUpdates; - private final LDConfig config = new LDConfig.Builder().build(); + private final LDConfig config = baseConfig().build(); public FileDataSourceTest() throws Exception { store = inMemoryDataStore(); From 48ba83104e2e3b755a8f5634950769bcf5055797 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 14 Feb 2022 17:24:30 -0800 Subject: [PATCH 621/673] update readme notes about logging --- CONTRIBUTING.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3bacac93b..3dfa4e2e0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,13 +56,15 @@ The project in the `benchmarks` subdirectory uses [JMH](https://openjdk.java.net ### Logging -Currently the SDK uses SLF4J for all log output. Here some things to keep in mind for good logging behavior: +The SDK uses a LaunchDarkly logging facade, [`com.launchdarkly.logging`](https://github.com/launchdarkly/java-logging). By default, this facade sends output to SLF4J. + +Here some things to keep in mind for good logging behavior: 1. Stick to the standardized logger name scheme defined in `Loggers.java`, preferably for all log output, but definitely for all log output above `DEBUG` level. Logger names can be useful for filtering log output, so it is desirable for users to be able to reference a clear, stable logger name like `com.launchdarkly.sdk.server.LDClient.Events` rather than a class name like `com.launchdarkly.sdk.server.EventSummarizer` which is an implementation detail. The text of a log message should be distinctive enough that we can easily find which class generated the message. -2. Use parameterized messages (`Logger.MAIN.info("The value is {}", someValue)`) rather than string concatenation (`Logger.MAIN.info("The value is " + someValue)`). This avoids the overhead of string concatenation if the logger is not enabled for that level. If computing the value is an expensive operation, and it is _only_ relevant for logging, consider implementing that computation via a custom `toString()` method on some wrapper type so that it will be done lazily only if the log level is enabled. +2. Use parameterized messages (`logger.info("The value is {}", someValue)`) rather than string concatenation (`logger.info("The value is " + someValue)`). This avoids the overhead of string concatenation if the logger is not enabled for that level. If computing the value is an expensive operation, and it is _only_ relevant for logging, consider implementing that computation via a custom `toString()` method on some wrapper type so that it will be done lazily only if the log level is enabled. -3. Exception stacktraces should only be logged at debug level. For instance: `Logger.MAIN.warn("An error happened: {}", ex.toString()); Logger.MAIN.debug(ex.toString(), ex)`. Also, consider whether the stacktrace would be at all meaningful in this particular context; for instance, in a `try` block around a network I/O operation, the stacktrace would only tell us (a) some internal location in Java standard libraries and (b) the location in our own code where we tried to do the operation; (a) is very unlikely to tell us anything that the exception's type and message doesn't already tell us, and (b) could be more clearly communicated by just writing a specific log message. +3. There is a standard pattern for logging exceptions, using the `com.launchdarkly.logging.LogValues` helpers. First, log the basic description of the exception at whatever level is appropriate (`WARN` or `ERROR`): `logger.warn("An error happened: {}", LogValues.exceptionSummary(ex))`. Then, log a stack at debug level: `logger.debug(LogValues.exceptionTrace(ex))`. The `exceptionTrace` helper is lazily evaluated so that the stacktrace will only be computed if debug logging is actually enabled. However, consider whether the stacktrace would be at all meaningful in this particular context; for instance, in a `try` block around a network I/O operation, the stacktrace would only tell us (a) some internal location in Java standard libraries and (b) the location in our own code where we tried to do the operation; (a) is very unlikely to tell us anything that the exception's type and message doesn't already tell us, and (b) could be more clearly communicated by just writing a specific log message. ### Code coverage From b5ac0af790642194bca0e1c4d6c6a10568b6640c Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 14 Feb 2022 17:25:05 -0800 Subject: [PATCH 622/673] set base logger name for SDK per test --- .../service/src/main/java/sdktest/SdkClientEntity.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/contract-tests/service/src/main/java/sdktest/SdkClientEntity.java b/contract-tests/service/src/main/java/sdktest/SdkClientEntity.java index a3f43fd88..2aef74ef1 100644 --- a/contract-tests/service/src/main/java/sdktest/SdkClientEntity.java +++ b/contract-tests/service/src/main/java/sdktest/SdkClientEntity.java @@ -40,7 +40,7 @@ public SdkClientEntity(TestService owner, CreateInstanceParams params) { this.logger = LoggerFactory.getLogger(params.tag); logger.info("Starting SDK client"); - LDConfig config = buildSdkConfig(params.configuration); + LDConfig config = buildSdkConfig(params.configuration, params.tag); this.client = new LDClient(params.configuration.credential, config); if (!client.isInitialized() && !params.configuration.initCanFail) { throw new RuntimeException("client initialization failed or timed out"); @@ -182,9 +182,11 @@ public void close() { logger.info("Test ended"); } - private LDConfig buildSdkConfig(SdkConfigParams params) { + private LDConfig buildSdkConfig(SdkConfigParams params, String tag) { LDConfig.Builder builder = new LDConfig.Builder(); + builder.logging(Components.logging().baseLoggerName(tag + ".sdk")); + if (params.startWaitTimeMs != null) { builder.startWait(Duration.ofMillis(params.startWaitTimeMs.longValue())); } From e539ce1da52592a87b96b2af943c79bebbf98f67 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 14 Feb 2022 17:27:25 -0800 Subject: [PATCH 623/673] comment --- .../com/launchdarkly/sdk/server/DefaultEventProcessor.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java index 3358b5a76..f321a6616 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java @@ -284,6 +284,9 @@ private void onUncaughtException(Thread thread, Throwable e) { logger.error("Event processor thread was terminated by an unrecoverable error. No more analytics events will be sent. {} {}", LogValues.exceptionSummary(e), LogValues.exceptionTrace(e)); + // Note that this is a rare case where we always log the exception stacktrace, instead of only + // logging it at debug level. That's because an exception of this kind should never happen and, + // if it happens, may be difficult to debug. // Flip the switch to prevent DefaultEventProcessor from putting any more messages on the queue closed.set(true); From c514d8e359a890c3397eb32f082e6346009f8c16 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 14 Feb 2022 17:42:36 -0800 Subject: [PATCH 624/673] javadoc fixes --- .../src/main/java/sdktest/SdkClientEntity.java | 12 +++++++----- .../launchdarkly/sdk/server/Components.java | 18 +++++++++++++++--- .../LoggingConfigurationBuilder.java | 4 ++-- 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/contract-tests/service/src/main/java/sdktest/SdkClientEntity.java b/contract-tests/service/src/main/java/sdktest/SdkClientEntity.java index 2aef74ef1..709f26c9e 100644 --- a/contract-tests/service/src/main/java/sdktest/SdkClientEntity.java +++ b/contract-tests/service/src/main/java/sdktest/SdkClientEntity.java @@ -1,5 +1,7 @@ package sdktest; +import com.launchdarkly.logging.LDLogAdapter; +import com.launchdarkly.logging.LDLogger; import com.launchdarkly.sdk.EvaluationDetail; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.json.JsonSerialization; @@ -34,13 +36,13 @@ public class SdkClientEntity { private final LDClient client; - final Logger logger; + final LDLogger logger; public SdkClientEntity(TestService owner, CreateInstanceParams params) { - this.logger = LoggerFactory.getLogger(params.tag); + this.logger = LDLogger.withAdapter(owner.logging, params.tag); logger.info("Starting SDK client"); - LDConfig config = buildSdkConfig(params.configuration, params.tag); + LDConfig config = buildSdkConfig(params.configuration, owner.logging, params.tag); this.client = new LDClient(params.configuration.credential, config); if (!client.isInitialized() && !params.configuration.initCanFail) { throw new RuntimeException("client initialization failed or timed out"); @@ -182,10 +184,10 @@ public void close() { logger.info("Test ended"); } - private LDConfig buildSdkConfig(SdkConfigParams params, String tag) { + private LDConfig buildSdkConfig(SdkConfigParams params, LDLogAdapter logAdapter, String tag) { LDConfig.Builder builder = new LDConfig.Builder(); - builder.logging(Components.logging().baseLoggerName(tag + ".sdk")); + builder.logging(Components.logging(logAdapter).baseLoggerName(tag + ".sdk")); if (params.startWaitTimeMs != null) { builder.startWait(Duration.ofMillis(params.startWaitTimeMs.longValue())); diff --git a/src/main/java/com/launchdarkly/sdk/server/Components.java b/src/main/java/com/launchdarkly/sdk/server/Components.java index d57a1be2a..f87603146 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Components.java +++ b/src/main/java/com/launchdarkly/sdk/server/Components.java @@ -324,22 +324,34 @@ public static LoggingConfigurationBuilder logging() { } /** - * Returns a configuration builder for the SDK's logging configuration. + * Returns a configuration builder for the SDK's logging configuration, specifying the + * implementation of logging to use. + *

      + * This is a shortcut for Components.logging().adapter(logAdapter). The + * com.launchdarkly.logging + * API defines the {@link LDLogAdapter} interface to specify where log output should be sent. By default, + * it is set to {@link com.launchdarkly.logging.LDSLF4J#adapter()}, meaning that output will be sent to + * SLF4J and controlled by the SLF4J configuration. You may use + * the {@link com.launchdarkly.logging.Logs} factory methods, or a custom implementation, to handle log + * output differently. For instance, you may specify {@link com.launchdarkly.logging.Logs#basic()} for + * simple console output, or {@link com.launchdarkly.logging.Logs#toJavaUtilLogging()} to use the + * java.util.logging framework. *

      * Passing this to {@link LDConfig.Builder#logging(com.launchdarkly.sdk.server.interfaces.LoggingConfigurationFactory)}, * after setting any desired properties on the builder, applies this configuration to the SDK. *

      
          *     LDConfig config = new LDConfig.Builder()
          *         .logging(
      -   *              Components.logging()
      -   *                  .logDataSourceOutageAsErrorAfter(Duration.ofSeconds(120))
      +   *              Components.logging(Logs.basic())
          *         )
          *         .build();
          * 
      * + * @param logAdapter the log adapter * @return a configuration builder * @since 5.8.0 * @see LDConfig.Builder#logging(com.launchdarkly.sdk.server.interfaces.LoggingConfigurationFactory) + * @see LoggingConfigurationBuilder#adapter(LDLogAdapter) */ public static LoggingConfigurationBuilder logging(LDLogAdapter logAdapter) { return logging().adapter(logAdapter); diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/LoggingConfigurationBuilder.java b/src/main/java/com/launchdarkly/sdk/server/integrations/LoggingConfigurationBuilder.java index 7a2df54e7..7eab3d6e0 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/LoggingConfigurationBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/LoggingConfigurationBuilder.java @@ -40,7 +40,7 @@ public abstract class LoggingConfigurationBuilder implements LoggingConfiguratio /** * Specifies the implementation of logging to use. *

      - * The com.aunchdarkly.logging + * The com.launchdarkly.logging * API defines the {@link LDLogAdapter} interface to specify where log output should be sent. By default, * it is set to {@link com.launchdarkly.logging.LDSLF4J#adapter()}, meaning that output will be sent to * SLF4J and controlled by the SLF4J configuration. You may use @@ -93,7 +93,7 @@ public LoggingConfigurationBuilder adapter(LDLogAdapter logAdapter) { *

    2. .Evaluation: problems in evaluating a feature flag or flags, which were * caused by invalid flag data or incorrect usage of the SDK rather than for instance a * database problem.
    3. - *
    4. codec>.Events problems or status messages regarding the SDK's delivery of + *
    5. .Events problems or status messages regarding the SDK's delivery of * analytics event data to LaunchDarkly.
    6. * *

      From 723379356d9a7b06aee695ed55cd32fd158ba6c2 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 14 Feb 2022 17:47:25 -0800 Subject: [PATCH 625/673] revert accidental commit --- .../src/main/java/sdktest/SdkClientEntity.java | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/contract-tests/service/src/main/java/sdktest/SdkClientEntity.java b/contract-tests/service/src/main/java/sdktest/SdkClientEntity.java index 709f26c9e..2aef74ef1 100644 --- a/contract-tests/service/src/main/java/sdktest/SdkClientEntity.java +++ b/contract-tests/service/src/main/java/sdktest/SdkClientEntity.java @@ -1,7 +1,5 @@ package sdktest; -import com.launchdarkly.logging.LDLogAdapter; -import com.launchdarkly.logging.LDLogger; import com.launchdarkly.sdk.EvaluationDetail; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.json.JsonSerialization; @@ -36,13 +34,13 @@ public class SdkClientEntity { private final LDClient client; - final LDLogger logger; + final Logger logger; public SdkClientEntity(TestService owner, CreateInstanceParams params) { - this.logger = LDLogger.withAdapter(owner.logging, params.tag); + this.logger = LoggerFactory.getLogger(params.tag); logger.info("Starting SDK client"); - LDConfig config = buildSdkConfig(params.configuration, owner.logging, params.tag); + LDConfig config = buildSdkConfig(params.configuration, params.tag); this.client = new LDClient(params.configuration.credential, config); if (!client.isInitialized() && !params.configuration.initCanFail) { throw new RuntimeException("client initialization failed or timed out"); @@ -184,10 +182,10 @@ public void close() { logger.info("Test ended"); } - private LDConfig buildSdkConfig(SdkConfigParams params, LDLogAdapter logAdapter, String tag) { + private LDConfig buildSdkConfig(SdkConfigParams params, String tag) { LDConfig.Builder builder = new LDConfig.Builder(); - builder.logging(Components.logging(logAdapter).baseLoggerName(tag + ".sdk")); + builder.logging(Components.logging().baseLoggerName(tag + ".sdk")); if (params.startWaitTimeMs != null) { builder.startWait(Duration.ofMillis(params.startWaitTimeMs.longValue())); From 75a4c8ecac9ac8da65479671b6c777eee504eb2f Mon Sep 17 00:00:00 2001 From: Alex Engelberg Date: Wed, 13 Apr 2022 10:58:02 -0700 Subject: [PATCH 626/673] Upload JUnit XML to CircleCI on failure (#320) Fix a bug in the CircleCI config that was only uploading JUnit XML on _success_, not failure. --- .circleci/config.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 37d487759..3f2bc8df9 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -80,10 +80,11 @@ jobs: ./gradlew jacocoTestReport mkdir -p coverage/ cp -r build/reports/jacoco/test/* ./coverage - - run: mkdir -p ~/junit/ - run: name: Save test results - command: find . -type f -regex ".*/build/test-results/.*xml" -exec cp {} ~/junit/ \; + command: | + mkdir -p ~/junit/ + find . -type f -regex ".*/build/test-results/.*xml" -exec cp {} ~/junit/ \; when: always - run: make build-contract-tests @@ -126,7 +127,8 @@ jobs: name: save test results command: | mkdir .\junit - cp build/test-results/test/*.xml junit + cp build/test-results/test/*.xml junit -ErrorAction SilentlyContinue + when: always - store_test_results: path: .\junit - store_artifacts: From 7612288d3421d62a75fac64a21222b129aa2686f Mon Sep 17 00:00:00 2001 From: Alex Engelberg Date: Mon, 18 Apr 2022 12:04:32 -0700 Subject: [PATCH 627/673] Add application tag support (#319) --- .../main/java/sdktest/Representations.java | 6 ++ .../main/java/sdktest/SdkClientEntity.java | 12 +++ .../src/main/java/sdktest/TestService.java | 3 +- .../sdk/server/ClientContextImpl.java | 4 +- .../launchdarkly/sdk/server/Components.java | 24 ++++++ .../sdk/server/ComponentsImpl.java | 6 ++ .../com/launchdarkly/sdk/server/LDConfig.java | 21 +++++ .../com/launchdarkly/sdk/server/Util.java | 36 +++++++++ .../integrations/ApplicationInfoBuilder.java | 77 +++++++++++++++++++ .../server/interfaces/ApplicationInfo.java | 46 +++++++++++ .../server/interfaces/BasicConfiguration.java | 31 +++++++- .../sdk/server/ClientContextImplTest.java | 2 +- .../server/DefaultFeatureRequestorTest.java | 2 +- .../launchdarkly/sdk/server/LDConfigTest.java | 2 +- .../com/launchdarkly/sdk/server/UtilTest.java | 13 ++++ .../ApplicationInfoBuilderTest.java | 27 +++++++ .../HttpConfigurationBuilderTest.java | 12 ++- .../LoggingConfigurationBuilderTest.java | 2 +- 18 files changed, 316 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/launchdarkly/sdk/server/integrations/ApplicationInfoBuilder.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/interfaces/ApplicationInfo.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/integrations/ApplicationInfoBuilderTest.java diff --git a/contract-tests/service/src/main/java/sdktest/Representations.java b/contract-tests/service/src/main/java/sdktest/Representations.java index 60cf2b2a4..c30e538d9 100644 --- a/contract-tests/service/src/main/java/sdktest/Representations.java +++ b/contract-tests/service/src/main/java/sdktest/Representations.java @@ -25,6 +25,7 @@ public static class SdkConfigParams { SdkConfigStreamParams streaming; SdkConfigEventParams events; SdkConfigBigSegmentsParams bigSegments; + SdkConfigTagParams tags; } public static class SdkConfigStreamParams { @@ -49,6 +50,11 @@ public static class SdkConfigBigSegmentsParams { Long statusPollIntervalMs; Long staleAfterMs; } + + public static class SdkConfigTagParams { + String applicationId; + String applicationVersion; + } public static class CommandParams { String command; diff --git a/contract-tests/service/src/main/java/sdktest/SdkClientEntity.java b/contract-tests/service/src/main/java/sdktest/SdkClientEntity.java index a3f43fd88..9bc473ad1 100644 --- a/contract-tests/service/src/main/java/sdktest/SdkClientEntity.java +++ b/contract-tests/service/src/main/java/sdktest/SdkClientEntity.java @@ -8,6 +8,7 @@ import com.launchdarkly.sdk.server.FlagsStateOption; import com.launchdarkly.sdk.server.LDClient; import com.launchdarkly.sdk.server.LDConfig; +import com.launchdarkly.sdk.server.integrations.ApplicationInfoBuilder; import com.launchdarkly.sdk.server.integrations.BigSegmentsConfigurationBuilder; import com.launchdarkly.sdk.server.integrations.EventProcessorBuilder; import com.launchdarkly.sdk.server.integrations.StreamingDataSourceBuilder; @@ -235,6 +236,17 @@ private LDConfig buildSdkConfig(SdkConfigParams params) { } builder.bigSegments(bsb); } + + if (params.tags != null) { + ApplicationInfoBuilder ab = Components.applicationInfo(); + if (params.tags.applicationId != null) { + ab.applicationId(params.tags.applicationId); + } + if (params.tags.applicationVersion != null) { + ab.applicationVersion(params.tags.applicationVersion); + } + builder.applicationInfo(ab); + } return builder.build(); } diff --git a/contract-tests/service/src/main/java/sdktest/TestService.java b/contract-tests/service/src/main/java/sdktest/TestService.java index af6647879..68eed1456 100644 --- a/contract-tests/service/src/main/java/sdktest/TestService.java +++ b/contract-tests/service/src/main/java/sdktest/TestService.java @@ -26,7 +26,8 @@ public class TestService { "all-flags-client-side-only", "all-flags-details-only-for-tracked-flags", "all-flags-with-reasons", - "big-segments" + "big-segments", + "tags" }; static final Gson gson = new GsonBuilder().serializeNulls().create(); diff --git a/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java b/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java index 1cdf659fe..8004b36f9 100644 --- a/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java @@ -1,5 +1,6 @@ package com.launchdarkly.sdk.server; +import com.launchdarkly.sdk.server.interfaces.ApplicationInfo; import com.launchdarkly.sdk.server.interfaces.BasicConfiguration; import com.launchdarkly.sdk.server.interfaces.ClientContext; import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; @@ -51,7 +52,8 @@ private ClientContextImpl( ScheduledExecutorService sharedExecutor, DiagnosticAccumulator diagnosticAccumulator ) { - this.basicConfiguration = new BasicConfiguration(sdkKey, configuration.offline, configuration.threadPriority); + ApplicationInfo applicationInfo = configuration.applicationInfoBuilder.createApplicationInfo(); + this.basicConfiguration = new BasicConfiguration(sdkKey, configuration.offline, configuration.threadPriority, applicationInfo); this.httpConfiguration = configuration.httpConfigFactory.createHttpConfiguration(basicConfiguration); this.loggingConfiguration = configuration.loggingConfigFactory.createLoggingConfiguration(basicConfiguration); diff --git a/src/main/java/com/launchdarkly/sdk/server/Components.java b/src/main/java/com/launchdarkly/sdk/server/Components.java index bede125f5..171406f39 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Components.java +++ b/src/main/java/com/launchdarkly/sdk/server/Components.java @@ -11,6 +11,7 @@ import com.launchdarkly.sdk.server.ComponentsImpl.PersistentDataStoreBuilderImpl; import com.launchdarkly.sdk.server.ComponentsImpl.PollingDataSourceBuilderImpl; import com.launchdarkly.sdk.server.ComponentsImpl.StreamingDataSourceBuilderImpl; +import com.launchdarkly.sdk.server.integrations.ApplicationInfoBuilder; import com.launchdarkly.sdk.server.integrations.BigSegmentsConfigurationBuilder; import com.launchdarkly.sdk.server.integrations.EventProcessorBuilder; import com.launchdarkly.sdk.server.integrations.HttpConfigurationBuilder; @@ -320,4 +321,27 @@ public static HttpAuthentication httpBasicAuthentication(String username, String public static LoggingConfigurationBuilder logging() { return new LoggingConfigurationBuilderImpl(); } + + /** + * Returns a configuration builder for the SDK's application metadata. + *

      + * Passing this to {@link LDConfig.Builder#applicationInfo(com.launchdarkly.sdk.server.integrations.ApplicationInfoBuilder)}, + * after setting any desired properties on the builder, applies this configuration to the SDK. + *

      
      +   *     LDConfig config = new LDConfig.Builder()
      +   *         .applicationInfo(
      +   *             Components.applicationInfo()
      +   *                 .applicationId("authentication-service")
      +   *                 .applicationVersion("1.0.0")
      +   *         )
      +   *         .build();
      +   * 
      + * + * @return a builder object + * @since 5.8.0 + * @see LDConfig.Builder#applicationInfo(com.launchdarkly.sdk.server.integrations.ApplicationInfoBuilder) + */ + public static ApplicationInfoBuilder applicationInfo() { + return new ApplicationInfoBuilder(); + } } diff --git a/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java b/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java index 33ddaa4fb..86aab22c2 100644 --- a/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java @@ -253,6 +253,12 @@ public HttpConfiguration createHttpConfiguration(BasicConfiguration basicConfigu ImmutableMap.Builder headers = ImmutableMap.builder(); headers.put("Authorization", basicConfiguration.getSdkKey()); headers.put("User-Agent", "JavaClient/" + Version.SDK_VERSION); + if (basicConfiguration.getApplicationInfo() != null) { + String tagHeader = Util.applicationTagHeader(basicConfiguration.getApplicationInfo()); + if (!tagHeader.isEmpty()) { + headers.put("X-LaunchDarkly-Tags", tagHeader); + } + } if (wrapperName != null) { String wrapperId = wrapperVersion == null ? wrapperName : (wrapperName + "/" + wrapperVersion); headers.put("X-LaunchDarkly-Wrapper", wrapperId); diff --git a/src/main/java/com/launchdarkly/sdk/server/LDConfig.java b/src/main/java/com/launchdarkly/sdk/server/LDConfig.java index 2ae272aa1..4c18a51bd 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDConfig.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDConfig.java @@ -1,6 +1,7 @@ package com.launchdarkly.sdk.server; import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.server.integrations.ApplicationInfoBuilder; import com.launchdarkly.sdk.server.integrations.BigSegmentsConfigurationBuilder; import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreFactory; import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; @@ -25,6 +26,7 @@ public final class LDConfig { protected static final LDConfig DEFAULT = new Builder().build(); + final ApplicationInfoBuilder applicationInfoBuilder; final BigSegmentsConfigurationBuilder bigSegmentsConfigBuilder; final DataSourceFactory dataSourceFactory; final DataStoreFactory dataStoreFactory; @@ -46,6 +48,8 @@ protected LDConfig(Builder builder) { this.eventProcessorFactory = builder.eventProcessorFactory == null ? Components.sendEvents() : builder.eventProcessorFactory; } + this.applicationInfoBuilder = builder.applicationInfoBuilder == null ? Components.applicationInfo() : + builder.applicationInfoBuilder; this.bigSegmentsConfigBuilder = builder.bigSegmentsConfigBuilder == null ? Components.bigSegments(null) : builder.bigSegmentsConfigBuilder; this.dataStoreFactory = builder.dataStoreFactory == null ? Components.inMemoryDataStore() : @@ -72,6 +76,7 @@ protected LDConfig(Builder builder) { * */ public static class Builder { + private ApplicationInfoBuilder applicationInfoBuilder = null; private BigSegmentsConfigurationBuilder bigSegmentsConfigBuilder = null; private DataSourceFactory dataSourceFactory = null; private DataStoreFactory dataStoreFactory = null; @@ -89,6 +94,22 @@ public static class Builder { public Builder() { } + /** + * Sets the SDK's application metadata, which may be used in LaunchDarkly analytics or other product features, + * but does not affect feature flag evaluations. + *

      + * This object is normally a configuration builder obtained from {@link Components#applicationInfo()}, + * which has methods for setting individual logging-related properties. + * + * @param applicationInfoBuilder a configuration builder object returned by {@link Components#applicationInfo()} + * @return the builder + * @since 5.8.0 + */ + public Builder applicationInfo(ApplicationInfoBuilder applicationInfoBuilder) { + this.applicationInfoBuilder = applicationInfoBuilder; + return this; + } + /** * Sets the configuration of the SDK's Big Segments feature. *

      diff --git a/src/main/java/com/launchdarkly/sdk/server/Util.java b/src/main/java/com/launchdarkly/sdk/server/Util.java index fc6a4cc02..990e12343 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Util.java +++ b/src/main/java/com/launchdarkly/sdk/server/Util.java @@ -1,5 +1,7 @@ package com.launchdarkly.sdk.server; +import com.launchdarkly.sdk.server.Loggers; +import com.launchdarkly.sdk.server.interfaces.ApplicationInfo; import com.launchdarkly.sdk.server.interfaces.HttpAuthentication; import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; @@ -13,7 +15,10 @@ import java.nio.file.SimpleFileVisitor; import java.nio.file.attribute.BasicFileAttributes; import java.time.Duration; +import java.util.ArrayList; +import java.util.List; import java.util.Map; +import java.util.regex.Pattern; import java.util.concurrent.TimeUnit; import static com.google.common.collect.Iterables.transform; @@ -207,4 +212,35 @@ static URI concatenateUriPath(URI baseUri, String path) { String addPath = path.startsWith("/") ? path.substring(1) : path; return URI.create(uriStr + (uriStr.endsWith("/") ? "" : "/") + addPath); } + + // Tag values must not be empty, and only contain letters, numbers, `.`, `_`, or `-`. + private static Pattern TAG_VALUE_REGEX = Pattern.compile("^[\\w.-]+$"); + + /** + * Builds the "X-LaunchDarkly-Tags" HTTP header out of the configured application info. + * + * @param applicationInfo the application metadata + * @return a space-separated string of tags, e.g. "application-id/authentication-service application-version/1.0.0" + */ + static String applicationTagHeader(ApplicationInfo applicationInfo) { + String[][] tags = { + {"applicationId", "application-id", applicationInfo.getApplicationId()}, + {"applicationVersion", "application-version", applicationInfo.getApplicationVersion()}, + }; + List parts = new ArrayList<>(); + for (String[] row : tags) { + String javaKey = row[0]; + String tagKey = row[1]; + String tagVal = row[2]; + if (tagVal == null) { + continue; + } + if (!TAG_VALUE_REGEX.matcher(tagVal).matches()) { + Loggers.MAIN.warn("Value of ApplicationInfo.{} contained invalid characters and was discarded", javaKey); + continue; + } + parts.add(tagKey + "/" + tagVal); + } + return String.join(" ", parts); + } } diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/ApplicationInfoBuilder.java b/src/main/java/com/launchdarkly/sdk/server/integrations/ApplicationInfoBuilder.java new file mode 100644 index 000000000..1b510ef72 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/ApplicationInfoBuilder.java @@ -0,0 +1,77 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.interfaces.ApplicationInfo; + +/** + * Contains methods for configuring the SDK's application metadata. + *

      + * Application metadata may be used in LaunchDarkly analytics or other product features, but does not affect feature flag evaluations. + *

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

      
      + *     LDConfig config = new LDConfig.Builder()
      + *         .applicationInfo(
      + *             Components.applicationInfo()
      + *                 .applicationId("authentication-service")
      + *                 .applicationVersion("1.0.0")
      + *         )
      + *         .build();
      + * 
      + *

      + * + * @since 5.8.0 + */ +public final class ApplicationInfoBuilder { + private String applicationId; + private String applicationVersion; + + /** + * Create an empty ApplicationInfoBuilder. + * + * @see Components#applicationInfo() + */ + public ApplicationInfoBuilder() {} + + /** + * Sets a unique identifier representing the application where the LaunchDarkly SDK is running. + *

      + * This can be specified as any string value as long as it only uses the following characters: ASCII + * letters, ASCII digits, period, hyphen, underscore. A string containing any other characters will be + * ignored. + * + * @param applicationId the application identifier + * @return the builder + */ + public ApplicationInfoBuilder applicationId(String applicationId) { + this.applicationId = applicationId; + return this; + } + + /** + * Sets a unique identifier representing the version of the application where the LaunchDarkly SDK + * is running. + *

      + * This can be specified as any string value as long as it only uses the following characters: ASCII + * letters, ASCII digits, period, hyphen, underscore. A string containing any other characters will be + * ignored. + * + * @param applicationVersion the application version + * @return the builder + */ + public ApplicationInfoBuilder applicationVersion(String applicationVersion) { + this.applicationVersion = applicationVersion; + return this; + } + + /** + * Called internally by the SDK to create the configuration object. + * + * @return the configuration object + */ + public ApplicationInfo createApplicationInfo() { + return new ApplicationInfo(applicationId, applicationVersion); + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/ApplicationInfo.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/ApplicationInfo.java new file mode 100644 index 000000000..e64ed6c0b --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/ApplicationInfo.java @@ -0,0 +1,46 @@ +package com.launchdarkly.sdk.server.interfaces; + +import com.launchdarkly.sdk.server.integrations.ApplicationInfoBuilder; + +/** + * Encapsulates the SDK's application metadata. + *

      + * See {@link ApplicationInfoBuilder} for more details on these properties. + * + * @since 5.8.0 + */ +public final class ApplicationInfo { + private String applicationId; + private String applicationVersion; + + /** + * Used internally by the SDK to store application metadata. + * + * @param applicationId the application ID + * @param applicationVersion the application version + * @see ApplicationInfoBuilder + */ + public ApplicationInfo(String applicationId, String applicationVersion) { + this.applicationId = applicationId; + this.applicationVersion = applicationVersion; + } + + /** + * A unique identifier representing the application where the LaunchDarkly SDK is running. + * + * @return the application identifier, or null + */ + public String getApplicationId() { + return applicationId; + } + + /** + * A unique identifier representing the version of the application where the + * LaunchDarkly SDK is running. + * + * @return the application version, or null + */ + public String getApplicationVersion() { + return applicationVersion; + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/BasicConfiguration.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/BasicConfiguration.java index 926cf3f13..3a19a9737 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/BasicConfiguration.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/BasicConfiguration.java @@ -9,18 +9,33 @@ public final class BasicConfiguration { private final String sdkKey; private final boolean offline; private final int threadPriority; - + private final ApplicationInfo applicationInfo; + /** * Constructs an instance. - * + * * @param sdkKey the SDK key * @param offline true if the SDK was configured to be completely offline * @param threadPriority the thread priority that should be used for any worker threads created by SDK components + * @param applicationInfo metadata about the application using this SDK */ - public BasicConfiguration(String sdkKey, boolean offline, int threadPriority) { + public BasicConfiguration(String sdkKey, boolean offline, int threadPriority, ApplicationInfo applicationInfo) { this.sdkKey = sdkKey; this.offline = offline; this.threadPriority = threadPriority; + this.applicationInfo = applicationInfo; + } + + /** + * Constructs an instance. + * + * @param sdkKey the SDK key + * @param offline true if the SDK was configured to be completely offline + * @param threadPriority the thread priority that should be used for any worker threads created by SDK components + */ + @Deprecated + public BasicConfiguration(String sdkKey, boolean offline, int threadPriority) { + this(sdkKey, offline, threadPriority, null); } /** @@ -51,4 +66,14 @@ public boolean isOffline() { public int getThreadPriority() { return threadPriority; } + + /** + * The metadata about the application using this SDK. + * + * @return the application info + * @see com.launchdarkly.sdk.server.LDConfig.Builder#applicationInfo(com.launchdarkly.sdk.server.integrations.ApplicationInfoBuilder) + */ + public ApplicationInfo getApplicationInfo() { + return applicationInfo; + } } diff --git a/src/test/java/com/launchdarkly/sdk/server/ClientContextImplTest.java b/src/test/java/com/launchdarkly/sdk/server/ClientContextImplTest.java index 1268e5378..026576a5b 100644 --- a/src/test/java/com/launchdarkly/sdk/server/ClientContextImplTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/ClientContextImplTest.java @@ -133,7 +133,7 @@ public void packagePrivatePropertiesHaveDefaultsIfContextIsNotOurImplementation( private static final class SomeOtherContextImpl implements ClientContext { public BasicConfiguration getBasic() { - return new BasicConfiguration(SDK_KEY, false, Thread.MIN_PRIORITY); + return new BasicConfiguration(SDK_KEY, false, Thread.MIN_PRIORITY, null); } public HttpConfiguration getHttp() { diff --git a/src/test/java/com/launchdarkly/sdk/server/DefaultFeatureRequestorTest.java b/src/test/java/com/launchdarkly/sdk/server/DefaultFeatureRequestorTest.java index 2c3f52587..4844a5e0f 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DefaultFeatureRequestorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultFeatureRequestorTest.java @@ -42,7 +42,7 @@ private DefaultFeatureRequestor makeRequestor(HttpServer server, LDConfig config } private HttpConfiguration makeHttpConfig(LDConfig config) { - return config.httpConfigFactory.createHttpConfiguration(new BasicConfiguration(sdkKey, false, 0)); + return config.httpConfigFactory.createHttpConfiguration(new BasicConfiguration(sdkKey, false, 0, null)); } private void verifyExpectedData(FeatureRequestor.AllData data) { diff --git a/src/test/java/com/launchdarkly/sdk/server/LDConfigTest.java b/src/test/java/com/launchdarkly/sdk/server/LDConfigTest.java index 0afba1f6b..3794e990a 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDConfigTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDConfigTest.java @@ -26,7 +26,7 @@ @SuppressWarnings("javadoc") public class LDConfigTest { - private static final BasicConfiguration BASIC_CONFIG = new BasicConfiguration("", false, 0); + private static final BasicConfiguration BASIC_CONFIG = new BasicConfiguration("", false, 0, null); @Test public void defaults() { diff --git a/src/test/java/com/launchdarkly/sdk/server/UtilTest.java b/src/test/java/com/launchdarkly/sdk/server/UtilTest.java index 6cf5b6fb3..2a9b91151 100644 --- a/src/test/java/com/launchdarkly/sdk/server/UtilTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/UtilTest.java @@ -1,5 +1,6 @@ package com.launchdarkly.sdk.server; +import com.launchdarkly.sdk.server.interfaces.ApplicationInfo; import com.launchdarkly.sdk.server.interfaces.HttpAuthentication; import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; @@ -90,4 +91,16 @@ public void describeDuration() { assertEquals("1 minute", Util.describeDuration(Duration.ofMillis(60000))); assertEquals("2 minutes", Util.describeDuration(Duration.ofMillis(120000))); } + + @Test + public void applicationTagHeader() { + assertEquals("", Util.applicationTagHeader(new ApplicationInfo(null, null))); + assertEquals("application-id/foo", Util.applicationTagHeader(new ApplicationInfo("foo", null))); + assertEquals("application-version/1.0.0", Util.applicationTagHeader(new ApplicationInfo(null, "1.0.0"))); + assertEquals("application-id/foo application-version/1.0.0", Util.applicationTagHeader(new ApplicationInfo("foo", "1.0.0"))); + // Values with invalid characters get discarded + assertEquals("", Util.applicationTagHeader(new ApplicationInfo("invalid name", "lol!"))); + // Empty values get discarded + assertEquals("", Util.applicationTagHeader(new ApplicationInfo("", ""))); + } } diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/ApplicationInfoBuilderTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/ApplicationInfoBuilderTest.java new file mode 100644 index 000000000..9b9977e06 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/ApplicationInfoBuilderTest.java @@ -0,0 +1,27 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.interfaces.ApplicationInfo; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +@SuppressWarnings("javadoc") +public class ApplicationInfoBuilderTest { + @Test + public void infoBuilder() { + ApplicationInfo i1 = Components.applicationInfo() + .createApplicationInfo(); + assertNull(i1.getApplicationId()); + assertNull(i1.getApplicationVersion()); + + ApplicationInfo i2 = Components.applicationInfo() + .applicationId("authentication-service") + .applicationVersion("1.0.0") + .createApplicationInfo(); + assertEquals("authentication-service", i2.getApplicationId()); + assertEquals("1.0.0", i2.getApplicationVersion()); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/HttpConfigurationBuilderTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/HttpConfigurationBuilderTest.java index 6b2b6bea6..06783edf8 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/HttpConfigurationBuilderTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/HttpConfigurationBuilderTest.java @@ -2,6 +2,7 @@ import com.google.common.collect.ImmutableMap; import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.interfaces.ApplicationInfo; import com.launchdarkly.sdk.server.interfaces.BasicConfiguration; import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; @@ -32,7 +33,7 @@ @SuppressWarnings("javadoc") public class HttpConfigurationBuilderTest { private static final String SDK_KEY = "sdk-key"; - private static final BasicConfiguration BASIC_CONFIG = new BasicConfiguration(SDK_KEY, false, 0); + private static final BasicConfiguration BASIC_CONFIG = new BasicConfiguration(SDK_KEY, false, 0, null); private static ImmutableMap.Builder buildBasicHeaders() { return ImmutableMap.builder() @@ -136,6 +137,15 @@ public void testWrapperWithVersion() { .createHttpConfiguration(BASIC_CONFIG); assertEquals("Scala/0.1.0", ImmutableMap.copyOf(hc.getDefaultHeaders()).get("X-LaunchDarkly-Wrapper")); } + + @Test + public void testApplicationTags() { + ApplicationInfo info = new ApplicationInfo("authentication-service", "1.0.0"); + BasicConfiguration basicConfigWithTags = new BasicConfiguration(SDK_KEY, false, 0, info); + HttpConfiguration hc = Components.httpConfiguration() + .createHttpConfiguration(basicConfigWithTags); + assertEquals("application-id/authentication-service application-version/1.0.0", ImmutableMap.copyOf(hc.getDefaultHeaders()).get("X-LaunchDarkly-Tags")); + } public static class StubSocketFactory extends SocketFactory { public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort) diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/LoggingConfigurationBuilderTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/LoggingConfigurationBuilderTest.java index 33d86a53a..f9c8f6992 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/LoggingConfigurationBuilderTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/LoggingConfigurationBuilderTest.java @@ -14,7 +14,7 @@ @SuppressWarnings("javadoc") public class LoggingConfigurationBuilderTest { private static final String SDK_KEY = "sdk-key"; - private static final BasicConfiguration BASIC_CONFIG = new BasicConfiguration(SDK_KEY, false, 0); + private static final BasicConfiguration BASIC_CONFIG = new BasicConfiguration(SDK_KEY, false, 0, null); @Test public void testDefaults() { From ddf894558ea7b9795428e3a6ee0da5e5e797c91a Mon Sep 17 00:00:00 2001 From: Alex Engelberg Date: Mon, 2 May 2022 17:33:46 -0700 Subject: [PATCH 628/673] Enforce 64 character limit on application tag values (#323) --- src/main/java/com/launchdarkly/sdk/server/Util.java | 4 ++++ src/test/java/com/launchdarkly/sdk/server/UtilTest.java | 2 ++ 2 files changed, 6 insertions(+) diff --git a/src/main/java/com/launchdarkly/sdk/server/Util.java b/src/main/java/com/launchdarkly/sdk/server/Util.java index 990e12343..f199407b7 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Util.java +++ b/src/main/java/com/launchdarkly/sdk/server/Util.java @@ -239,6 +239,10 @@ static String applicationTagHeader(ApplicationInfo applicationInfo) { Loggers.MAIN.warn("Value of ApplicationInfo.{} contained invalid characters and was discarded", javaKey); continue; } + if (tagVal.length() > 64) { + Loggers.MAIN.warn("Value of ApplicationInfo.{} was longer than 64 characters and was discarded", javaKey); + continue; + } parts.add(tagKey + "/" + tagVal); } return String.join(" ", parts); diff --git a/src/test/java/com/launchdarkly/sdk/server/UtilTest.java b/src/test/java/com/launchdarkly/sdk/server/UtilTest.java index 2a9b91151..1aca2b32f 100644 --- a/src/test/java/com/launchdarkly/sdk/server/UtilTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/UtilTest.java @@ -100,6 +100,8 @@ public void applicationTagHeader() { assertEquals("application-id/foo application-version/1.0.0", Util.applicationTagHeader(new ApplicationInfo("foo", "1.0.0"))); // Values with invalid characters get discarded assertEquals("", Util.applicationTagHeader(new ApplicationInfo("invalid name", "lol!"))); + // Values over 64 chars get discarded + assertEquals("", Util.applicationTagHeader(new ApplicationInfo("look-at-this-incredibly-long-application-id-like-wow-it-sure-is-verbose", null))); // Empty values get discarded assertEquals("", Util.applicationTagHeader(new ApplicationInfo("", ""))); } From e2ac86256190d577a5c46a7f88e60b7c94295bd7 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 3 May 2022 16:23:52 -0700 Subject: [PATCH 629/673] fix "wrong type" logic in evaluations when default value is null --- .../com/launchdarkly/sdk/server/LDClient.java | 42 ++++++++++--------- .../sdk/server/LDClientEvaluationTest.java | 36 ++++++++++++++++ 2 files changed, 59 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClient.java b/src/main/java/com/launchdarkly/sdk/server/LDClient.java index 3bafc888f..3e927d401 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClient.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClient.java @@ -11,6 +11,7 @@ import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.LDValueType; import com.launchdarkly.sdk.server.integrations.EventProcessorBuilder; import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreStatusProvider; import com.launchdarkly.sdk.server.interfaces.BigSegmentsConfiguration; @@ -365,64 +366,65 @@ public FeatureFlagsState allFlagsState(LDUser user, FlagsStateOption... options) @Override public boolean boolVariation(String featureKey, LDUser user, boolean defaultValue) { - return evaluate(featureKey, user, LDValue.of(defaultValue), true).booleanValue(); + return evaluate(featureKey, user, LDValue.of(defaultValue), LDValueType.BOOLEAN).booleanValue(); } @Override public int intVariation(String featureKey, LDUser user, int defaultValue) { - return evaluate(featureKey, user, LDValue.of(defaultValue), true).intValue(); + return evaluate(featureKey, user, LDValue.of(defaultValue), LDValueType.NUMBER).intValue(); } @Override public double doubleVariation(String featureKey, LDUser user, double defaultValue) { - return evaluate(featureKey, user, LDValue.of(defaultValue), true).doubleValue(); + return evaluate(featureKey, user, LDValue.of(defaultValue), LDValueType.NUMBER).doubleValue(); } @Override public String stringVariation(String featureKey, LDUser user, String defaultValue) { - return evaluate(featureKey, user, LDValue.of(defaultValue), true).stringValue(); + return evaluate(featureKey, user, LDValue.of(defaultValue), LDValueType.STRING).stringValue(); } @Override public LDValue jsonValueVariation(String featureKey, LDUser user, LDValue defaultValue) { - return evaluate(featureKey, user, LDValue.normalize(defaultValue), false); + return evaluate(featureKey, user, LDValue.normalize(defaultValue), null); } @Override public EvaluationDetail boolVariationDetail(String featureKey, LDUser user, boolean defaultValue) { - Evaluator.EvalResult result = evaluateInternal(featureKey, user, LDValue.of(defaultValue), true, - eventFactoryWithReasons); + Evaluator.EvalResult result = evaluateInternal(featureKey, user, LDValue.of(defaultValue), + LDValueType.BOOLEAN, eventFactoryWithReasons); return EvaluationDetail.fromValue(result.getValue().booleanValue(), result.getVariationIndex(), result.getReason()); } @Override public EvaluationDetail intVariationDetail(String featureKey, LDUser user, int defaultValue) { - Evaluator.EvalResult result = evaluateInternal(featureKey, user, LDValue.of(defaultValue), true, - eventFactoryWithReasons); + Evaluator.EvalResult result = evaluateInternal(featureKey, user, LDValue.of(defaultValue), + LDValueType.NUMBER, eventFactoryWithReasons); return EvaluationDetail.fromValue(result.getValue().intValue(), result.getVariationIndex(), result.getReason()); } @Override public EvaluationDetail doubleVariationDetail(String featureKey, LDUser user, double defaultValue) { - Evaluator.EvalResult result = evaluateInternal(featureKey, user, LDValue.of(defaultValue), true, - eventFactoryWithReasons); + Evaluator.EvalResult result = evaluateInternal(featureKey, user, LDValue.of(defaultValue), + LDValueType.NUMBER, eventFactoryWithReasons); return EvaluationDetail.fromValue(result.getValue().doubleValue(), result.getVariationIndex(), result.getReason()); } @Override public EvaluationDetail stringVariationDetail(String featureKey, LDUser user, String defaultValue) { - Evaluator.EvalResult result = evaluateInternal(featureKey, user, LDValue.of(defaultValue), true, - eventFactoryWithReasons); + Evaluator.EvalResult result = evaluateInternal(featureKey, user, LDValue.of(defaultValue), + LDValueType.STRING, eventFactoryWithReasons); return EvaluationDetail.fromValue(result.getValue().stringValue(), result.getVariationIndex(), result.getReason()); } @Override public EvaluationDetail jsonValueVariationDetail(String featureKey, LDUser user, LDValue defaultValue) { - Evaluator.EvalResult result = evaluateInternal(featureKey, user, LDValue.normalize(defaultValue), false, eventFactoryWithReasons); + Evaluator.EvalResult result = evaluateInternal(featureKey, user, LDValue.normalize(defaultValue), + null, eventFactoryWithReasons); return EvaluationDetail.fromValue(result.getValue(), result.getVariationIndex(), result.getReason()); } @@ -449,16 +451,16 @@ public boolean isFlagKnown(String featureKey) { return false; } - private LDValue evaluate(String featureKey, LDUser user, LDValue defaultValue, boolean checkType) { - return evaluateInternal(featureKey, user, defaultValue, checkType, eventFactoryDefault).getValue(); + private LDValue evaluate(String featureKey, LDUser user, LDValue defaultValue, LDValueType requireType) { + return evaluateInternal(featureKey, user, defaultValue, requireType, eventFactoryDefault).getValue(); } private Evaluator.EvalResult errorResult(EvaluationReason.ErrorKind errorKind, final LDValue defaultValue) { return new Evaluator.EvalResult(defaultValue, NO_VARIATION, EvaluationReason.error(errorKind)); } - private Evaluator.EvalResult evaluateInternal(String featureKey, LDUser user, LDValue defaultValue, boolean checkType, - EventFactory eventFactory) { + private Evaluator.EvalResult evaluateInternal(String featureKey, LDUser user, LDValue defaultValue, + LDValueType requireType, EventFactory eventFactory) { if (!isInitialized()) { if (dataStore.isInitialized()) { Loggers.EVALUATION.warn("Evaluation called before client initialized for feature flag \"{}\"; using last known values from data store", featureKey); @@ -496,7 +498,9 @@ private Evaluator.EvalResult evaluateInternal(String featureKey, LDUser user, LD evalResult.setValue(defaultValue); } else { LDValue value = evalResult.getValue(); // guaranteed not to be an actual Java null, but can be LDValue.ofNull() - if (checkType && !value.isNull() && !defaultValue.isNull() && defaultValue.getType() != value.getType()) { + if (requireType != null && + !value.isNull() && + value.getType() != requireType) { Loggers.EVALUATION.error("Feature flag evaluation expected result as {}, but got {}", defaultValue.getType(), value.getType()); sendFlagRequestEvent(eventFactory.newUnknownFeatureRequestEvent(featureKey, user, defaultValue, EvaluationReason.ErrorKind.WRONG_TYPE)); diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java index e0fb127f3..c21d4ea9e 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientEvaluationTest.java @@ -64,6 +64,10 @@ public void boolVariationReturnsFlagValue() throws Exception { @Test public void boolVariationReturnsDefaultValueForUnknownFlag() throws Exception { assertFalse(client.boolVariation("key", user, false)); + + assertEquals(EvaluationDetail.fromValue(false, NO_VARIATION, + EvaluationReason.error(EvaluationReason.ErrorKind.FLAG_NOT_FOUND)), + client.boolVariationDetail("key", user, false)); } @Test @@ -71,6 +75,10 @@ public void boolVariationReturnsDefaultValueForWrongType() throws Exception { upsertFlag(dataStore, flagWithValue("key", LDValue.of("wrong"))); assertFalse(client.boolVariation("key", user, false)); + + assertEquals(EvaluationDetail.fromValue(false, NO_VARIATION, + EvaluationReason.error(EvaluationReason.ErrorKind.WRONG_TYPE)), + client.boolVariationDetail("key", user, false)); } @Test @@ -103,6 +111,10 @@ public void intVariationFromDoubleRoundsTowardZero() throws Exception { @Test public void intVariationReturnsDefaultValueForUnknownFlag() throws Exception { assertEquals(1, client.intVariation("key", user, 1)); + + assertEquals(EvaluationDetail.fromValue(1, NO_VARIATION, + EvaluationReason.error(EvaluationReason.ErrorKind.FLAG_NOT_FOUND)), + client.intVariationDetail("key", user, 1)); } @Test @@ -110,6 +122,10 @@ public void intVariationReturnsDefaultValueForWrongType() throws Exception { upsertFlag(dataStore, flagWithValue("key", LDValue.of("wrong"))); assertEquals(1, client.intVariation("key", user, 1)); + + assertEquals(EvaluationDetail.fromValue(1, NO_VARIATION, + EvaluationReason.error(EvaluationReason.ErrorKind.WRONG_TYPE)), + client.intVariationDetail("key", user, 1)); } @Test @@ -129,6 +145,10 @@ public void doubleVariationReturnsFlagValueEvenIfEncodedAsInt() throws Exception @Test public void doubleVariationReturnsDefaultValueForUnknownFlag() throws Exception { assertEquals(1.0d, client.doubleVariation("key", user, 1.0d), 0d); + + assertEquals(EvaluationDetail.fromValue(1.0d, NO_VARIATION, + EvaluationReason.error(EvaluationReason.ErrorKind.FLAG_NOT_FOUND)), + client.doubleVariationDetail("key", user, 1.0d)); } @Test @@ -136,6 +156,10 @@ public void doubleVariationReturnsDefaultValueForWrongType() throws Exception { upsertFlag(dataStore, flagWithValue("key", LDValue.of("wrong"))); assertEquals(1.0d, client.doubleVariation("key", user, 1.0d), 0d); + + assertEquals(EvaluationDetail.fromValue(1.0d, NO_VARIATION, + EvaluationReason.error(EvaluationReason.ErrorKind.WRONG_TYPE)), + client.doubleVariationDetail("key", user, 1.0d)); } @Test @@ -160,6 +184,10 @@ public void stringVariationReturnsDefaultValueForUnknownFlag() throws Exception @Test public void stringVariationWithNullDefaultReturnsDefaultValueForUnknownFlag() throws Exception { assertNull(client.stringVariation("key", user, null)); + + assertEquals(EvaluationDetail.fromValue((String)null, NO_VARIATION, + EvaluationReason.error(EvaluationReason.ErrorKind.FLAG_NOT_FOUND)), + client.stringVariationDetail("key", user, null)); } @Test @@ -167,6 +195,10 @@ public void stringVariationReturnsDefaultValueForWrongType() throws Exception { upsertFlag(dataStore, flagWithValue("key", LDValue.of(true))); assertEquals("a", client.stringVariation("key", user, "a")); + + assertEquals(EvaluationDetail.fromValue("a", NO_VARIATION, + EvaluationReason.error(EvaluationReason.ErrorKind.WRONG_TYPE)), + client.stringVariationDetail("key", user, "a")); } @Test @@ -174,6 +206,10 @@ public void stringVariationWithNullDefaultReturnsDefaultValueForWrongType() thro upsertFlag(dataStore, flagWithValue("key", LDValue.of(true))); assertNull(client.stringVariation("key", user, null)); + + assertEquals(EvaluationDetail.fromValue((String)null, NO_VARIATION, + EvaluationReason.error(EvaluationReason.ErrorKind.WRONG_TYPE)), + client.stringVariationDetail("key", user, null)); } @Test From c24c950b3a45eb5124af093bb6b33b9f610fc83e Mon Sep 17 00:00:00 2001 From: Alex Engelberg Date: Thu, 19 May 2022 16:02:21 -0700 Subject: [PATCH 630/673] Rename master to main in .ldrelease/config.yml (#325) --- .ldrelease/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ldrelease/config.yml b/.ldrelease/config.yml index 2a24ae6f1..1d69b6299 100644 --- a/.ldrelease/config.yml +++ b/.ldrelease/config.yml @@ -17,7 +17,7 @@ jobs: name: gradle branches: - - name: master + - name: main description: 5.x - name: 4.x From 19f92b097ef887ff32c6507183726275f9c2c186 Mon Sep 17 00:00:00 2001 From: Alex Engelberg Date: Thu, 19 May 2022 16:09:32 -0700 Subject: [PATCH 631/673] Simpler way of setting base URIs in Java (#322) Now supports the `ServiceEndpoints` config for setting custom URIs for endpoints in a single place --- .../main/java/sdktest/Representations.java | 7 + .../main/java/sdktest/SdkClientEntity.java | 12 +- .../src/main/java/sdktest/TestService.java | 3 +- .../sdk/server/ClientContextImpl.java | 3 +- .../launchdarkly/sdk/server/Components.java | 24 +++ .../sdk/server/ComponentsImpl.java | 67 +++++- .../sdk/server/DefaultEventSender.java | 4 +- .../sdk/server/DefaultFeatureRequestor.java | 3 +- .../sdk/server/EventsConfiguration.java | 2 +- .../com/launchdarkly/sdk/server/LDConfig.java | 34 ++- .../sdk/server/StandardEndpoints.java | 60 ++++++ .../sdk/server/StreamProcessor.java | 3 +- .../integrations/EventProcessorBuilder.java | 10 +- .../PollingDataSourceBuilder.java | 10 +- .../integrations/ServiceEndpointsBuilder.java | 203 ++++++++++++++++++ .../StreamingDataSourceBuilder.java | 10 +- .../server/interfaces/BasicConfiguration.java | 34 ++- .../server/interfaces/ServiceEndpoints.java | 51 +++++ .../sdk/server/ClientContextImplTest.java | 2 +- .../sdk/server/DefaultEventProcessorTest.java | 2 +- .../server/DefaultFeatureRequestorTest.java | 2 +- .../sdk/server/DiagnosticEventTest.java | 44 +++- .../launchdarkly/sdk/server/LDClientTest.java | 40 ++++ .../launchdarkly/sdk/server/LDConfigTest.java | 2 +- .../sdk/server/PollingProcessorTest.java | 2 +- .../sdk/server/StreamProcessorTest.java | 2 +- .../HttpConfigurationBuilderTest.java | 4 +- .../LoggingConfigurationBuilderTest.java | 2 +- .../ServiceEndpointsBuilderTest.java | 84 ++++++++ 29 files changed, 682 insertions(+), 44 deletions(-) create mode 100644 src/main/java/com/launchdarkly/sdk/server/StandardEndpoints.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/integrations/ServiceEndpointsBuilder.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/interfaces/ServiceEndpoints.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/integrations/ServiceEndpointsBuilderTest.java diff --git a/contract-tests/service/src/main/java/sdktest/Representations.java b/contract-tests/service/src/main/java/sdktest/Representations.java index c30e538d9..250f87f99 100644 --- a/contract-tests/service/src/main/java/sdktest/Representations.java +++ b/contract-tests/service/src/main/java/sdktest/Representations.java @@ -26,6 +26,7 @@ public static class SdkConfigParams { SdkConfigEventParams events; SdkConfigBigSegmentsParams bigSegments; SdkConfigTagParams tags; + SdkConfigServiceEndpointParams serviceEndpoints; } public static class SdkConfigStreamParams { @@ -55,6 +56,12 @@ public static class SdkConfigTagParams { String applicationId; String applicationVersion; } + + public static class SdkConfigServiceEndpointParams { + String streaming; + String polling; + String events; + } public static class CommandParams { String command; diff --git a/contract-tests/service/src/main/java/sdktest/SdkClientEntity.java b/contract-tests/service/src/main/java/sdktest/SdkClientEntity.java index 9bc473ad1..8e620e0ce 100644 --- a/contract-tests/service/src/main/java/sdktest/SdkClientEntity.java +++ b/contract-tests/service/src/main/java/sdktest/SdkClientEntity.java @@ -11,6 +11,7 @@ import com.launchdarkly.sdk.server.integrations.ApplicationInfoBuilder; import com.launchdarkly.sdk.server.integrations.BigSegmentsConfigurationBuilder; import com.launchdarkly.sdk.server.integrations.EventProcessorBuilder; +import com.launchdarkly.sdk.server.integrations.ServiceEndpointsBuilder; import com.launchdarkly.sdk.server.integrations.StreamingDataSourceBuilder; import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreStatusProvider; @@ -247,7 +248,16 @@ private LDConfig buildSdkConfig(SdkConfigParams params) { } builder.applicationInfo(ab); } - + + if (params.serviceEndpoints != null) { + builder.serviceEndpoints( + Components.serviceEndpoints() + .streaming(params.serviceEndpoints.streaming) + .polling(params.serviceEndpoints.polling) + .events(params.serviceEndpoints.events) + ); + } + return builder.build(); } } diff --git a/contract-tests/service/src/main/java/sdktest/TestService.java b/contract-tests/service/src/main/java/sdktest/TestService.java index 68eed1456..dfc8dd89f 100644 --- a/contract-tests/service/src/main/java/sdktest/TestService.java +++ b/contract-tests/service/src/main/java/sdktest/TestService.java @@ -27,7 +27,8 @@ public class TestService { "all-flags-details-only-for-tracked-flags", "all-flags-with-reasons", "big-segments", - "tags" + "tags", + "service-endpoints" }; static final Gson gson = new GsonBuilder().serializeNulls().create(); diff --git a/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java b/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java index 8004b36f9..96f9e9ea4 100644 --- a/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java @@ -52,8 +52,7 @@ private ClientContextImpl( ScheduledExecutorService sharedExecutor, DiagnosticAccumulator diagnosticAccumulator ) { - ApplicationInfo applicationInfo = configuration.applicationInfoBuilder.createApplicationInfo(); - this.basicConfiguration = new BasicConfiguration(sdkKey, configuration.offline, configuration.threadPriority, applicationInfo); + this.basicConfiguration = new BasicConfiguration(sdkKey, configuration.offline, configuration.threadPriority, configuration.applicationInfo, configuration.serviceEndpoints); this.httpConfiguration = configuration.httpConfigFactory.createHttpConfiguration(basicConfiguration); this.loggingConfiguration = configuration.loggingConfigFactory.createLoggingConfiguration(basicConfiguration); diff --git a/src/main/java/com/launchdarkly/sdk/server/Components.java b/src/main/java/com/launchdarkly/sdk/server/Components.java index 171406f39..54bf82523 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Components.java +++ b/src/main/java/com/launchdarkly/sdk/server/Components.java @@ -10,6 +10,7 @@ import com.launchdarkly.sdk.server.ComponentsImpl.NullDataSourceFactory; import com.launchdarkly.sdk.server.ComponentsImpl.PersistentDataStoreBuilderImpl; import com.launchdarkly.sdk.server.ComponentsImpl.PollingDataSourceBuilderImpl; +import com.launchdarkly.sdk.server.ComponentsImpl.ServiceEndpointsBuilderImpl; import com.launchdarkly.sdk.server.ComponentsImpl.StreamingDataSourceBuilderImpl; import com.launchdarkly.sdk.server.integrations.ApplicationInfoBuilder; import com.launchdarkly.sdk.server.integrations.BigSegmentsConfigurationBuilder; @@ -18,6 +19,7 @@ import com.launchdarkly.sdk.server.integrations.LoggingConfigurationBuilder; import com.launchdarkly.sdk.server.integrations.PersistentDataStoreBuilder; import com.launchdarkly.sdk.server.integrations.PollingDataSourceBuilder; +import com.launchdarkly.sdk.server.integrations.ServiceEndpointsBuilder; import com.launchdarkly.sdk.server.integrations.StreamingDataSourceBuilder; import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreFactory; import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; @@ -344,4 +346,26 @@ public static LoggingConfigurationBuilder logging() { public static ApplicationInfoBuilder applicationInfo() { return new ApplicationInfoBuilder(); } + + /** + * Returns a builder for configuring custom service URIs. + *

      + * Passing this to {@link LDConfig.Builder#serviceEndpoints(com.launchdarkly.sdk.server.integrations.ServiceEndpointsBuilder)}, + * after setting any desired properties on the builder, applies this configuration to the SDK. + *

      
      +   *     LDConfig config = new LDConfig.Builder()
      +   *         .serviceEndpoints(
      +   *             Components.serviceEndpoints()
      +   *                 .relayProxy("http://my-relay-hostname:80")
      +   *         )
      +   *         .build();
      +   * 
      + * + * @return a builder object + * @since 5.9.0 + * @see LDConfig.Builder#serviceEndpoints(com.launchdarkly.sdk.server.integrations.ServiceEndpointsBuilder) + */ + public static ServiceEndpointsBuilder serviceEndpoints() { + return new ServiceEndpointsBuilderImpl(); + } } diff --git a/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java b/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java index 86aab22c2..9eb03fa00 100644 --- a/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java @@ -8,6 +8,7 @@ import com.launchdarkly.sdk.server.integrations.LoggingConfigurationBuilder; import com.launchdarkly.sdk.server.integrations.PersistentDataStoreBuilder; import com.launchdarkly.sdk.server.integrations.PollingDataSourceBuilder; +import com.launchdarkly.sdk.server.integrations.ServiceEndpointsBuilder; import com.launchdarkly.sdk.server.integrations.StreamingDataSourceBuilder; import com.launchdarkly.sdk.server.interfaces.BasicConfiguration; import com.launchdarkly.sdk.server.interfaces.ClientContext; @@ -28,6 +29,7 @@ import com.launchdarkly.sdk.server.interfaces.LoggingConfiguration; import com.launchdarkly.sdk.server.interfaces.PersistentDataStore; import com.launchdarkly.sdk.server.interfaces.PersistentDataStoreFactory; +import com.launchdarkly.sdk.server.interfaces.ServiceEndpoints; import java.io.IOException; import java.net.InetSocketAddress; @@ -139,7 +141,13 @@ public DataSource createDataSource(ClientContext context, DataSourceUpdates data Loggers.DATA_SOURCE.info("Enabling streaming API"); - URI streamUri = baseURI == null ? LDConfig.DEFAULT_STREAM_URI : baseURI; + URI streamUri = StandardEndpoints.selectBaseUri( + context.getBasic().getServiceEndpoints().getStreamingBaseUri(), + baseURI, + StandardEndpoints.DEFAULT_STREAMING_BASE_URI, + "streaming", + Loggers.MAIN + ); return new StreamProcessor( context.getHttp(), @@ -157,7 +165,10 @@ public LDValue describeConfiguration(BasicConfiguration basicConfiguration) { .put(ConfigProperty.STREAMING_DISABLED.name, false) .put(ConfigProperty.CUSTOM_BASE_URI.name, false) .put(ConfigProperty.CUSTOM_STREAM_URI.name, - baseURI != null && !baseURI.equals(LDConfig.DEFAULT_STREAM_URI)) + StandardEndpoints.isCustomBaseUri( + basicConfiguration.getServiceEndpoints().getStreamingBaseUri(), + baseURI, + StandardEndpoints.DEFAULT_STREAMING_BASE_URI)) .put(ConfigProperty.RECONNECT_TIME_MILLIS.name, initialReconnectDelay.toMillis()) .put(ConfigProperty.USING_RELAY_DAEMON.name, false) .build(); @@ -177,11 +188,16 @@ public DataSource createDataSource(ClientContext context, DataSourceUpdates data Loggers.DATA_SOURCE.info("Disabling streaming API"); Loggers.DATA_SOURCE.warn("You should only disable the streaming API if instructed to do so by LaunchDarkly support"); - - DefaultFeatureRequestor requestor = new DefaultFeatureRequestor( - context.getHttp(), - baseURI == null ? LDConfig.DEFAULT_BASE_URI : baseURI + + URI pollUri = StandardEndpoints.selectBaseUri( + context.getBasic().getServiceEndpoints().getPollingBaseUri(), + baseURI, + StandardEndpoints.DEFAULT_POLLING_BASE_URI, + "polling", + Loggers.MAIN ); + + DefaultFeatureRequestor requestor = new DefaultFeatureRequestor(context.getHttp(), pollUri); return new PollingProcessor( requestor, dataSourceUpdates, @@ -195,7 +211,10 @@ public LDValue describeConfiguration(BasicConfiguration basicConfiguration) { return LDValue.buildObject() .put(ConfigProperty.STREAMING_DISABLED.name, true) .put(ConfigProperty.CUSTOM_BASE_URI.name, - baseURI != null && !baseURI.equals(LDConfig.DEFAULT_BASE_URI)) + StandardEndpoints.isCustomBaseUri( + basicConfiguration.getServiceEndpoints().getPollingBaseUri(), + baseURI, + StandardEndpoints.DEFAULT_POLLING_BASE_URI)) .put(ConfigProperty.CUSTOM_STREAM_URI.name, false) .put(ConfigProperty.POLLING_INTERVAL_MILLIS.name, pollInterval.toMillis()) .put(ConfigProperty.USING_RELAY_DAEMON.name, false) @@ -210,12 +229,19 @@ public EventProcessor createEventProcessor(ClientContext context) { EventSender eventSender = (eventSenderFactory == null ? new DefaultEventSender.Factory() : eventSenderFactory) .createEventSender(context.getBasic(), context.getHttp()); + URI eventsUri = StandardEndpoints.selectBaseUri( + context.getBasic().getServiceEndpoints().getEventsBaseUri(), + baseURI, + StandardEndpoints.DEFAULT_EVENTS_BASE_URI, + "events", + Loggers.MAIN + ); return new DefaultEventProcessor( new EventsConfiguration( allAttributesPrivate, capacity, eventSender, - baseURI == null ? LDConfig.DEFAULT_EVENTS_URI : baseURI, + eventsUri, flushInterval, inlineUsersInEvents, privateAttributes, @@ -234,7 +260,11 @@ public EventProcessor createEventProcessor(ClientContext context) { public LDValue describeConfiguration(BasicConfiguration basicConfiguration) { 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, + StandardEndpoints.isCustomBaseUri( + basicConfiguration.getServiceEndpoints().getEventsBaseUri(), + baseURI, + StandardEndpoints.DEFAULT_EVENTS_BASE_URI)) .put(ConfigProperty.DIAGNOSTIC_RECORDING_INTERVAL_MILLIS.name, diagnosticRecordingInterval.toMillis()) .put(ConfigProperty.EVENTS_CAPACITY.name, capacity) .put(ConfigProperty.EVENTS_FLUSH_INTERVAL_MILLIS.name, flushInterval.toMillis()) @@ -333,4 +363,23 @@ public LoggingConfiguration createLoggingConfiguration(BasicConfiguration basicC return new LoggingConfigurationImpl(logDataSourceOutageAsErrorAfter); } } + + static final class ServiceEndpointsBuilderImpl extends ServiceEndpointsBuilder { + @Override + public ServiceEndpoints createServiceEndpoints() { + // If *any* custom URIs have been set, then we do not want to use default values for any that were not set, + // so we will leave those null. That way, if we decide later on (in other component factories, such as + // EventProcessorBuilder) that we are actually interested in one of these values, and we + // see that it is null, we can assume that there was a configuration mistake and log an + // error. + if (streamingBaseUri == null && pollingBaseUri == null && eventsBaseUri == null) { + return new ServiceEndpoints( + StandardEndpoints.DEFAULT_STREAMING_BASE_URI, + StandardEndpoints.DEFAULT_POLLING_BASE_URI, + StandardEndpoints.DEFAULT_EVENTS_BASE_URI + ); + } + return new ServiceEndpoints(streamingBaseUri, pollingBaseUri, eventsBaseUri); + } + } } diff --git a/src/main/java/com/launchdarkly/sdk/server/DefaultEventSender.java b/src/main/java/com/launchdarkly/sdk/server/DefaultEventSender.java index bf7e8afff..c3b03cc54 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DefaultEventSender.java +++ b/src/main/java/com/launchdarkly/sdk/server/DefaultEventSender.java @@ -80,14 +80,14 @@ public Result sendEventData(EventDataKind kind, String data, int eventCount, URI switch (kind) { case ANALYTICS: - path = "bulk"; + path = StandardEndpoints.ANALYTICS_EVENTS_POST_REQUEST_PATH; 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: - path = "diagnostic"; + path = StandardEndpoints.DIAGNOSTIC_EVENTS_POST_REQUEST_PATH; description = "diagnostic event"; break; default: diff --git a/src/main/java/com/launchdarkly/sdk/server/DefaultFeatureRequestor.java b/src/main/java/com/launchdarkly/sdk/server/DefaultFeatureRequestor.java index c3c7cc4bd..2eb323588 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DefaultFeatureRequestor.java +++ b/src/main/java/com/launchdarkly/sdk/server/DefaultFeatureRequestor.java @@ -27,7 +27,6 @@ */ final class DefaultFeatureRequestor implements FeatureRequestor { private static final Logger logger = Loggers.DATA_SOURCE; - 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 @VisibleForTesting final URI baseUri; @@ -38,7 +37,7 @@ final class DefaultFeatureRequestor implements FeatureRequestor { DefaultFeatureRequestor(HttpConfiguration httpConfig, URI baseUri) { this.baseUri = baseUri; - this.pollingUri = concatenateUriPath(baseUri, GET_LATEST_ALL_PATH); + this.pollingUri = concatenateUriPath(baseUri, StandardEndpoints.POLLING_REQUEST_PATH); OkHttpClient.Builder httpBuilder = new OkHttpClient.Builder(); configureHttpClientBuilder(httpConfig, httpBuilder); diff --git a/src/main/java/com/launchdarkly/sdk/server/EventsConfiguration.java b/src/main/java/com/launchdarkly/sdk/server/EventsConfiguration.java index c03dc3350..5e64742b7 100644 --- a/src/main/java/com/launchdarkly/sdk/server/EventsConfiguration.java +++ b/src/main/java/com/launchdarkly/sdk/server/EventsConfiguration.java @@ -37,7 +37,7 @@ final class EventsConfiguration { this.allAttributesPrivate = allAttributesPrivate; this.capacity = capacity; this.eventSender = eventSender; - this.eventsUri = eventsUri == null ? LDConfig.DEFAULT_EVENTS_URI : eventsUri; + this.eventsUri = eventsUri; this.flushInterval = flushInterval; this.inlineUsersInEvents = inlineUsersInEvents; this.privateAttributes = privateAttributes == null ? ImmutableSet.of() : ImmutableSet.copyOf(privateAttributes); diff --git a/src/main/java/com/launchdarkly/sdk/server/LDConfig.java b/src/main/java/com/launchdarkly/sdk/server/LDConfig.java index 4c18a51bd..0a7fc32fa 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDConfig.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDConfig.java @@ -3,6 +3,8 @@ import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.server.integrations.ApplicationInfoBuilder; import com.launchdarkly.sdk.server.integrations.BigSegmentsConfigurationBuilder; +import com.launchdarkly.sdk.server.integrations.ServiceEndpointsBuilder; +import com.launchdarkly.sdk.server.interfaces.ApplicationInfo; import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreFactory; import com.launchdarkly.sdk.server.interfaces.DataSourceFactory; import com.launchdarkly.sdk.server.interfaces.DataStoreFactory; @@ -10,6 +12,7 @@ import com.launchdarkly.sdk.server.interfaces.EventProcessorFactory; import com.launchdarkly.sdk.server.interfaces.HttpConfigurationFactory; import com.launchdarkly.sdk.server.interfaces.LoggingConfigurationFactory; +import com.launchdarkly.sdk.server.interfaces.ServiceEndpoints; import java.net.URI; import java.time.Duration; @@ -18,15 +21,11 @@ * This class exposes advanced configuration options for the {@link LDClient}. Instances of this class must be constructed with a {@link com.launchdarkly.sdk.server.LDConfig.Builder}. */ public final class LDConfig { - 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"); - static final Duration DEFAULT_START_WAIT = Duration.ofSeconds(5); protected static final LDConfig DEFAULT = new Builder().build(); - final ApplicationInfoBuilder applicationInfoBuilder; + final ApplicationInfo applicationInfo; final BigSegmentsConfigurationBuilder bigSegmentsConfigBuilder; final DataSourceFactory dataSourceFactory; final DataStoreFactory dataStoreFactory; @@ -34,6 +33,7 @@ public final class LDConfig { final EventProcessorFactory eventProcessorFactory; final HttpConfigurationFactory httpConfigFactory; final LoggingConfigurationFactory loggingConfigFactory; + final ServiceEndpoints serviceEndpoints; final boolean offline; final Duration startWait; final int threadPriority; @@ -48,8 +48,9 @@ protected LDConfig(Builder builder) { this.eventProcessorFactory = builder.eventProcessorFactory == null ? Components.sendEvents() : builder.eventProcessorFactory; } - this.applicationInfoBuilder = builder.applicationInfoBuilder == null ? Components.applicationInfo() : - builder.applicationInfoBuilder; + this.applicationInfo = (builder.applicationInfoBuilder == null ? Components.applicationInfo() : + builder.applicationInfoBuilder) + .createApplicationInfo(); this.bigSegmentsConfigBuilder = builder.bigSegmentsConfigBuilder == null ? Components.bigSegments(null) : builder.bigSegmentsConfigBuilder; this.dataStoreFactory = builder.dataStoreFactory == null ? Components.inMemoryDataStore() : @@ -60,6 +61,9 @@ protected LDConfig(Builder builder) { this.loggingConfigFactory = builder.loggingConfigFactory == null ? Components.logging() : builder.loggingConfigFactory; this.offline = builder.offline; + this.serviceEndpoints = (builder.serviceEndpointsBuilder == null ? Components.serviceEndpoints() : + builder.serviceEndpointsBuilder) + .createServiceEndpoints(); this.startWait = builder.startWait; this.threadPriority = builder.threadPriority; } @@ -84,6 +88,7 @@ public static class Builder { private EventProcessorFactory eventProcessorFactory = null; private HttpConfigurationFactory httpConfigFactory = null; private LoggingConfigurationFactory loggingConfigFactory = null; + private ServiceEndpointsBuilder serviceEndpointsBuilder = null; private boolean offline = false; private Duration startWait = DEFAULT_START_WAIT; private int threadPriority = Thread.MIN_PRIORITY; @@ -263,6 +268,21 @@ public Builder offline(boolean offline) { return this; } + /** + * Sets the base service URIs used by SDK components. + *

      + * This object is normally a configuration builder obtained from {@link Components#serviceEndpoints()}, + * which has methods for setting each external endpoint to a custom URI. + * + * @param serviceEndpointsBuilder a configuration builder object returned by {@link Components#applicationInfo()} + * @return the builder + * @since 5.9.0 + */ + public Builder serviceEndpoints(ServiceEndpointsBuilder serviceEndpointsBuilder) { + this.serviceEndpointsBuilder = serviceEndpointsBuilder; + return this; + } + /** * Set how long the constructor will block awaiting a successful connection to LaunchDarkly. * Setting this to a zero or negative duration will not block and cause the constructor to return immediately. diff --git a/src/main/java/com/launchdarkly/sdk/server/StandardEndpoints.java b/src/main/java/com/launchdarkly/sdk/server/StandardEndpoints.java new file mode 100644 index 000000000..c540cf026 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/StandardEndpoints.java @@ -0,0 +1,60 @@ +package com.launchdarkly.sdk.server; + +import java.net.URI; + +import org.slf4j.Logger; + +abstract class StandardEndpoints { + private StandardEndpoints() {} + + static URI DEFAULT_STREAMING_BASE_URI = URI.create("https://stream.launchdarkly.com"); + static URI DEFAULT_POLLING_BASE_URI = URI.create("https://app.launchdarkly.com"); + static URI DEFAULT_EVENTS_BASE_URI = URI.create("https://events.launchdarkly.com"); + + static String STREAMING_REQUEST_PATH = "/all"; + static String POLLING_REQUEST_PATH = "/sdk/latest-all"; + static String ANALYTICS_EVENTS_POST_REQUEST_PATH = "/bulk"; + static String DIAGNOSTIC_EVENTS_POST_REQUEST_PATH = "/diagnostic"; + + /** + * Internal method to decide which URI a given component should connect to. + *

      + * Always returns some URI, falling back on the default if necessary, but logs a warning if we detect that the application + * set some custom endpoints but not this one. + * + * @param serviceEndpointsValue the value set in ServiceEndpoints (this is either the default URI, a custom URI, or null) + * @param overrideValue the value overridden via the deprecated .baseURI() method (this is either a custom URI or null) + * @param defaultValue the constant default URI value defined in StandardEndpoints + * @param description a human-readable string for the type of endpoint being selected, for logging purposes + * @param logger the logger to which we should print the warning, if needed + * @return the base URI we should connect to + */ + static URI selectBaseUri(URI serviceEndpointsValue, URI overrideValue, URI defaultValue, String description, Logger logger) { + if (overrideValue != null) { + return overrideValue; + } + if (serviceEndpointsValue != null) { + return serviceEndpointsValue; + } + logger.warn("You have set custom ServiceEndpoints without specifying the {} base URI; connections may not work properly", description); + return defaultValue; + } + + /** + * Internal method to determine whether a given base URI was set to a custom value or not. + *

      + * This boolean value is only used for our diagnostic events. We only check if the value + * differs from the default; if the base URI was "overridden" in configuration, but + * happens to be equal to the default URI, we don't count that as custom + * for the purposes of this diagnostic. + * + * @param serviceEndpointsValue the value set in ServiceEndpoints + * @param overrideValue the value overridden via the deprecated .baseURI() method + * @param defaultValue the constant default URI value defined in StandardEndpoints + * @return true iff the base URI was customized + */ + static boolean isCustomBaseUri(URI serviceEndpointsValue, URI overrideValue, URI defaultValue) { + return (overrideValue != null && !overrideValue.equals(defaultValue)) || + (serviceEndpointsValue != null && !serviceEndpointsValue.equals(defaultValue)); + } +} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java index b0758eac4..f0406c206 100644 --- a/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java @@ -69,7 +69,6 @@ * if we succeed then the client can detect that we're initialized now by calling our Initialized method. */ final class StreamProcessor implements DataSource { - private static final String STREAM_URI_PATH = "all"; private static final String PUT = "put"; private static final String PATCH = "patch"; private static final String DELETE = "delete"; @@ -175,7 +174,7 @@ public Future start() { }; EventHandler handler = new StreamEventHandler(initFuture); - URI endpointUri = concatenateUriPath(streamUri, STREAM_URI_PATH); + URI endpointUri = concatenateUriPath(streamUri, StandardEndpoints.STREAMING_REQUEST_PATH); EventSource.Builder builder = new EventSource.Builder(handler, endpointUri) .threadPriority(threadPriority) diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/EventProcessorBuilder.java b/src/main/java/com/launchdarkly/sdk/server/integrations/EventProcessorBuilder.java index 0a027ecdc..a257e168c 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/EventProcessorBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/EventProcessorBuilder.java @@ -88,7 +88,12 @@ public EventProcessorBuilder allAttributesPrivate(boolean allAttributesPrivate) } /** - * Sets a custom base URI for the events service. + * Deprecated method for setting a custom base URI for the events service. + *

      + * The preferred way to set this option is now with + * {@link com.launchdarkly.sdk.server.LDConfig.Builder#serviceEndpoints(ServiceEndpointsBuilder)}. + * If you set this deprecated option, it overrides any value that was set with + * {@link com.launchdarkly.sdk.server.LDConfig.Builder#serviceEndpoints(ServiceEndpointsBuilder)}. *

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

        @@ -99,7 +104,10 @@ public EventProcessorBuilder allAttributesPrivate(boolean allAttributesPrivate) * * @param baseURI the base URI of the events service; null to use the default * @return the builder + * @deprecated Use {@link com.launchdarkly.sdk.server.LDConfig.Builder#serviceEndpoints(ServiceEndpointsBuilder)} and + * {@link ServiceEndpointsBuilder#events(URI)}. */ + @Deprecated public EventProcessorBuilder baseURI(URI baseURI) { this.baseURI = baseURI; return this; diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/PollingDataSourceBuilder.java b/src/main/java/com/launchdarkly/sdk/server/integrations/PollingDataSourceBuilder.java index b7f9d07c9..6b285d5c2 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/PollingDataSourceBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/PollingDataSourceBuilder.java @@ -36,7 +36,12 @@ public abstract class PollingDataSourceBuilder implements DataSourceFactory { protected Duration pollInterval = DEFAULT_POLL_INTERVAL; /** - * Sets a custom base URI for the polling service. + * Deprecated method for setting a custom base URI for the polling service. + *

        + * The preferred way to set this option is now with + * {@link com.launchdarkly.sdk.server.LDConfig.Builder#serviceEndpoints(ServiceEndpointsBuilder)}. + * If you set this deprecated option, it overrides any value that was set with + * {@link com.launchdarkly.sdk.server.LDConfig.Builder#serviceEndpoints(ServiceEndpointsBuilder)}. *

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

          @@ -47,7 +52,10 @@ public abstract class PollingDataSourceBuilder implements DataSourceFactory { * * @param baseURI the base URI of the polling service; null to use the default * @return the builder + * @deprecated Use {@link com.launchdarkly.sdk.server.LDConfig.Builder#serviceEndpoints(ServiceEndpointsBuilder)} and + * {@link ServiceEndpointsBuilder#polling(URI)}. */ + @Deprecated public PollingDataSourceBuilder baseURI(URI baseURI) { this.baseURI = baseURI; return this; diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/ServiceEndpointsBuilder.java b/src/main/java/com/launchdarkly/sdk/server/integrations/ServiceEndpointsBuilder.java new file mode 100644 index 000000000..e0c045817 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/ServiceEndpointsBuilder.java @@ -0,0 +1,203 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.LDConfig; +import com.launchdarkly.sdk.server.interfaces.ServiceEndpoints; +import java.net.URI; + +/** + * Contains methods for configuring the SDK's service URIs. + *

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

          + * The default behavior, if you do not change any of these properties, is that the SDK will connect to the standard endpoints + * in the LaunchDarkly production service. There are several use cases for changing these properties: + *

            + *
          • You are using the LaunchDarkly Relay Proxy. + * In this case, set {@link #relayProxy(URI)}. + *
          • You are connecting to a private instance of LaunchDarkly, rather than the standard production services. + * In this case, there will be custom base URIs for each service, so you must set {@link #streaming(URI)}, + * {@link #polling(URI)}, and {@link #events(URI)}. + *
          • You are connecting to a test fixture that simulates the service endpoints. In this case, you may set the + * base URIs to whatever you want, although the SDK will still set the URI paths to the expected paths for + * LaunchDarkly services. + *
          + *

          + * Each of the setter methods can be called with either a {@link URI} or an equivalent string. + * Passing a string that is not a valid URI will cause an immediate {@link IllegalArgumentException}. + *

          + * If you are using a private instance and you set some of the base URIs, but not all of them, the SDK + * will log an error and may not work properly. The only exception is if you have explicitly disabled + * the SDK's use of one of the services: for instance, if you have disabled analytics events with + * {@link Components#noEvents()}, you do not have to set {@link #events(URI)}. + * + *

          
          + *     // Example of specifying a Relay Proxy instance
          + *     LDConfig config = new LDConfig.Builder()
          + *         .serviceEndpoints(
          + *             Components.serviceEndpoints()
          + *                 .relayProxy("http://my-relay-hostname:80")
          + *         )
          + *         .build();
          + * 
          + *     // Example of specifying a private LaunchDarkly instance
          + *     LDConfig config = new LDConfig.Builder()
          + *         .serviceEndpoints(
          + *             Components.serviceEndpoints()
          + *                 .streaming("https://stream.mycompany.launchdarkly.com")
          + *                 .polling("https://app.mycompany.launchdarkly.com")
          + *                 .events("https://events.mycompany.launchdarkly.com"))
          + *         )
          + *         .build();
          + * 
          + * + * @since 5.9.0 + */ +public abstract class ServiceEndpointsBuilder { + protected URI streamingBaseUri; + protected URI pollingBaseUri; + protected URI eventsBaseUri; + + /** + * Sets a custom base URI for the events service. + *

          + * You should only call this method if you are using a private instance or test fixture + * (see {@link ServiceEndpointsBuilder}). If you are using the LaunchDarkly Relay Proxy, + * call {@link #relayProxy(URI)} instead. + *

          
          +   *     LDConfig config = new LDConfig.Builder()
          +   *       .serviceEndpoints(
          +   *           Components.serviceEndpoints()
          +   *               .streaming("https://stream.mycompany.launchdarkly.com")
          +   *               .polling("https://app.mycompany.launchdarkly.com")
          +   *               .events("https://events.mycompany.launchdarkly.com")
          +   *       )
          +   *       .build();
          +   * 
          + * + * @param eventsBaseUri the base URI of the events service; null to use the default + * @return the builder + */ + public ServiceEndpointsBuilder events(URI eventsBaseUri) { + this.eventsBaseUri = eventsBaseUri; + return this; + } + + /** + * Equivalent to {@link #events(URI)}, specifying the URI as a string. + * @param eventsBaseUri the base URI of the events service; null to use the default + * @return the builder + */ + public ServiceEndpointsBuilder events(String eventsBaseUri) { + return events(eventsBaseUri == null ? null : URI.create(eventsBaseUri)); + } + + /** + * Sets a custom base URI for the polling service. + *

          + * You should only call this method if you are using a private instance or test fixture + * (see {@link ServiceEndpointsBuilder}). If you are using the LaunchDarkly Relay Proxy, + * call {@link #relayProxy(URI)} instead. + *

          
          +   *     LDConfig config = new LDConfig.Builder()
          +   *       .serviceEndpoints(
          +   *           Components.serviceEndpoints()
          +   *               .streaming("https://stream.mycompany.launchdarkly.com")
          +   *               .polling("https://app.mycompany.launchdarkly.com")
          +   *               .events("https://events.mycompany.launchdarkly.com")
          +   *       )
          +   *       .build();
          +   * 
          + * + * @param pollingBaseUri the base URI of the polling service; null to use the default + * @return the builder + */ + public ServiceEndpointsBuilder polling(URI pollingBaseUri) { + this.pollingBaseUri = pollingBaseUri; + return this; + } + + /** + * Equivalent to {@link #polling(URI)}, specifying the URI as a string. + * @param pollingBaseUri the base URI of the events service; null to use the default + * @return the builder + */ + public ServiceEndpointsBuilder polling(String pollingBaseUri) { + return polling(pollingBaseUri == null ? null : URI.create(pollingBaseUri)); + } + + /** + * Specifies a single base URI for a Relay Proxy instance. + *

          + * When using the LaunchDarkly Relay Proxy, the SDK only needs to know the single base URI + * of the Relay Proxy, which will provide all the proxied service endpoints. + *

          
          +   *     LDConfig config = new LDConfig.Builder()
          +   *       .serviceEndpoints(
          +   *           Components.serviceEndpoints()
          +   *               .relayProxy("http://my-relay-hostname:8080")
          +   *       )
          +   *       .build();
          +   * 
          + * + * @param relayProxyBaseUri the Relay Proxy base URI, or null to reset to default endpoints + * @return the builder + */ + public ServiceEndpointsBuilder relayProxy(URI relayProxyBaseUri) { + this.eventsBaseUri = relayProxyBaseUri; + this.pollingBaseUri = relayProxyBaseUri; + this.streamingBaseUri = relayProxyBaseUri; + return this; + } + + /** + * Equivalent to {@link #relayProxy(URI)}, specifying the URI as a string. + * @param relayProxyBaseUri the Relay Proxy base URI, or null to reset to default endpoints + * @return the builder + */ + public ServiceEndpointsBuilder relayProxy(String relayProxyBaseUri) { + return relayProxy(relayProxyBaseUri == null ? null : URI.create(relayProxyBaseUri)); + } + + /** + * Sets a custom base URI for the streaming service. + *

          + * You should only call this method if you are using a private instance or test fixture + * (see {@link ServiceEndpointsBuilder}). If you are using the LaunchDarkly Relay Proxy, + * call {@link #relayProxy(URI)} instead. + *

          
          +   *     LDConfig config = new LDConfig.Builder()
          +   *       .serviceEndpoints(
          +   *           Components.serviceEndpoints()
          +   *               .streaming("https://stream.mycompany.launchdarkly.com")
          +   *               .polling("https://app.mycompany.launchdarkly.com")
          +   *               .events("https://events.mycompany.launchdarkly.com")
          +   *       )
          +   *       .build();
          +   * 
          + * + * @param streamingBaseUri the base URI of the streaming service; null to use the default + * @return the builder + */ + public ServiceEndpointsBuilder streaming(URI streamingBaseUri) { + this.streamingBaseUri = streamingBaseUri; + return this; + } + + /** + * Equivalent to {@link #streaming(URI)}, specifying the URI as a string. + * @param streamingBaseUri the base URI of the events service; null to use the default + * @return the builder + */ + public ServiceEndpointsBuilder streaming(String streamingBaseUri) { + return streaming(streamingBaseUri == null ? null : URI.create(streamingBaseUri)); + } + + /** + * Called internally by the SDK to create a configuration instance. Applications do not need + * to call this method. + * @return the configuration object + */ + abstract public ServiceEndpoints createServiceEndpoints(); +} diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/StreamingDataSourceBuilder.java b/src/main/java/com/launchdarkly/sdk/server/integrations/StreamingDataSourceBuilder.java index 3f5ab2f92..24ed254a2 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/StreamingDataSourceBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/StreamingDataSourceBuilder.java @@ -32,7 +32,12 @@ public abstract class StreamingDataSourceBuilder implements DataSourceFactory { protected Duration initialReconnectDelay = DEFAULT_INITIAL_RECONNECT_DELAY; /** - * Sets a custom base URI for the streaming service. + * Deprecated method for setting a custom base URI for the streaming service. + *

          + * The preferred way to set this option is now with + * {@link com.launchdarkly.sdk.server.LDConfig.Builder#serviceEndpoints(ServiceEndpointsBuilder)}. + * If you set this deprecated option, it overrides any value that was set with + * {@link com.launchdarkly.sdk.server.LDConfig.Builder#serviceEndpoints(ServiceEndpointsBuilder)}. *

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

            @@ -43,7 +48,10 @@ public abstract class StreamingDataSourceBuilder implements DataSourceFactory { * * @param baseURI the base URI of the streaming service; null to use the default * @return the builder + * @deprecated Use {@link com.launchdarkly.sdk.server.LDConfig.Builder#serviceEndpoints(ServiceEndpointsBuilder)} and + * {@link ServiceEndpointsBuilder#streaming(URI)}. */ + @Deprecated public StreamingDataSourceBuilder baseURI(URI baseURI) { this.baseURI = baseURI; return this; diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/BasicConfiguration.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/BasicConfiguration.java index 3a19a9737..981d20255 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/BasicConfiguration.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/BasicConfiguration.java @@ -1,5 +1,7 @@ package com.launchdarkly.sdk.server.interfaces; +import com.launchdarkly.sdk.server.Components; + /** * The most basic properties of the SDK client that are available to all SDK component factories. * @@ -10,6 +12,7 @@ public final class BasicConfiguration { private final boolean offline; private final int threadPriority; private final ApplicationInfo applicationInfo; + private final ServiceEndpoints serviceEndpoints; /** * Constructs an instance. @@ -18,12 +21,14 @@ public final class BasicConfiguration { * @param offline true if the SDK was configured to be completely offline * @param threadPriority the thread priority that should be used for any worker threads created by SDK components * @param applicationInfo metadata about the application using this SDK + * @param serviceEndpoints the SDK's service URIs */ - public BasicConfiguration(String sdkKey, boolean offline, int threadPriority, ApplicationInfo applicationInfo) { + public BasicConfiguration(String sdkKey, boolean offline, int threadPriority, ApplicationInfo applicationInfo, ServiceEndpoints serviceEndpoints) { this.sdkKey = sdkKey; this.offline = offline; this.threadPriority = threadPriority; this.applicationInfo = applicationInfo; + this.serviceEndpoints = serviceEndpoints != null ? serviceEndpoints : Components.serviceEndpoints().createServiceEndpoints(); } /** @@ -32,10 +37,25 @@ public BasicConfiguration(String sdkKey, boolean offline, int threadPriority, Ap * @param sdkKey the SDK key * @param offline true if the SDK was configured to be completely offline * @param threadPriority the thread priority that should be used for any worker threads created by SDK components + * @param applicationInfo metadata about the application using this SDK + * @deprecated Use {@link BasicConfiguration#BasicConfiguration(String, boolean, int, ApplicationInfo, ServiceEndpoints)} + */ + @Deprecated + public BasicConfiguration(String sdkKey, boolean offline, int threadPriority, ApplicationInfo applicationInfo) { + this(sdkKey, offline, threadPriority, applicationInfo, null); + } + + /** + * Constructs an instance. + * + * @param sdkKey the SDK key + * @param offline true if the SDK was configured to be completely offline + * @param threadPriority the thread priority that should be used for any worker threads created by SDK components + * @deprecated Use {@link BasicConfiguration#BasicConfiguration(String, boolean, int, ApplicationInfo, ServiceEndpoints)} */ @Deprecated public BasicConfiguration(String sdkKey, boolean offline, int threadPriority) { - this(sdkKey, offline, threadPriority, null); + this(sdkKey, offline, threadPriority, null, null); } /** @@ -76,4 +96,14 @@ public int getThreadPriority() { public ApplicationInfo getApplicationInfo() { return applicationInfo; } + + /** + * Returns the base service URIs used by SDK components. + * + * @return the service endpoints + * @see com.launchdarkly.sdk.server.LDConfig.Builder#serviceEndpoints(com.launchdarkly.sdk.server.integrations.ServiceEndpointsBuilder) + */ + public ServiceEndpoints getServiceEndpoints() { + return serviceEndpoints; + } } diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/ServiceEndpoints.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/ServiceEndpoints.java new file mode 100644 index 000000000..553de9963 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/ServiceEndpoints.java @@ -0,0 +1,51 @@ +package com.launchdarkly.sdk.server.interfaces; + +import com.launchdarkly.sdk.server.integrations.ServiceEndpointsBuilder; +import java.net.URI; + +/** + * Specifies the base service URIs used by SDK components. + *

            + * See {@link ServiceEndpointsBuilder} for more details on these properties. + */ +public final class ServiceEndpoints { + private URI streamingBaseUri; + private URI pollingBaseUri; + private URI eventsBaseUri; + + /** + * Used internally by the SDK to store service endpoints. + * @param streamingBaseUri the base URI for the streaming service + * @param pollingBaseUri the base URI for the polling service + * @param eventsBaseUri the base URI for the events service + */ + public ServiceEndpoints(URI streamingBaseUri, URI pollingBaseUri, URI eventsBaseUri) { + this.streamingBaseUri = streamingBaseUri; + this.pollingBaseUri = pollingBaseUri; + this.eventsBaseUri = eventsBaseUri; + } + + /** + * The base URI for the streaming service. + * @return the base URI, or null + */ + public URI getStreamingBaseUri() { + return streamingBaseUri; + } + + /** + * The base URI for the polling service. + * @return the base URI, or null + */ + public URI getPollingBaseUri() { + return pollingBaseUri; + } + + /** + * The base URI for the events service. + * @return the base URI, or null + */ + public URI getEventsBaseUri() { + return eventsBaseUri; + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/ClientContextImplTest.java b/src/test/java/com/launchdarkly/sdk/server/ClientContextImplTest.java index 026576a5b..097e28ce9 100644 --- a/src/test/java/com/launchdarkly/sdk/server/ClientContextImplTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/ClientContextImplTest.java @@ -133,7 +133,7 @@ public void packagePrivatePropertiesHaveDefaultsIfContextIsNotOurImplementation( private static final class SomeOtherContextImpl implements ClientContext { public BasicConfiguration getBasic() { - return new BasicConfiguration(SDK_KEY, false, Thread.MIN_PRIORITY, null); + return new BasicConfiguration(SDK_KEY, false, Thread.MIN_PRIORITY, null, null); } public HttpConfiguration getHttp() { diff --git a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java index 6946627ec..115467dae 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorTest.java @@ -46,7 +46,7 @@ public void builderHasDefaultConfiguration() throws Exception { assertThat(ec.capacity, equalTo(EventProcessorBuilder.DEFAULT_CAPACITY)); assertThat(ec.diagnosticRecordingInterval, equalTo(EventProcessorBuilder.DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL)); assertThat(ec.eventSender, instanceOf(DefaultEventSender.class)); - assertThat(ec.eventsUri, equalTo(LDConfig.DEFAULT_EVENTS_URI)); + assertThat(ec.eventsUri, equalTo(StandardEndpoints.DEFAULT_EVENTS_BASE_URI)); assertThat(ec.flushInterval, equalTo(EventProcessorBuilder.DEFAULT_FLUSH_INTERVAL)); assertThat(ec.inlineUsersInEvents, is(false)); assertThat(ec.privateAttributes, equalTo(ImmutableSet.of())); diff --git a/src/test/java/com/launchdarkly/sdk/server/DefaultFeatureRequestorTest.java b/src/test/java/com/launchdarkly/sdk/server/DefaultFeatureRequestorTest.java index 4844a5e0f..8e3d73b83 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DefaultFeatureRequestorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultFeatureRequestorTest.java @@ -42,7 +42,7 @@ private DefaultFeatureRequestor makeRequestor(HttpServer server, LDConfig config } private HttpConfiguration makeHttpConfig(LDConfig config) { - return config.httpConfigFactory.createHttpConfiguration(new BasicConfiguration(sdkKey, false, 0, null)); + return config.httpConfigFactory.createHttpConfiguration(new BasicConfiguration(sdkKey, false, 0, null, null)); } private void verifyExpectedData(FeatureRequestor.AllData data) { diff --git a/src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java b/src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java index 5472408c0..d261cea4e 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DiagnosticEventTest.java @@ -120,6 +120,44 @@ public void testCustomDiagnosticConfigurationGeneralProperties() { assertEquals(expected, diagnosticJson); } + + @Test + public void testCustomDiagnosticConfigurationForServiceEndpoints() { + LDConfig ldConfig1 = new LDConfig.Builder() + .serviceEndpoints( + Components.serviceEndpoints() + .streaming(CUSTOM_URI) + .events(CUSTOM_URI) + // this shouldn't show up in diagnostics because we don't use the polling component + .polling(CUSTOM_URI) + ) + .build(); + LDValue expected1 = expectedDefaultProperties() + .put("customStreamURI", true) + .put("customEventsURI", true) + .build(); + assertEquals(expected1, makeConfigData(ldConfig1)); + + LDConfig ldConfig2 = new LDConfig.Builder() + .serviceEndpoints( + Components.serviceEndpoints() + .events(CUSTOM_URI) + .polling(CUSTOM_URI) + ) + .dataSource( + Components.pollingDataSource() + ) + .events(Components.sendEvents()) + .build(); + LDValue expected2 = expectedDefaultPropertiesWithoutStreaming() + .put("customBaseURI", true) + .put("customEventsURI", true) + .put("customStreamURI", false) + .put("pollingIntervalMillis", PollingDataSourceBuilder.DEFAULT_POLL_INTERVAL.toMillis()) + .put("streamingDisabled", true) + .build(); + assertEquals(expected2, makeConfigData(ldConfig2)); + } @Test public void testCustomDiagnosticConfigurationForStreaming() { @@ -144,7 +182,7 @@ public void testCustomDiagnosticConfigurationForStreaming() { assertEquals(expected2, makeConfigData(ldConfig2)); LDConfig ldConfig3 = new LDConfig.Builder() - .dataSource(Components.streamingDataSource().baseURI(LDConfig.DEFAULT_STREAM_URI)) // set a URI, but not a custom one + .dataSource(Components.streamingDataSource().baseURI(StandardEndpoints.DEFAULT_STREAMING_BASE_URI)) // set a URI, but not a custom one .build(); LDValue expected3 = expectedDefaultProperties().build(); assertEquals(expected3, makeConfigData(ldConfig3)); @@ -185,7 +223,7 @@ public void testCustomDiagnosticConfigurationForPolling() { assertEquals(expected2, makeConfigData(ldConfig2)); LDConfig ldConfig3 = new LDConfig.Builder() - .dataSource(Components.pollingDataSource().baseURI(LDConfig.DEFAULT_BASE_URI)) // set a URI, but not a custom one + .dataSource(Components.pollingDataSource().baseURI(StandardEndpoints.DEFAULT_POLLING_BASE_URI)) // set a URI, but not a custom one .build(); assertEquals(expected2, makeConfigData(ldConfig3)); // result is same as previous test case } @@ -278,7 +316,7 @@ public void testCustomDiagnosticConfigurationForEvents() { assertEquals(expected2, diagnosticJson2); LDConfig ldConfig3 = new LDConfig.Builder() - .events(Components.sendEvents().baseURI(LDConfig.DEFAULT_EVENTS_URI)) // set a base URI, but not a custom one + .events(Components.sendEvents().baseURI(StandardEndpoints.DEFAULT_EVENTS_BASE_URI)) // set a base URI, but not a custom one .build(); assertEquals(expected2, makeConfigData(ldConfig3)); // result is same as previous test case diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java index d16083f9f..d7ac2eec3 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientTest.java @@ -155,6 +155,19 @@ public void clientHasNullEventProcessorWithNoEvents() throws Exception { } } + @Test + public void canSetCustomEventsEndpoint() throws Exception { + URI eu = URI.create("http://fake"); + LDConfig config = new LDConfig.Builder() + .serviceEndpoints(Components.serviceEndpoints().events(eu)) + .events(Components.sendEvents()) + .diagnosticOptOut(true) + .build(); + try (LDClient client = new LDClient(SDK_KEY, config)) { + assertEquals(eu, ((DefaultEventProcessor) client.eventProcessor).dispatcher.eventsConfig.eventsUri); + } + } + @Test public void streamingClientHasStreamProcessor() throws Exception { LDConfig config = new LDConfig.Builder() @@ -167,6 +180,19 @@ public void streamingClientHasStreamProcessor() throws Exception { } } + @Test + public void canSetCustomStreamingEndpoint() throws Exception { + URI su = URI.create("http://fake"); + LDConfig config = new LDConfig.Builder() + .serviceEndpoints(Components.serviceEndpoints().streaming(su)) + .events(Components.noEvents()) + .startWait(Duration.ZERO) + .build(); + try (LDClient client = new LDClient(SDK_KEY, config)) { + assertEquals(su, ((StreamProcessor) client.dataSource).streamUri); + } + } + @Test public void pollingClientHasPollingProcessor() throws IOException { LDConfig config = new LDConfig.Builder() @@ -179,6 +205,20 @@ public void pollingClientHasPollingProcessor() throws IOException { } } + @Test + public void canSetCustomPollingEndpoint() throws Exception { + URI pu = URI.create("http://fake"); + LDConfig config = new LDConfig.Builder() + .dataSource(Components.pollingDataSource()) + .serviceEndpoints(Components.serviceEndpoints().polling(pu)) + .events(Components.noEvents()) + .startWait(Duration.ZERO) + .build(); + try (LDClient client = new LDClient(SDK_KEY, config)) { + assertEquals(pu, ((DefaultFeatureRequestor) ((PollingProcessor) client.dataSource).requestor).baseUri); + } + } + @Test public void sameDiagnosticAccumulatorPassedToFactoriesWhenSupported() throws IOException { DataSourceFactory mockDataSourceFactory = createStrictMock(DataSourceFactory.class); diff --git a/src/test/java/com/launchdarkly/sdk/server/LDConfigTest.java b/src/test/java/com/launchdarkly/sdk/server/LDConfigTest.java index 3794e990a..830451e41 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDConfigTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDConfigTest.java @@ -26,7 +26,7 @@ @SuppressWarnings("javadoc") public class LDConfigTest { - private static final BasicConfiguration BASIC_CONFIG = new BasicConfiguration("", false, 0, null); + private static final BasicConfiguration BASIC_CONFIG = new BasicConfiguration("", false, 0, null, null); @Test public void defaults() { diff --git a/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java index c32bccc48..9e39712a6 100644 --- a/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/PollingProcessorTest.java @@ -96,7 +96,7 @@ public void setError(int status) { public void builderHasDefaultConfiguration() throws Exception { DataSourceFactory f = Components.pollingDataSource(); try (PollingProcessor pp = (PollingProcessor)f.createDataSource(clientContext(SDK_KEY, LDConfig.DEFAULT), null)) { - assertThat(((DefaultFeatureRequestor)pp.requestor).baseUri, equalTo(LDConfig.DEFAULT_BASE_URI)); + assertThat(((DefaultFeatureRequestor)pp.requestor).baseUri, equalTo(StandardEndpoints.DEFAULT_POLLING_BASE_URI)); assertThat(pp.pollInterval, equalTo(PollingDataSourceBuilder.DEFAULT_POLL_INTERVAL)); } } diff --git a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java index 8f2828ae1..95a1eb717 100644 --- a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorTest.java @@ -140,7 +140,7 @@ public void builderHasDefaultConfiguration() throws Exception { try (StreamProcessor sp = (StreamProcessor)f.createDataSource(clientContext(SDK_KEY, LDConfig.DEFAULT), dataSourceUpdates)) { assertThat(sp.initialReconnectDelay, equalTo(StreamingDataSourceBuilder.DEFAULT_INITIAL_RECONNECT_DELAY)); - assertThat(sp.streamUri, equalTo(LDConfig.DEFAULT_STREAM_URI)); + assertThat(sp.streamUri, equalTo(StandardEndpoints.DEFAULT_STREAMING_BASE_URI)); } } diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/HttpConfigurationBuilderTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/HttpConfigurationBuilderTest.java index 06783edf8..19956b8f0 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/HttpConfigurationBuilderTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/HttpConfigurationBuilderTest.java @@ -33,7 +33,7 @@ @SuppressWarnings("javadoc") public class HttpConfigurationBuilderTest { private static final String SDK_KEY = "sdk-key"; - private static final BasicConfiguration BASIC_CONFIG = new BasicConfiguration(SDK_KEY, false, 0, null); + private static final BasicConfiguration BASIC_CONFIG = new BasicConfiguration(SDK_KEY, false, 0, null, null); private static ImmutableMap.Builder buildBasicHeaders() { return ImmutableMap.builder() @@ -141,7 +141,7 @@ public void testWrapperWithVersion() { @Test public void testApplicationTags() { ApplicationInfo info = new ApplicationInfo("authentication-service", "1.0.0"); - BasicConfiguration basicConfigWithTags = new BasicConfiguration(SDK_KEY, false, 0, info); + BasicConfiguration basicConfigWithTags = new BasicConfiguration(SDK_KEY, false, 0, info, null); HttpConfiguration hc = Components.httpConfiguration() .createHttpConfiguration(basicConfigWithTags); assertEquals("application-id/authentication-service application-version/1.0.0", ImmutableMap.copyOf(hc.getDefaultHeaders()).get("X-LaunchDarkly-Tags")); diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/LoggingConfigurationBuilderTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/LoggingConfigurationBuilderTest.java index f9c8f6992..8100ad89c 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/LoggingConfigurationBuilderTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/LoggingConfigurationBuilderTest.java @@ -14,7 +14,7 @@ @SuppressWarnings("javadoc") public class LoggingConfigurationBuilderTest { private static final String SDK_KEY = "sdk-key"; - private static final BasicConfiguration BASIC_CONFIG = new BasicConfiguration(SDK_KEY, false, 0, null); + private static final BasicConfiguration BASIC_CONFIG = new BasicConfiguration(SDK_KEY, false, 0, null, null); @Test public void testDefaults() { diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/ServiceEndpointsBuilderTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/ServiceEndpointsBuilderTest.java new file mode 100644 index 000000000..44f04923c --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/ServiceEndpointsBuilderTest.java @@ -0,0 +1,84 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.interfaces.ServiceEndpoints; + +import java.net.URI; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +@SuppressWarnings("javadoc") +public class ServiceEndpointsBuilderTest { + @Test + public void usesAllDefaultUrisIfNoneAreOverridden() { + ServiceEndpoints se = Components.serviceEndpoints().createServiceEndpoints(); + assertEquals(URI.create("https://stream.launchdarkly.com"), se.getStreamingBaseUri()); + assertEquals(URI.create("https://app.launchdarkly.com"), se.getPollingBaseUri()); + assertEquals(URI.create("https://events.launchdarkly.com"), se.getEventsBaseUri()); + } + + @Test + public void canSetAllUrisToCustomValues() { + URI su = URI.create("https://my-streaming"); + URI pu = URI.create("https://my-polling"); + URI eu = URI.create("https://my-events"); + ServiceEndpoints se = Components.serviceEndpoints() + .streaming(su) + .polling(pu) + .events(eu) + .createServiceEndpoints(); + assertEquals(su, se.getStreamingBaseUri()); + assertEquals(pu, se.getPollingBaseUri()); + assertEquals(eu, se.getEventsBaseUri()); + } + + @Test + public void ifCustomUrisAreSetAnyUnsetOnesDefaultToNull() { + URI su = URI.create("https://my-streaming"); + URI pu = URI.create("https://my-polling"); + URI eu = URI.create("https://my-events"); + ServiceEndpoints se1 = Components.serviceEndpoints().streaming(su).createServiceEndpoints(); + assertEquals(su, se1.getStreamingBaseUri()); + assertNull(se1.getPollingBaseUri()); + assertNull(se1.getEventsBaseUri()); + + ServiceEndpoints se2 = Components.serviceEndpoints().polling(pu).createServiceEndpoints(); + assertNull(se2.getStreamingBaseUri()); + assertEquals(pu, se2.getPollingBaseUri()); + assertNull(se2.getEventsBaseUri()); + + ServiceEndpoints se3 = Components.serviceEndpoints().events(eu).createServiceEndpoints(); + assertNull(se3.getStreamingBaseUri()); + assertNull(se3.getPollingBaseUri()); + assertEquals(eu, se3.getEventsBaseUri()); + } + + @Test + public void settingRelayProxyUriSetsAllUris() { + URI customRelay = URI.create("http://my-relay"); + ServiceEndpoints se = Components.serviceEndpoints().relayProxy(customRelay).createServiceEndpoints(); + assertEquals(customRelay, se.getStreamingBaseUri()); + assertEquals(customRelay, se.getPollingBaseUri()); + assertEquals(customRelay, se.getEventsBaseUri()); + } + + @Test + public void stringSettersAreEquivalentToUriSetters() { + String su = "https://my-streaming"; + String pu = "https://my-polling"; + String eu = "https://my-events"; + ServiceEndpoints se1 = Components.serviceEndpoints().streaming(su).polling(pu).events(eu).createServiceEndpoints(); + assertEquals(URI.create(su), se1.getStreamingBaseUri()); + assertEquals(URI.create(pu), se1.getPollingBaseUri()); + assertEquals(URI.create(eu), se1.getEventsBaseUri()); + + String ru = "http://my-relay"; + ServiceEndpoints se2 = Components.serviceEndpoints().relayProxy(ru).createServiceEndpoints(); + assertEquals(URI.create(ru), se2.getStreamingBaseUri()); + assertEquals(URI.create(ru), se2.getPollingBaseUri()); + assertEquals(URI.create(ru), se2.getEventsBaseUri()); + } +} From b617fc482ad4ca58d26baabf2d09b4ed2cf22346 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 14 Jun 2022 19:05:31 -0700 Subject: [PATCH 632/673] update logging info in readme --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 48d0beb15..94568334f 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,11 @@ Refer to the [SDK reference guide](https://docs.launchdarkly.com/sdk/server-side ## 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. +By default, the LaunchDarkly SDK uses [SLF4J](https://www.slf4j.org/). SLF4J has its own configuration mechanisms for determining where output will go, and filtering by level and/or logger name. For an example of using SLF4J with a simple console logging configuration, check out the [hello-java](https://github.com/launchdarkly/hello-java) project. + +The SDK can also be configured to use other adapters from the [com.launchdarkly.logging](https://github.com/launchdarkly/java-logging) facade instead of SLF4J. See `LoggingConfigurationBuilder`. This allows the logging behavior to be completely determined by the application, rather than by external SLF4J configuration. + +All loggers are namespaced under `com.launchdarkly`, if you are using name-based filtering. 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. From fe84a21c9a981b857e5dc9b4883caf3b18227eb1 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 14 Jun 2022 19:06:07 -0700 Subject: [PATCH 633/673] use 1.0.0 release of logging package --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 167e52f81..058f6f179 100644 --- a/build.gradle +++ b/build.gradle @@ -73,7 +73,7 @@ ext.versions = [ "guava": "30.1-jre", "jackson": "2.11.2", "launchdarklyJavaSdkCommon": "1.3.0", - "launchdarklyLogging": "0.0.1-SNAPSHOT", + "launchdarklyLogging": "1.0.0", "okhttp": "4.8.1", // specify this for the SDK build instead of relying on the transitive dependency from okhttp-eventsource "okhttpEventsource": "2.3.2", "slf4j": "1.7.21", From dce709770722c4a501a0f06f6871e56db9f39721 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 14 Jun 2022 19:37:57 -0700 Subject: [PATCH 634/673] misc cleanup --- .../com/launchdarkly/sdk/server/ClientContextImpl.java | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java b/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java index 5086ee690..2cf8fd5b7 100644 --- a/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java @@ -1,7 +1,6 @@ package com.launchdarkly.sdk.server; import com.launchdarkly.logging.LDLogger; -import com.launchdarkly.logging.Logs; import com.launchdarkly.sdk.server.interfaces.BasicConfiguration; import com.launchdarkly.sdk.server.interfaces.ClientContext; import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; @@ -116,18 +115,13 @@ public LoggingConfiguration getLogging() { return loggingConfiguration; } -// @Override -// public LDLogger getBaseLogger() { -// return baseLogger; -// } - private static LDLogger makeDefaultLogger(BasicConfiguration basicConfiguration) { LoggingConfiguration lc = Components.logging().createLoggingConfiguration(basicConfiguration); if (lc instanceof LoggingConfiguration.AdapterOptions) { LoggingConfiguration.AdapterOptions ao = (LoggingConfiguration.AdapterOptions)lc; return LDLogger.withAdapter(ao.getLogAdapter(), ao.getBaseLoggerName()); } - return LDLogger.withAdapter(Logs.none(), ""); + return LDLogger.none(); } /** From 7bed7e560cf076ed7348fd237f65d1501271850c Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 14 Jun 2022 19:52:56 -0700 Subject: [PATCH 635/673] remove unnecessary extra interfaces, just use default methods instead --- .../sdk/server/ClientContextImpl.java | 22 ++++--------- .../sdk/server/ComponentsImpl.java | 12 +++---- .../sdk/server/DefaultEventSender.java | 5 ++- .../sdk/server/LoggingConfigurationImpl.java | 2 +- .../server/interfaces/EventSenderFactory.java | 31 +++++++++---------- .../interfaces/LoggingConfiguration.java | 29 ++++++++--------- 6 files changed, 41 insertions(+), 60 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java b/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java index 2cf8fd5b7..c3c7ded4f 100644 --- a/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/ClientContextImpl.java @@ -1,6 +1,7 @@ package com.launchdarkly.sdk.server; import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.logging.Logs; import com.launchdarkly.sdk.server.interfaces.BasicConfiguration; import com.launchdarkly.sdk.server.interfaces.ClientContext; import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; @@ -52,18 +53,16 @@ private ClientContextImpl( ScheduledExecutorService sharedExecutor, DiagnosticAccumulator diagnosticAccumulator ) { - LDLogger baseLogger; // There is some temporarily over-elaborate logic here because the component factory interfaces can't // be updated to make the dependencies more sensible till the next major version. BasicConfiguration tempBasic = new BasicConfiguration(sdkKey, configuration.offline, configuration.threadPriority, configuration.applicationInfo, configuration.serviceEndpoints, LDLogger.none()); this.loggingConfiguration = configuration.loggingConfigFactory.createLoggingConfiguration(tempBasic); - if (this.loggingConfiguration instanceof LoggingConfiguration.AdapterOptions) { - LoggingConfiguration.AdapterOptions ao = (LoggingConfiguration.AdapterOptions)this.loggingConfiguration; - baseLogger = LDLogger.withAdapter(ao.getLogAdapter(), ao.getBaseLoggerName()); - } else { - baseLogger = makeDefaultLogger(tempBasic); - } + LDLogger baseLogger = LDLogger.withAdapter( + loggingConfiguration.getLogAdapter() == null ? Logs.none() : loggingConfiguration.getLogAdapter(), + loggingConfiguration.getBaseLoggerName() == null ? Loggers.BASE_LOGGER_NAME : + loggingConfiguration.getBaseLoggerName() + ); this.basicConfiguration = new BasicConfiguration( sdkKey, @@ -115,15 +114,6 @@ public LoggingConfiguration getLogging() { return loggingConfiguration; } - private static LDLogger makeDefaultLogger(BasicConfiguration basicConfiguration) { - LoggingConfiguration lc = Components.logging().createLoggingConfiguration(basicConfiguration); - if (lc instanceof LoggingConfiguration.AdapterOptions) { - LoggingConfiguration.AdapterOptions ao = (LoggingConfiguration.AdapterOptions)lc; - return LDLogger.withAdapter(ao.getLogAdapter(), ao.getBaseLoggerName()); - } - return LDLogger.none(); - } - /** * This mechanism is a convenience for internal components to access the package-private fields of the * context if it is a ClientContextImpl, and to receive null values for those fields if it is not. diff --git a/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java b/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java index 0b2075619..9e7219333 100644 --- a/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java @@ -240,13 +240,11 @@ public EventProcessor createEventProcessor(ClientContext context) { LDLogger logger = baseLogger.subLogger(Loggers.EVENTS_LOGGER_NAME); EventSenderFactory senderFactory = eventSenderFactory == null ? new DefaultEventSender.Factory() : eventSenderFactory; - EventSender eventSender = - (senderFactory instanceof EventSenderFactory.WithLogger) ? - ((EventSenderFactory.WithLogger)senderFactory).createEventSender( - context.getBasic(), - context.getHttp(), - logger) : - senderFactory.createEventSender(context.getBasic(), context.getHttp()); + EventSender eventSender = senderFactory.createEventSender( + context.getBasic(), + context.getHttp(), + logger + ); URI eventsUri = StandardEndpoints.selectBaseUri( context.getBasic().getServiceEndpoints().getEventsBaseUri(), baseURI, diff --git a/src/main/java/com/launchdarkly/sdk/server/DefaultEventSender.java b/src/main/java/com/launchdarkly/sdk/server/DefaultEventSender.java index 16956a717..7c2e4569e 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DefaultEventSender.java +++ b/src/main/java/com/launchdarkly/sdk/server/DefaultEventSender.java @@ -1,7 +1,6 @@ package com.launchdarkly.sdk.server; import com.launchdarkly.logging.LDLogger; -import com.launchdarkly.logging.Logs; import com.launchdarkly.sdk.server.interfaces.BasicConfiguration; import com.launchdarkly.sdk.server.interfaces.EventSender; import com.launchdarkly.sdk.server.interfaces.EventSenderFactory; @@ -164,11 +163,11 @@ private final Date parseResponseDate(Response response) { return null; } - static final class Factory implements EventSenderFactory, EventSenderFactory.WithLogger { + static final class Factory implements EventSenderFactory { @Override public EventSender createEventSender(BasicConfiguration basicConfiguration, HttpConfiguration httpConfiguration) { return new DefaultEventSender(httpConfiguration, DefaultEventSender.DEFAULT_RETRY_DELAY, - LDLogger.withAdapter(Logs.none(), "")); + LDLogger.none()); } @Override diff --git a/src/main/java/com/launchdarkly/sdk/server/LoggingConfigurationImpl.java b/src/main/java/com/launchdarkly/sdk/server/LoggingConfigurationImpl.java index cc05792da..81b7f1904 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LoggingConfigurationImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/LoggingConfigurationImpl.java @@ -5,7 +5,7 @@ import java.time.Duration; -final class LoggingConfigurationImpl implements LoggingConfiguration, LoggingConfiguration.AdapterOptions { +final class LoggingConfigurationImpl implements LoggingConfiguration { private final String baseLoggerName; private final LDLogAdapter logAdapter; private final Duration logDataSourceOutageAsErrorAfter; diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/EventSenderFactory.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/EventSenderFactory.java index b9499d514..f576a530b 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/EventSenderFactory.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/EventSenderFactory.java @@ -10,33 +10,30 @@ */ public interface EventSenderFactory { /** - * Called by the SDK to create the implementation object. + * Older method for creating the implementation object. This is superseded by the method that + * includes a logger instance. * * @param basicConfiguration the basic global SDK configuration properties * @param httpConfiguration HTTP configuration properties * @return an {@link EventSender} + * @deprecated use the overload that includes a logger */ + @Deprecated EventSender createEventSender(BasicConfiguration basicConfiguration, HttpConfiguration httpConfiguration); /** - * Alternate factory interface for compatibility with the SDK's new logging facade. This is defined as a - * separate interface for now because modifying EventSenderFactory would be a breaking change. This will - * be simplified in the next major version. + * Called by the SDK to create the implementation object. * + * @param basicConfiguration the basic global SDK configuration properties + * @param httpConfiguration HTTP configuration properties + * @param logger the configured logger + * @return an {@link EventSender} * @since 5.10.0 */ - public interface WithLogger { - /** - * Called by the SDK to create the implementation object. - * - * @param basicConfiguration the basic global SDK configuration properties - * @param httpConfiguration HTTP configuration properties - * @param logger the configured logger - * @return an {@link EventSender} - */ - EventSender createEventSender( - BasicConfiguration basicConfiguration, - HttpConfiguration httpConfiguration, - LDLogger logger); + default EventSender createEventSender( + BasicConfiguration basicConfiguration, + HttpConfiguration httpConfiguration, + LDLogger logger) { + return createEventSender(basicConfiguration, httpConfiguration); } } diff --git a/src/main/java/com/launchdarkly/sdk/server/interfaces/LoggingConfiguration.java b/src/main/java/com/launchdarkly/sdk/server/interfaces/LoggingConfiguration.java index 32972635f..45403bcab 100644 --- a/src/main/java/com/launchdarkly/sdk/server/interfaces/LoggingConfiguration.java +++ b/src/main/java/com/launchdarkly/sdk/server/interfaces/LoggingConfiguration.java @@ -23,23 +23,20 @@ public interface LoggingConfiguration { Duration getLogDataSourceOutageAsErrorAfter(); /** - * Additional properties used for the SDK's new logging facade. These are defined as a separate - * interface for now because modifying LoggingConfiguration would be a breaking change. This will - * be simplified in the next major version. - * + * Returns the configured base logger name. + * @return the logger name + * @since 5.10.0 + */ + default String getBaseLoggerName() { + return null; + } + + /** + * Returns the configured logging adapter. + * @return the logging adapter * @since 5.10.0 */ - public interface AdapterOptions { - /** - * Returns the configured base logger name. - * @return the logger name - */ - String getBaseLoggerName(); - - /** - * Returns the configured logging adapter. - * @return the logging adapter - */ - LDLogAdapter getLogAdapter(); + default LDLogAdapter getLogAdapter() { + return null; } } From 6953613dedde24cbae26dd7c7c6382036a8e1514 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 15 Jun 2022 13:02:57 -0700 Subject: [PATCH 636/673] make BigSegmentStoreWrapper.pollingDetectsStaleStatus test less timing-sensitive --- .../launchdarkly/sdk/server/BigSegmentStoreWrapperTest.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/test/java/com/launchdarkly/sdk/server/BigSegmentStoreWrapperTest.java b/src/test/java/com/launchdarkly/sdk/server/BigSegmentStoreWrapperTest.java index 1b5a8bb38..c758966ba 100644 --- a/src/test/java/com/launchdarkly/sdk/server/BigSegmentStoreWrapperTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/BigSegmentStoreWrapperTest.java @@ -235,7 +235,7 @@ public void pollingDetectsStoreUnavailability() throws Exception { public void pollingDetectsStaleStatus() throws Exception { replayAll(); - storeMetadata.set(new StoreMetadata(System.currentTimeMillis() + 5000)); + storeMetadata.set(new StoreMetadata(System.currentTimeMillis() + 10000)); BigSegmentsConfiguration bsConfig = Components.bigSegments(storeFactoryMock) .statusPollInterval(Duration.ofMillis(10)) .staleAfter(Duration.ofMillis(200)) @@ -246,12 +246,12 @@ public void pollingDetectsStaleStatus() throws Exception { BlockingQueue statuses = new LinkedBlockingQueue<>(); eventBroadcaster.register(statuses::add); - storeMetadata.set(new StoreMetadata(System.currentTimeMillis() - 200)); + storeMetadata.set(new StoreMetadata(System.currentTimeMillis() - 1000)); Status status1 = statuses.take(); assertTrue(status1.isStale()); assertEquals(status1, wrapper.getStatus()); - storeMetadata.set(new StoreMetadata(System.currentTimeMillis() + 5000)); + storeMetadata.set(new StoreMetadata(System.currentTimeMillis() + 10000)); Status status2 = statuses.take(); assertFalse(status2.isStale()); assertEquals(status2, wrapper.getStatus()); From 96d1db2ba30b9c4fd95e419170a8ffaa4dcfa688 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 15 Jun 2022 13:17:00 -0700 Subject: [PATCH 637/673] make LDEndToEndClientTest.test____SpecialHttpConfigurations less timing-sensitive --- .../sdk/server/LDClientEndToEndTest.java | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientEndToEndTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientEndToEndTest.java index 37faffa5d..ee442b860 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientEndToEndTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientEndToEndTest.java @@ -123,9 +123,7 @@ public void testPollingModeSpecialHttpConfigurations() throws Exception { new LDConfig.Builder() .dataSource(Components.pollingDataSource().baseURI(serverUri)) .events(noEvents()) - .http(httpConfig) - .startWait(Duration.ofMillis(100)) - .build()); + .http(httpConfig)); } @Test @@ -208,9 +206,7 @@ public void testStreamingModeSpecialHttpConfigurations() throws Exception { new LDConfig.Builder() .dataSource(Components.streamingDataSource().baseURI(serverUri)) .events(noEvents()) - .http(httpConfig) - .startWait(Duration.ofMillis(100)) - .build()); + .http(httpConfig)); } @Test @@ -254,17 +250,21 @@ public void clientSendsDiagnosticEvent() throws Exception { } private static void testWithSpecialHttpConfigurations(Handler handler, - BiFunction makeConfig) throws Exception { + BiFunction makeConfig) throws Exception { TestHttpUtil.testWithSpecialHttpConfigurations(handler, (serverUri, httpConfig) -> { - LDConfig config = makeConfig.apply(serverUri, httpConfig); + LDConfig config = makeConfig.apply(serverUri, httpConfig) + .startWait(Duration.ofSeconds(10)) // allow extra time to be sure it can connect + .build(); try (LDClient client = new LDClient(sdkKey, config)) { assertTrue(client.isInitialized()); assertTrue(client.boolVariation(flagKey, user, false)); } }, (serverUri, httpConfig) -> { - LDConfig config = makeConfig.apply(serverUri, httpConfig); + LDConfig config = makeConfig.apply(serverUri, httpConfig) + .startWait(Duration.ofMillis(100)) // don't wait terribly long when we don't expect it to succeed + .build(); try (LDClient client = new LDClient(sdkKey, config)) { assertFalse(client.isInitialized()); } From 27e58ba21d6d34ed4cd04222ccf014f8d63998c3 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 15 Jun 2022 13:46:22 -0700 Subject: [PATCH 638/673] make data source status tests less timing-sensitive --- .../com/launchdarkly/sdk/server/TestUtil.java | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/test/java/com/launchdarkly/sdk/server/TestUtil.java b/src/test/java/com/launchdarkly/sdk/server/TestUtil.java index 4cf89588e..7e3a0ba91 100644 --- a/src/test/java/com/launchdarkly/sdk/server/TestUtil.java +++ b/src/test/java/com/launchdarkly/sdk/server/TestUtil.java @@ -21,6 +21,7 @@ import java.net.InetSocketAddress; import java.net.Socket; import java.net.SocketAddress; +import java.time.Duration; import java.util.function.BiConsumer; import java.util.function.Function; import java.util.function.Supplier; @@ -66,17 +67,29 @@ public static void upsertSegment(DataStore store, Segment segment) { store.upsert(SEGMENTS, segment.getKey(), new ItemDescriptor(segment.getVersion(), segment)); } + public static DataSourceStatusProvider.Status requireDataSourceStatus(BlockingQueue statuses, Duration timeout) { + return awaitValue(statuses, timeout.toMillis(), TimeUnit.MILLISECONDS); + } + public static DataSourceStatusProvider.Status requireDataSourceStatus(BlockingQueue statuses) { - return awaitValue(statuses, 1, TimeUnit.SECONDS); + return requireDataSourceStatus(statuses, Duration.ofSeconds(5)); + // Using a fairly long default timeout here because there can be unpredictable execution delays + // in CI. If there's a test where we specifically need to enforce a smaller timeout, we can set + // that explicitly on a per-call basis. } public static DataSourceStatusProvider.Status requireDataSourceStatus(BlockingQueue statuses, - DataSourceStatusProvider.State expectedState) { - DataSourceStatusProvider.Status status = requireDataSourceStatus(statuses); + DataSourceStatusProvider.State expectedState, Duration timeout) { + DataSourceStatusProvider.Status status = requireDataSourceStatus(statuses, timeout); assertEquals(expectedState, status.getState()); return status; } + public static DataSourceStatusProvider.Status requireDataSourceStatus(BlockingQueue statuses, + DataSourceStatusProvider.State expectedState) { + return requireDataSourceStatus(statuses, expectedState, Duration.ofSeconds(5)); + } + public static DataSourceStatusProvider.Status requireDataSourceStatusEventually(BlockingQueue statuses, DataSourceStatusProvider.State expectedState, DataSourceStatusProvider.State possibleStateBeforeThat) { return Assertions.assertPolledFunctionReturnsValue(2, TimeUnit.SECONDS, 0, null, () -> { From a8448c3d85e6d003cebd1ed343938cd5e6c4098b Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Sun, 26 Jun 2022 16:58:20 -0700 Subject: [PATCH 639/673] use streaming JSON parsing for incoming LD data --- build.gradle | 2 +- .../sdk/server/DataModelSerialization.java | 141 +++++++++ .../sdk/server/DefaultFeatureRequestor.java | 25 +- .../sdk/server/FeatureRequestor.java | 40 +-- .../launchdarkly/sdk/server/JsonHelpers.java | 56 ++-- .../sdk/server/PollingProcessor.java | 6 +- .../sdk/server/StreamProcessor.java | 132 +++------ .../sdk/server/StreamProcessorEvents.java | 270 ++++++++++++++++++ .../server/DataModelSerializationTest.java | 92 ++++++ .../server/DefaultFeatureRequestorTest.java | 52 ++-- .../sdk/server/JsonHelpersTest.java | 47 --- .../sdk/server/StreamProcessorEventsTest.java | 130 +++++++++ .../com/launchdarkly/sdk/server/TestUtil.java | 24 ++ 13 files changed, 765 insertions(+), 252 deletions(-) create mode 100644 src/main/java/com/launchdarkly/sdk/server/DataModelSerialization.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/StreamProcessorEvents.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/StreamProcessorEventsTest.java diff --git a/build.gradle b/build.gradle index d5b1fbc25..58cdd7f30 100644 --- a/build.gradle +++ b/build.gradle @@ -74,7 +74,7 @@ ext.versions = [ "jackson": "2.11.2", "launchdarklyJavaSdkCommon": "1.3.0", "okhttp": "4.8.1", // specify this for the SDK build instead of relying on the transitive dependency from okhttp-eventsource - "okhttpEventsource": "2.3.2", + "okhttpEventsource": "2.6.0-SNAPSHOT", "slf4j": "1.7.21", "snakeyaml": "1.26", "jedis": "2.9.0" diff --git a/src/main/java/com/launchdarkly/sdk/server/DataModelSerialization.java b/src/main/java/com/launchdarkly/sdk/server/DataModelSerialization.java new file mode 100644 index 000000000..dd55cb879 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/DataModelSerialization.java @@ -0,0 +1,141 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.gson.JsonElement; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.launchdarkly.sdk.server.DataModel.FeatureFlag; +import com.launchdarkly.sdk.server.DataModel.Segment; +import com.launchdarkly.sdk.server.DataModel.VersionedData; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; +import com.launchdarkly.sdk.server.interfaces.SerializationException; + +import java.io.IOException; +import java.util.AbstractMap; +import java.util.Map; + +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; +import static com.launchdarkly.sdk.server.JsonHelpers.gsonInstance; + +/** + * JSON conversion logic specifically for our data model types. + *

            + * More general JSON helpers are in JsonHelpers. + */ +abstract class DataModelSerialization { + /** + * 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(DataKind kind, JsonElement parsedJson) throws SerializationException { + VersionedData item; + try { + if (kind == FEATURES) { + item = gsonInstance().fromJson(parsedJson, FeatureFlag.class); + } else if (kind == SEGMENTS) { + item = gsonInstance().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 (RuntimeException e) { + // A variety of unchecked exceptions can be thrown from JSON parsing; treat them all the same + throw new SerializationException(e); + } + return item; + } + + /** + * Deserializes a data model object from a Gson reader. + * + * @param kind the data kind + * @param jr the JSON reader + * @return the deserialized item + */ + static VersionedData deserializeFromJsonReader(DataKind kind, JsonReader jr) throws SerializationException { + VersionedData item; + try { + if (kind == FEATURES) { + item = gsonInstance().fromJson(jr, FeatureFlag.class); + } else if (kind == SEGMENTS) { + item = gsonInstance().fromJson(jr, 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 (RuntimeException e) { + // A variety of unchecked exceptions can be thrown from JSON parsing; treat them all the same + throw new SerializationException(e); + } + return item; + } + + /** + * Deserializes a full set of flag/segment data from a standard JSON object representation + * in the form {"flags": ..., "segments": ...} (which is used in both streaming and polling + * responses). + * + * @param jr the JSON reader + * @return the deserialized data + */ + static FullDataSet parseFullDataSet(JsonReader jr) throws SerializationException { + ImmutableList.Builder> flags = ImmutableList.builder(); + ImmutableList.Builder> segments = ImmutableList.builder(); + + try { + jr.beginObject(); + while (jr.peek() != JsonToken.END_OBJECT) { + String kindName = jr.nextName(); + Class itemClass; + ImmutableList.Builder> listBuilder; + switch (kindName) { + case "flags": + itemClass = DataModel.FeatureFlag.class; + listBuilder = flags; + break; + case "segments": + itemClass = DataModel.Segment.class; + listBuilder = segments; + break; + default: + jr.skipValue(); + continue; + } + jr.beginObject(); + while (jr.peek() != JsonToken.END_OBJECT) { + String key = jr.nextName(); + @SuppressWarnings("unchecked") + Object item = JsonHelpers.deserialize(jr, (Class)itemClass); + listBuilder.add(new AbstractMap.SimpleEntry<>(key, + new ItemDescriptor(((VersionedData)item).getVersion(), item))); + } + jr.endObject(); + } + jr.endObject(); + + return new FullDataSet(ImmutableMap.of( + FEATURES, new KeyedItems<>(flags.build()), + SEGMENTS, new KeyedItems<>(segments.build()) + ).entrySet()); + } catch (IOException e) { + throw new SerializationException(e); + } catch (RuntimeException e) { + // A variety of unchecked exceptions can be thrown from JSON parsing; treat them all the same + throw new SerializationException(e); + } + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/DefaultFeatureRequestor.java b/src/main/java/com/launchdarkly/sdk/server/DefaultFeatureRequestor.java index 2eb323588..d97bc2fb7 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DefaultFeatureRequestor.java +++ b/src/main/java/com/launchdarkly/sdk/server/DefaultFeatureRequestor.java @@ -1,6 +1,9 @@ package com.launchdarkly.sdk.server; import com.google.common.annotations.VisibleForTesting; +import com.google.gson.stream.JsonReader; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; import com.launchdarkly.sdk.server.interfaces.SerializationException; @@ -11,6 +14,7 @@ import java.nio.file.Files; import java.nio.file.Path; +import static com.launchdarkly.sdk.server.DataModelSerialization.parseFullDataSet; import static com.launchdarkly.sdk.server.Util.concatenateUriPath; import static com.launchdarkly.sdk.server.Util.configureHttpClientBuilder; import static com.launchdarkly.sdk.server.Util.getHeadersBuilderFor; @@ -23,7 +27,7 @@ import okhttp3.Response; /** - * Implementation of getting flag data via a polling request. Used by both streaming and polling components. + * Implementation of getting flag data via a polling request. */ final class DefaultFeatureRequestor implements FeatureRequestor { private static final Logger logger = Loggers.DATA_SOURCE; @@ -59,7 +63,8 @@ public void close() { Util.deleteDirectory(cacheDir); } - public AllData getAllData(boolean returnDataEvenIfCached) throws IOException, HttpErrorException, SerializationException { + public FullDataSet getAllData(boolean returnDataEvenIfCached) + throws IOException, HttpErrorException, SerializationException { Request request = new Request.Builder() .url(pollingUri.toURL()) .headers(headers) @@ -75,18 +80,18 @@ public AllData getAllData(boolean returnDataEvenIfCached) throws IOException, Ht logger.debug("Cache hit count: " + httpClient.cache().hitCount() + " Cache network Count: " + httpClient.cache().networkCount()); return null; } - - 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("Get flag(s) response: " + response.toString()); logger.debug("Network response: " + response.networkResponse()); logger.debug("Cache hit count: " + httpClient.cache().hitCount() + " Cache network Count: " + httpClient.cache().networkCount()); logger.debug("Cache response: " + response.cacheResponse()); - - return JsonHelpers.deserialize(body, AllData.class); + + if (!response.isSuccessful()) { + throw new HttpErrorException(response.code()); + } + + JsonReader jr = new JsonReader(response.body().charStream()); + return parseFullDataSet(jr); } } } diff --git a/src/main/java/com/launchdarkly/sdk/server/FeatureRequestor.java b/src/main/java/com/launchdarkly/sdk/server/FeatureRequestor.java index 37cc0d780..64a012eec 100644 --- a/src/main/java/com/launchdarkly/sdk/server/FeatureRequestor.java +++ b/src/main/java/com/launchdarkly/sdk/server/FeatureRequestor.java @@ -1,20 +1,10 @@ package com.launchdarkly.sdk.server; -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableMap; -import com.launchdarkly.sdk.server.DataModel.VersionedData; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.KeyedItems; import java.io.Closeable; import java.io.IOException; -import java.util.AbstractMap; -import java.util.Map; - -import static com.launchdarkly.sdk.server.DataModel.FEATURES; -import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; /** * Internal abstraction for polling requests. Currently this is only used by PollingProcessor, and @@ -31,33 +21,5 @@ interface FeatureRequestor extends Closeable { * @throws IOException for network errors * @throws HttpErrorException for HTTP error responses */ - AllData getAllData(boolean returnDataEvenIfCached) throws IOException, HttpErrorException; - - static class AllData { - final Map flags; - final Map segments; - - AllData(Map flags, Map segments) { - this.flags = flags; - this.segments = segments; - } - - FullDataSet toFullDataSet() { - return new FullDataSet(ImmutableMap.of( - FEATURES, toKeyedItems(FEATURES, flags), - SEGMENTS, toKeyedItems(SEGMENTS, segments) - ).entrySet()); - } - - static KeyedItems toKeyedItems(DataKind kind, Map itemsMap) { - ImmutableList.Builder> builder = ImmutableList.builder(); - if (itemsMap != null) { - for (Map.Entry e: itemsMap.entrySet()) { - ItemDescriptor item = new ItemDescriptor(e.getValue().getVersion(), e.getValue()); - builder.add(new AbstractMap.SimpleEntry<>(e.getKey(), item)); - } - } - return new KeyedItems<>(builder.build()); - } - } + FullDataSet getAllData(boolean returnDataEvenIfCached) throws IOException, HttpErrorException; } diff --git a/src/main/java/com/launchdarkly/sdk/server/JsonHelpers.java b/src/main/java/com/launchdarkly/sdk/server/JsonHelpers.java index ed9b36878..e910617f8 100644 --- a/src/main/java/com/launchdarkly/sdk/server/JsonHelpers.java +++ b/src/main/java/com/launchdarkly/sdk/server/JsonHelpers.java @@ -2,24 +2,16 @@ 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.sdk.LDUser; -import com.launchdarkly.sdk.server.DataModel.FeatureFlag; -import com.launchdarkly.sdk.server.DataModel.Segment; -import com.launchdarkly.sdk.server.DataModel.VersionedData; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.interfaces.SerializationException; import java.io.IOException; - -import static com.launchdarkly.sdk.server.DataModel.FEATURES; -import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; +import java.io.Reader; abstract class JsonHelpers { private JsonHelpers() {} @@ -77,6 +69,22 @@ static T deserialize(String json, Class objectClass) throws Serialization } } + static T deserialize(Reader reader, Class objectClass) throws SerializationException { + try { + return gsonInstance().fromJson(reader, objectClass); + } catch (Exception e) { + throw new SerializationException(e); + } + } + + static T deserialize(JsonReader reader, Class objectClass) throws SerializationException { + try { + return gsonInstance().fromJson(reader, 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 @@ -90,36 +98,6 @@ static String serialize(Object o) { return gsonInstance().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(DataKind kind, JsonElement parsedJson) throws SerializationException { - VersionedData item; - try { - if (kind == FEATURES) { - item = gsonInstance().fromJson(parsedJson, FeatureFlag.class); - } else if (kind == SEGMENTS) { - item = gsonInstance().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/sdk/server/PollingProcessor.java b/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java index 31ad32a39..c0829302a 100644 --- a/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/PollingProcessor.java @@ -6,6 +6,8 @@ import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorKind; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.State; import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.interfaces.SerializationException; import org.slf4j.Logger; @@ -89,12 +91,12 @@ private void poll() { // want to bother parsing the data or reinitializing the data store. But if we never succeeded in // storing any data, then we would still want to parse and try to store it even if it's cached. boolean alreadyInited = initialized.get(); - FeatureRequestor.AllData allData = requestor.getAllData(!alreadyInited); + FullDataSet allData = requestor.getAllData(!alreadyInited); if (allData == null) { // This means it was cached, and alreadyInited was true dataSourceUpdates.updateStatus(State.VALID, null); } else { - if (dataSourceUpdates.init(allData.toFullDataSet())) { + if (dataSourceUpdates.init(allData)) { dataSourceUpdates.updateStatus(State.VALID, null); if (!initialized.getAndSet(true)) { logger.info("Initialized LaunchDarkly client."); diff --git a/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java index f0406c206..6558ecaa1 100644 --- a/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java @@ -1,22 +1,23 @@ package com.launchdarkly.sdk.server; import com.google.common.annotations.VisibleForTesting; -import com.google.gson.JsonElement; +import com.google.gson.JsonParseException; +import com.google.gson.stream.JsonReader; import com.launchdarkly.eventsource.ConnectionErrorHandler; import com.launchdarkly.eventsource.ConnectionErrorHandler.Action; import com.launchdarkly.eventsource.EventHandler; import com.launchdarkly.eventsource.EventSource; import com.launchdarkly.eventsource.MessageEvent; import com.launchdarkly.eventsource.UnsuccessfulResponseException; -import com.launchdarkly.sdk.server.DataModel.VersionedData; +import com.launchdarkly.sdk.server.StreamProcessorEvents.DeleteData; +import com.launchdarkly.sdk.server.StreamProcessorEvents.PatchData; +import com.launchdarkly.sdk.server.StreamProcessorEvents.PutData; import com.launchdarkly.sdk.server.interfaces.DataSource; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorInfo; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.ErrorKind; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider.State; import com.launchdarkly.sdk.server.interfaces.DataSourceUpdates; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; import com.launchdarkly.sdk.server.interfaces.SerializationException; @@ -24,17 +25,15 @@ import org.slf4j.Logger; import java.io.IOException; +import java.io.Reader; import java.net.URI; import java.time.Duration; import java.time.Instant; -import java.util.AbstractMap; -import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Function; -import static com.launchdarkly.sdk.server.DataModel.ALL_DATA_KINDS; -import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; import static com.launchdarkly.sdk.server.Util.checkIfErrorIsRecoverableAndLog; import static com.launchdarkly.sdk.server.Util.concatenateUriPath; import static com.launchdarkly.sdk.server.Util.configureHttpClientBuilder; @@ -176,8 +175,22 @@ public Future start() { EventHandler handler = new StreamEventHandler(initFuture); URI endpointUri = concatenateUriPath(streamUri, StandardEndpoints.STREAMING_REQUEST_PATH); + // Notes about the configuration of the EventSource below: + // + // 1. Setting streamEventData(true) is an optimization to let us read the event's data field directly + // from HTTP response stream, rather than waiting for the whole event to be buffered in memory. See + // the Javadoc for EventSource.Builder.streamEventData for more details. This relies on an assumption + // that the LD streaming endpoints will always send the "event:" field before the "data:" field. + // + // 2. The readTimeout here is not the same read timeout that can be set in LDConfig. We default to a + // smaller one there because we don't expect long delays within any *non*-streaming response that the + // LD client gets. A read timeout on the stream will result in the connection being cycled, so we set + // this to be slightly more than the expected interval between heartbeat signals. + EventSource.Builder builder = new EventSource.Builder(handler, endpointUri) .threadPriority(threadPriority) + .readBufferSize(5000) + .streamEventData(true) .loggerBaseName(Loggers.DATA_SOURCE_LOGGER_NAME) .clientBuilderActions(new EventSource.Builder.ClientConfigurer() { public void configure(OkHttpClient.Builder builder) { @@ -188,10 +201,6 @@ public void configure(OkHttpClient.Builder builder) { .headers(headers) .reconnectTime(initialReconnectDelay) .readTimeout(DEAD_CONNECTION_INTERVAL); - // Note that this is not the same read timeout that can be set in LDConfig. We default to a smaller one - // there because we don't expect long delays within any *non*-streaming response that the LD client gets. - // A read timeout on the stream will result in the connection being cycled, so we set this to be slightly - // more than the expected interval between heartbeat signals. es = builder.build(); esStarted = System.currentTimeMillis(); @@ -238,23 +247,23 @@ public void onClosed() throws Exception { } @Override - public void onMessage(String name, MessageEvent event) throws Exception { + public void onMessage(String eventName, MessageEvent event) throws Exception { try { - switch (name) { + switch (eventName) { case PUT: - handlePut(event.getData()); + handlePut(event.getDataReader()); break; case PATCH: - handlePatch(event.getData()); + handlePatch(event.getDataReader()); break; case DELETE: - handleDelete(event.getData()); + handleDelete(event.getDataReader()); break; default: - logger.warn("Unexpected event found in stream: " + name); + logger.warn("Unexpected event found in stream: " + eventName); break; } lastStoreUpdateFailed = false; @@ -287,12 +296,11 @@ public void onMessage(String name, MessageEvent event) throws Exception { } } - private void handlePut(String eventData) throws StreamInputException, StreamStoreException { + private void handlePut(Reader eventData) throws StreamInputException, StreamStoreException { recordStreamInit(false); esStarted = 0; - PutData putData = parseStreamJson(PutData.class, eventData); - FullDataSet allData = putData.data.toFullDataSet(); - if (!dataSourceUpdates.init(allData)) { + PutData putData = parseStreamJson(StreamProcessorEvents::parsePutData, eventData); + if (!dataSourceUpdates.init(putData.data)) { throw new StreamStoreException(); } if (!initialized.getAndSet(true)) { @@ -301,30 +309,23 @@ private void handlePut(String eventData) throws StreamInputException, StreamStor } } - private void handlePatch(String eventData) throws StreamInputException, StreamStoreException { - PatchData data = parseStreamJson(PatchData.class, eventData); - Map.Entry kindAndKey = getKindAndKeyFromStreamApiPath(data.path); - if (kindAndKey == null) { + private void handlePatch(Reader eventData) throws StreamInputException, StreamStoreException { + PatchData data = parseStreamJson(StreamProcessorEvents::parsePatchData, eventData); + if (data.kind == null) { return; } - DataKind kind = kindAndKey.getKey(); - String key = kindAndKey.getValue(); - VersionedData item = deserializeFromParsedJson(kind, data.data); - if (!dataSourceUpdates.upsert(kind, key, new ItemDescriptor(item.getVersion(), item))) { + if (!dataSourceUpdates.upsert(data.kind, data.key, data.item)) { throw new StreamStoreException(); } } - private void handleDelete(String eventData) throws StreamInputException, StreamStoreException { - DeleteData data = parseStreamJson(DeleteData.class, eventData); - Map.Entry kindAndKey = getKindAndKeyFromStreamApiPath(data.path); - if (kindAndKey == null) { + private void handleDelete(Reader eventData) throws StreamInputException, StreamStoreException { + DeleteData data = parseStreamJson(StreamProcessorEvents::parseDeleteData, eventData); + if (data.kind == null) { return; } - DataKind kind = kindAndKey.getKey(); - String key = kindAndKey.getValue(); ItemDescriptor placeholder = new ItemDescriptor(data.version, null); - if (!dataSourceUpdates.upsert(kind, key, placeholder)) { + if (!dataSourceUpdates.upsert(data.kind, data.key, placeholder)) { throw new StreamStoreException(); } } @@ -341,33 +342,17 @@ public void onError(Throwable throwable) { } } - private static Map.Entry getKindAndKeyFromStreamApiPath(String path) throws StreamInputException { - if (path == null) { - throw new StreamInputException("missing item path"); - } - for (DataKind kind: ALL_DATA_KINDS) { - String prefix = (kind == SEGMENTS) ? "/segments/" : "/flags/"; - if (path.startsWith(prefix)) { - return new AbstractMap.SimpleEntry(kind, 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 { + private static T parseStreamJson(Function parser, Reader r) throws StreamInputException { try { - return JsonHelpers.deserialize(json, c); - } catch (SerializationException e) { + try (JsonReader jr = new JsonReader(r)) { + return parser.apply(jr); + } + } catch (JsonParseException e) { throw new StreamInputException(e); - } - } - - private static VersionedData deserializeFromParsedJson(DataKind kind, JsonElement parsedJson) - throws StreamInputException { - try { - return JsonHelpers.deserializeFromParsedJson(kind, parsedJson); } catch (SerializationException e) { throw new StreamInputException(e); + } catch (IOException e) { + throw new StreamInputException(e); } } @@ -375,10 +360,6 @@ private static VersionedData deserializeFromParsedJson(DataKind kind, JsonElemen // (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); } @@ -387,27 +368,4 @@ public StreamInputException(Throwable cause) { // This exception class indicates that the data store failed to persist an update. @SuppressWarnings("serial") private static final class StreamStoreException extends Exception {} - - private static final class PutData { - FeatureRequestor.AllData data; - - @SuppressWarnings("unused") // used by Gson - public PutData() { } - } - - private static final class PatchData { - String path; - JsonElement data; - - @SuppressWarnings("unused") // used by Gson - public PatchData() { } - } - - private static final class DeleteData { - String path; - int version; - - @SuppressWarnings("unused") // used by Gson - public DeleteData() { } - } } diff --git a/src/main/java/com/launchdarkly/sdk/server/StreamProcessorEvents.java b/src/main/java/com/launchdarkly/sdk/server/StreamProcessorEvents.java new file mode 100644 index 000000000..b8d740265 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/StreamProcessorEvents.java @@ -0,0 +1,270 @@ +package com.launchdarkly.sdk.server; + +import com.google.gson.JsonElement; +import com.google.gson.JsonParseException; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.launchdarkly.sdk.server.DataModel.VersionedData; +import com.launchdarkly.sdk.server.DataModelDependencies.KindAndKey; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.SerializationException; + +import java.io.IOException; + +import static com.launchdarkly.sdk.server.DataModel.ALL_DATA_KINDS; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; +import static com.launchdarkly.sdk.server.DataModelSerialization.deserializeFromJsonReader; +import static com.launchdarkly.sdk.server.DataModelSerialization.deserializeFromParsedJson; +import static com.launchdarkly.sdk.server.DataModelSerialization.parseFullDataSet; +import static com.launchdarkly.sdk.server.JsonHelpers.gsonInstance; + +// Deserialization of stream message data is all encapsulated here, so StreamProcessor can +// deal with just the logical behavior of the stream and we can test this logic separately. +// The parseFullDataSet +// +// All of the parsing methods have the following behavior: +// +// - They take the input data as a JsonReader, which the caller is responsible for constructing. +// +// - A SerializationException is thrown for any malformed data. That includes 1. totally invalid +// JSON, 2. well-formed JSON that is missing a necessary property for this message type. The Gson +// parser can throw various kinds of unchecked exceptions for things like wrong data types, but we +// wrap them all in SerializationException. +// +// - For messages that have a "path" property, which might be for instance "/flags/xyz" to refer +// to a feature flag with the key "xyz", an unrecognized path like "/cats/Lucy" is not considered +// an error since it might mean LaunchDarkly now supports some new kind of data the SDK can't yet +// use and should ignore. In this case we simply return null in place of a DataKind. +abstract class StreamProcessorEvents { + // This is the logical representation of the data in the "put" event. In the JSON representation, + // the "data" property is actually a map of maps, but the schema we use internally is a list of + // lists instead. + // + // The "path" property is normally always "/"; the LD streaming service sends this property, but + // some versions of Relay do not, so we do not require it. + // + // Example JSON representation: + // + // { + // "path": "/", + // "data": { + // "flags": { + // "flag1": { "key": "flag1", "version": 1, ...etc. }, + // "flag2": { "key": "flag2", "version": 1, ...etc. }, + // }, + // "segments": { + // "segment1": { "key", "segment1", "version": 1, ...etc. } + // } + // } + // } + static final class PutData { + final String path; + final FullDataSet data; + + PutData(String path, FullDataSet data) { + this.path = path; + this.data = data; + } + } + + // This is the logical representation of the data in the "patch" event. In the JSON representation, + // there is a "path" property in the format "/flags/key" or "/segments/key", which we convert into + // Kind and Key when we parse it. The "data" property is the JSON representation of the flag or + // segment, which we deserialize into an ItemDescriptor. + // + // Example JSON representation: + // + // { + // "path": "/flags/flagkey", + // "data": { + // "key": "flagkey", + // "version": 2, ...etc. + // } + // } + static final class PatchData { + final DataKind kind; + final String key; + final ItemDescriptor item; + + PatchData(DataKind kind, String key, ItemDescriptor item) { + this.kind = kind; + this.key = key; + this.item = item; + } + } + + // This is the logical representation of the data in the "delete" event. In the JSON representation, + // there is a "path" property in the format "/flags/key" or "/segments/key", which we convert into + // Kind and Key when we parse it. + // + // Example JSON representation: + // + // { + // "path": "/flags/flagkey", + // "version": 3 + // } + static final class DeleteData { + final DataKind kind; + final String key; + final int version; + + public DeleteData(DataKind kind, String key, int version) { + this.kind = kind; + this.key = key; + this.version = version; + } + } + + static PutData parsePutData(JsonReader jr) { + String path = null; + FullDataSet data = null; + + try { + jr.beginObject(); + while (jr.peek() != JsonToken.END_OBJECT) { + String prop = jr.nextName(); + switch (prop) { + case "path": + path = jr.nextString(); + break; + case "data": + data = parseFullDataSet(jr); + break; + default: + jr.skipValue(); + } + } + jr.endObject(); + + if (data == null) { + throw missingRequiredProperty("put", "data"); + } + + return new PutData(path, data); + } catch (IOException e) { + throw new SerializationException(e); + } catch (RuntimeException e) { + throw new SerializationException(e); + } + } + + static PatchData parsePatchData(JsonReader jr) { + // The logic here is a little convoluted because JSON object property ordering is arbitrary, so + // we don't know for sure that we'll see the "path" property before the "data" property, but we + // won't know what kind of object to parse "data" into until we know whether "path" starts with + // "/flags" or "/segments". So, if we see "data" first, we'll have to pull its value into a + // temporary buffer and parse it afterward, which is less efficient than parsing directly from + // the stream. However, in practice, the LD streaming service does send "path" first. + DataKind kind = null; + String key = null; + VersionedData dataItem = null; + JsonElement bufferedParsedData = null; + + try { + jr.beginObject(); + while (jr.peek() != JsonToken.END_OBJECT) { + String prop = jr.nextName(); + switch (prop) { + case "path": + KindAndKey kindAndKey = parsePath(jr.nextString()); + if (kindAndKey == null) { + // An unrecognized path isn't considered an error; we'll just return a null kind, + // indicating that we should ignore this event. + return new PatchData(null, null, null); + } + kind = kindAndKey.kind; + key = kindAndKey.key; + break; + case "data": + if (kind != null) { + dataItem = deserializeFromJsonReader(kind, jr); + } else { + bufferedParsedData = gsonInstance().fromJson(jr, JsonElement.class); + } + break; + default: + jr.skipValue(); + } + } + jr.endObject(); + + if (kind == null) { + throw missingRequiredProperty("patch", "path"); + } + if (dataItem == null) { + if (bufferedParsedData == null) { + throw missingRequiredProperty("patch", "path"); + } + dataItem = deserializeFromParsedJson(kind, bufferedParsedData); + } + return new PatchData(kind, key, new ItemDescriptor(dataItem.getVersion(), dataItem)); + } catch (IOException e) { + throw new SerializationException(e); + } catch (RuntimeException e) { + throw new SerializationException(e); + } + } + + static DeleteData parseDeleteData(JsonReader jr) { + DataKind kind = null; + String key = null; + Integer version = null; + + try { + jr.beginObject(); + while (jr.peek() != JsonToken.END_OBJECT) { + String prop = jr.nextName(); + switch (prop) { + case "path": + KindAndKey kindAndKey = parsePath(jr.nextString()); + if (kindAndKey == null) { + // An unrecognized path isn't considered an error; we'll just return a null kind, + // indicating that we should ignore this event. + return new DeleteData(null, null, 0); + } + kind = kindAndKey.kind; + key = kindAndKey.key; + break; + case "version": + version = jr.nextInt(); + break; + default: + jr.skipValue(); + } + } + jr.endObject(); + + if (kind == null) { + throw missingRequiredProperty("delete", "path"); + } + if (version == null) { + throw missingRequiredProperty("delete", "version"); + } + return new DeleteData(kind, key, version); + } catch (IOException e) { + throw new SerializationException(e); + } catch (RuntimeException e) { + throw new SerializationException(e); + } + } + + private static KindAndKey parsePath(String path) { + if (path == null) { + throw new JsonParseException("item path cannot be null"); + } + for (DataKind kind: ALL_DATA_KINDS) { + String prefix = (kind == SEGMENTS) ? "/segments/" : "/flags/"; + if (path.startsWith(prefix)) { + return new KindAndKey(kind, 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 JsonParseException missingRequiredProperty(String eventName, String propName) { + return new JsonParseException(String.format("stream \"{}\" event did not have required property \"{}\"", + eventName, propName)); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java b/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java index 4a5afddb3..9ab24a7e2 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java @@ -2,6 +2,7 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; +import com.google.gson.JsonElement; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.ObjectBuilder; import com.launchdarkly.sdk.UserAttribute; @@ -14,7 +15,12 @@ import com.launchdarkly.sdk.server.DataModel.Segment; import com.launchdarkly.sdk.server.DataModel.SegmentRule; import com.launchdarkly.sdk.server.DataModel.Target; +import com.launchdarkly.sdk.server.DataModel.VersionedData; import com.launchdarkly.sdk.server.DataModel.WeightedVariation; +import com.launchdarkly.sdk.server.DataStoreTestTypes.DataBuilder; +import com.launchdarkly.sdk.server.interfaces.SerializationException; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import org.junit.Test; @@ -24,6 +30,13 @@ import static com.launchdarkly.sdk.server.DataModel.FEATURES; import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; +import static com.launchdarkly.sdk.server.DataModelSerialization.deserializeFromParsedJson; +import static com.launchdarkly.sdk.server.DataModelSerialization.parseFullDataSet; +import static com.launchdarkly.sdk.server.JsonHelpers.serialize; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.segmentBuilder; +import static com.launchdarkly.sdk.server.TestUtil.assertDataSetEquals; +import static com.launchdarkly.sdk.server.TestUtil.jsonReaderFrom; import static com.launchdarkly.testhelpers.JsonAssertions.assertJsonEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -33,6 +46,49 @@ @SuppressWarnings("javadoc") public class DataModelSerializationTest { + + @Test + public void deserializeFlagFromParsedJson() { + String json = "{\"key\":\"flagkey\",\"version\":1}"; + JsonElement element = JsonHelpers.gsonInstance().fromJson(json, JsonElement.class); + VersionedData flag = deserializeFromParsedJson(DataModel.FEATURES, element); + assertEquals(FeatureFlag.class, flag.getClass()); + assertEquals("flagkey", flag.getKey()); + assertEquals(1, flag.getVersion()); + } + + @Test(expected=SerializationException.class) + public void deserializeInvalidFlagFromParsedJson() { + String json = "{\"key\":[3]}"; + JsonElement element = JsonHelpers.gsonInstance().fromJson(json, JsonElement.class); + deserializeFromParsedJson(DataModel.FEATURES, element); + } + + @Test + public void deserializeSegmentFromParsedJson() { + String json = "{\"key\":\"segkey\",\"version\":1}"; + JsonElement element = JsonHelpers.gsonInstance().fromJson(json, JsonElement.class); + VersionedData segment = deserializeFromParsedJson(DataModel.SEGMENTS, element); + assertEquals(Segment.class, segment.getClass()); + assertEquals("segkey", segment.getKey()); + assertEquals(1, segment.getVersion()); + } + + @Test(expected=SerializationException.class) + public void deserializeInvalidSegmentFromParsedJson() { + String json = "{\"key\":[3]}"; + JsonElement element = JsonHelpers.gsonInstance().fromJson(json, JsonElement.class); + deserializeFromParsedJson(DataModel.SEGMENTS, element); + } + + @Test(expected=IllegalArgumentException.class) + public void deserializeInvalidDataKindFromParsedJson() { + String json = "{\"key\":\"something\",\"version\":1}"; + JsonElement element = JsonHelpers.gsonInstance().fromJson(json, JsonElement.class); + DataKind mysteryKind = new DataKind("incorrect", null, null); + deserializeFromParsedJson(mysteryKind, element); + } + @Test public void flagIsDeserializedWithAllProperties() { assertFlagFromJson( @@ -301,6 +357,42 @@ public void explicitNullsAreToleratedForNullableValues() { ); } + @Test + public void parsingFullDataSetEmptyObject() throws Exception { + String json = "{}"; + FullDataSet allData = parseFullDataSet(jsonReaderFrom(json)); + assertDataSetEquals(DataBuilder.forStandardTypes().build(), allData); + } + + @Test + public void parsingFullDataSetFlagsOnly() throws Exception { + FeatureFlag flag = flagBuilder("flag1").version(1000).build(); + String json = "{\"flags\":{\"flag1\":" + serialize(flag) + "}}"; + FullDataSet allData = parseFullDataSet(jsonReaderFrom(json)); + assertDataSetEquals(DataBuilder.forStandardTypes().addAny(FEATURES, flag).build(), allData); + } + + @Test + public void parsingFullDataSetSegmentsOnly() throws Exception { + Segment segment = segmentBuilder("segment1").version(1000).build(); + String json = "{\"segments\":{\"segment1\":" + serialize(segment) + "}}"; + FullDataSet allData = parseFullDataSet(jsonReaderFrom(json)); + assertDataSetEquals(DataBuilder.forStandardTypes().addAny(SEGMENTS, segment).build(), allData); + } + + @Test + public void parsingFullDataSetFlagsAndSegments() throws Exception { + FeatureFlag flag1 = flagBuilder("flag1").version(1000).build(); + FeatureFlag flag2 = flagBuilder("flag2").version(1001).build(); + Segment segment1 = segmentBuilder("segment1").version(1000).build(); + Segment segment2 = segmentBuilder("segment2").version(1001).build(); + String json = "{\"flags\":{\"flag1\":" + serialize(flag1) + ",\"flag2\":" + serialize(flag2) + "}" + + ",\"segments\":{\"segment1\":" + serialize(segment1) + ",\"segment2\":" + serialize(segment2) + "}}"; + FullDataSet allData = parseFullDataSet(jsonReaderFrom(json)); + assertDataSetEquals(DataBuilder.forStandardTypes() + .addAny(FEATURES, flag1, flag2).addAny(SEGMENTS, segment1, segment2).build(), allData); + } + private void assertFlagFromJson(LDValue flagJson, Consumer action) { FeatureFlag flag = (FeatureFlag)FEATURES.deserialize(flagJson.toJsonString()).getItem(); action.accept(flag); diff --git a/src/test/java/com/launchdarkly/sdk/server/DefaultFeatureRequestorTest.java b/src/test/java/com/launchdarkly/sdk/server/DefaultFeatureRequestorTest.java index 8e3d73b83..18a06eab1 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DefaultFeatureRequestorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultFeatureRequestorTest.java @@ -1,6 +1,11 @@ package com.launchdarkly.sdk.server; +import com.launchdarkly.sdk.server.DataModel.FeatureFlag; +import com.launchdarkly.sdk.server.DataModel.Segment; +import com.launchdarkly.sdk.server.DataStoreTestTypes.DataBuilder; import com.launchdarkly.sdk.server.interfaces.BasicConfiguration; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.interfaces.HttpConfiguration; import com.launchdarkly.testhelpers.httptest.Handler; import com.launchdarkly.testhelpers.httptest.Handlers; @@ -12,7 +17,11 @@ import java.net.URI; import java.util.Map; +import static com.launchdarkly.sdk.server.JsonHelpers.serialize; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.segmentBuilder; import static com.launchdarkly.sdk.server.TestComponents.clientContext; +import static com.launchdarkly.sdk.server.TestUtil.assertDataSetEquals; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.junit.Assert.assertEquals; @@ -24,10 +33,12 @@ public class DefaultFeatureRequestorTest { private static final String sdkKey = "sdk-key"; private static final String flag1Key = "flag1"; - private static final String flag1Json = "{\"key\":\"" + flag1Key + "\"}"; + private static final FeatureFlag flag1 = flagBuilder(flag1Key).version(1000).build(); + private static final String flag1Json = serialize(flag1); private static final String flagsJson = "{\"" + flag1Key + "\":" + flag1Json + "}"; private static final String segment1Key = "segment1"; - private static final String segment1Json = "{\"key\":\"" + segment1Key + "\"}"; + private static final Segment segment1 = segmentBuilder(segment1Key).version(2000).build(); + private static final String segment1Json = serialize(segment1); private static final String segmentsJson = "{\"" + segment1Key + "\":" + segment1Json + "}"; private static final String allDataJson = "{\"flags\":" + flagsJson + ",\"segments\":" + segmentsJson + "}"; @@ -45,14 +56,11 @@ private HttpConfiguration makeHttpConfig(LDConfig config) { return config.httpConfigFactory.createHttpConfiguration(new BasicConfiguration(sdkKey, false, 0, null, null)); } - private void verifyExpectedData(FeatureRequestor.AllData data) { + private void verifyExpectedData(FullDataSet data) { 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); + assertDataSetEquals(DataBuilder.forStandardTypes() + .addAny(DataModel.FEATURES, flag1).addAny(DataModel.SEGMENTS, segment1).build(), + data); } @Test @@ -61,7 +69,7 @@ public void requestAllData() throws Exception { try (HttpServer server = HttpServer.start(resp)) { try (DefaultFeatureRequestor r = makeRequestor(server)) { - FeatureRequestor.AllData data = r.getAllData(true); + FullDataSet data = r.getAllData(true); RequestInfo req = server.getRecorder().requireRequest(); assertEquals("/sdk/latest-all", req.getPath()); @@ -84,7 +92,7 @@ public void responseIsCached() throws Exception { try (HttpServer server = HttpServer.start(cacheableThenCached)) { try (DefaultFeatureRequestor r = makeRequestor(server)) { - FeatureRequestor.AllData data1 = r.getAllData(true); + FullDataSet data1 = r.getAllData(true); verifyExpectedData(data1); RequestInfo req1 = server.getRecorder().requireRequest(); @@ -92,7 +100,7 @@ public void responseIsCached() throws Exception { verifyHeaders(req1); assertNull(req1.getHeader("If-None-Match")); - FeatureRequestor.AllData data2 = r.getAllData(false); + FullDataSet data2 = r.getAllData(false); assertNull(data2); RequestInfo req2 = server.getRecorder().requireRequest(); @@ -115,7 +123,7 @@ public void responseIsCachedButWeWantDataAnyway() throws Exception { try (HttpServer server = HttpServer.start(cacheableThenCached)) { try (DefaultFeatureRequestor r = makeRequestor(server)) { - FeatureRequestor.AllData data1 = r.getAllData(true); + FullDataSet data1 = r.getAllData(true); verifyExpectedData(data1); RequestInfo req1 = server.getRecorder().requireRequest(); @@ -123,7 +131,7 @@ public void responseIsCachedButWeWantDataAnyway() throws Exception { verifyHeaders(req1); assertNull(req1.getHeader("If-None-Match")); - FeatureRequestor.AllData data2 = r.getAllData(true); + FullDataSet data2 = r.getAllData(true); verifyExpectedData(data2); RequestInfo req2 = server.getRecorder().requireRequest(); @@ -143,7 +151,7 @@ public void testSpecialHttpConfigurations() throws Exception { LDConfig config = new LDConfig.Builder().http(goodHttpConfig).build(); try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(makeHttpConfig(config), targetUri)) { try { - FeatureRequestor.AllData data = r.getAllData(false); + FullDataSet data = r.getAllData(false); verifyExpectedData(data); } catch (Exception e) { throw new RuntimeException(e); @@ -170,7 +178,7 @@ public void baseUriDoesNotNeedTrailingSlash() throws Exception { try (HttpServer server = HttpServer.start(resp)) { try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(makeHttpConfig(LDConfig.DEFAULT), server.getUri())) { - FeatureRequestor.AllData data = r.getAllData(true); + FullDataSet data = r.getAllData(true); RequestInfo req = server.getRecorder().requireRequest(); assertEquals("/sdk/latest-all", req.getPath()); @@ -189,7 +197,7 @@ public void baseUriCanHaveContextPath() throws Exception { URI uri = server.getUri().resolve("/context/path"); try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(makeHttpConfig(LDConfig.DEFAULT), uri)) { - FeatureRequestor.AllData data = r.getAllData(true); + FullDataSet data = r.getAllData(true); RequestInfo req = server.getRecorder().requireRequest(); assertEquals("/context/path/sdk/latest-all", req.getPath()); @@ -206,14 +214,4 @@ private void verifyHeaders(RequestInfo req) { assertThat(req.getHeader(kv.getKey()), equalTo(kv.getValue())); } } - - private void verifyFlag(DataModel.FeatureFlag flag, String key) { - assertNotNull(flag); - assertEquals(key, flag.getKey()); - } - - private void verifySegment(DataModel.Segment segment, String key) { - assertNotNull(segment); - assertEquals(key, segment.getKey()); - } } diff --git a/src/test/java/com/launchdarkly/sdk/server/JsonHelpersTest.java b/src/test/java/com/launchdarkly/sdk/server/JsonHelpersTest.java index 28f767554..25ce7f133 100644 --- a/src/test/java/com/launchdarkly/sdk/server/JsonHelpersTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/JsonHelpersTest.java @@ -1,11 +1,6 @@ package com.launchdarkly.sdk.server; -import com.google.gson.JsonElement; import com.google.gson.annotations.JsonAdapter; -import com.launchdarkly.sdk.server.DataModel.FeatureFlag; -import com.launchdarkly.sdk.server.DataModel.Segment; -import com.launchdarkly.sdk.server.DataModel.VersionedData; -import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.interfaces.SerializationException; import org.junit.Test; @@ -35,48 +30,6 @@ public void deserializeInvalidJson() { JsonHelpers.deserialize("{\"value", MySerializableClass.class); } - @Test - public void deserializeFlagFromParsedJson() { - String json = "{\"key\":\"flagkey\",\"version\":1}"; - JsonElement element = JsonHelpers.gsonInstance().fromJson(json, JsonElement.class); - VersionedData flag = JsonHelpers.deserializeFromParsedJson(DataModel.FEATURES, element); - assertEquals(FeatureFlag.class, flag.getClass()); - assertEquals("flagkey", flag.getKey()); - assertEquals(1, flag.getVersion()); - } - - @Test(expected=SerializationException.class) - public void deserializeInvalidFlagFromParsedJson() { - String json = "{\"key\":[3]}"; - JsonElement element = JsonHelpers.gsonInstance().fromJson(json, JsonElement.class); - JsonHelpers.deserializeFromParsedJson(DataModel.FEATURES, element); - } - - @Test - public void deserializeSegmentFromParsedJson() { - String json = "{\"key\":\"segkey\",\"version\":1}"; - JsonElement element = JsonHelpers.gsonInstance().fromJson(json, JsonElement.class); - VersionedData segment = JsonHelpers.deserializeFromParsedJson(DataModel.SEGMENTS, element); - assertEquals(Segment.class, segment.getClass()); - assertEquals("segkey", segment.getKey()); - assertEquals(1, segment.getVersion()); - } - - @Test(expected=SerializationException.class) - public void deserializeInvalidSegmentFromParsedJson() { - String json = "{\"key\":[3]}"; - JsonElement element = JsonHelpers.gsonInstance().fromJson(json, JsonElement.class); - JsonHelpers.deserializeFromParsedJson(DataModel.SEGMENTS, element); - } - - @Test(expected=IllegalArgumentException.class) - public void deserializeInvalidDataKindFromParsedJson() { - String json = "{\"key\":\"something\",\"version\":1}"; - JsonElement element = JsonHelpers.gsonInstance().fromJson(json, JsonElement.class); - DataKind mysteryKind = new DataKind("incorrect", null, null); - JsonHelpers.deserializeFromParsedJson(mysteryKind, element); - } - @Test public void postProcessingTypeAdapterFactoryCallsAfterDeserializedIfApplicable() { // This tests the mechanism that ensures afterDeserialize() is called on every FeatureFlag or diff --git a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorEventsTest.java b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorEventsTest.java new file mode 100644 index 000000000..58118b596 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorEventsTest.java @@ -0,0 +1,130 @@ +package com.launchdarkly.sdk.server; + +import com.google.gson.JsonParseException; +import com.launchdarkly.sdk.server.DataModel.FeatureFlag; +import com.launchdarkly.sdk.server.DataModel.Segment; +import com.launchdarkly.sdk.server.DataStoreTestTypes.DataBuilder; +import com.launchdarkly.sdk.server.StreamProcessorEvents.DeleteData; +import com.launchdarkly.sdk.server.StreamProcessorEvents.PatchData; +import com.launchdarkly.sdk.server.StreamProcessorEvents.PutData; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; + +import org.junit.Test; + +import static com.launchdarkly.sdk.server.DataModel.FEATURES; +import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; +import static com.launchdarkly.sdk.server.JsonHelpers.serialize; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.segmentBuilder; +import static com.launchdarkly.sdk.server.StreamProcessorEvents.parseDeleteData; +import static com.launchdarkly.sdk.server.StreamProcessorEvents.parsePatchData; +import static com.launchdarkly.sdk.server.StreamProcessorEvents.parsePutData; +import static com.launchdarkly.sdk.server.TestUtil.assertDataSetEquals; +import static com.launchdarkly.sdk.server.TestUtil.assertItemEquals; +import static com.launchdarkly.sdk.server.TestUtil.assertThrows; +import static com.launchdarkly.sdk.server.TestUtil.jsonReaderFrom; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.nullValue; + +public class StreamProcessorEventsTest { + + @Test + public void parsingPutData() throws Exception { + FeatureFlag flag = flagBuilder("flag1").version(1000).build(); + Segment segment = segmentBuilder("segment1").version(1000).build(); + + String allDataJson = "{" + + "\"flags\": {\"flag1\":" + serialize(flag) + "}" + + ",\"segments\": {\"segment1\":" + serialize(segment) + "}}"; + FullDataSet expectedAllData = DataBuilder.forStandardTypes() + .addAny(FEATURES, flag).addAny(SEGMENTS, segment).build(); + String validInput = "{\"path\": \"/\", \"data\":" + allDataJson + "}"; + PutData validResult = parsePutData(jsonReaderFrom(validInput)); + assertThat(validResult.path, equalTo("/")); + assertDataSetEquals(expectedAllData, validResult.data); + + String inputWithoutPath = "{\"data\":" + allDataJson + "}"; + PutData resultWithoutPath = parsePutData(jsonReaderFrom(inputWithoutPath)); + assertThat(resultWithoutPath.path, nullValue()); + assertDataSetEquals(expectedAllData, validResult.data); + + String inputWithoutData = "{\"path\":\"/\"}"; + assertThrows(JsonParseException.class, + () -> parsePutData(jsonReaderFrom(inputWithoutData))); + } + + @Test + public void parsingPatchData() throws Exception { + FeatureFlag flag = flagBuilder("flag1").version(1000).build(); + Segment segment = segmentBuilder("segment1").version(1000).build(); + String flagJson = serialize(flag), segmentJson = serialize(segment); + + String validFlagInput = "{\"path\":\"/flags/flag1\", \"data\":" + flagJson + "}"; + PatchData validFlagResult = parsePatchData(jsonReaderFrom(validFlagInput)); + assertThat(validFlagResult.kind, equalTo(FEATURES)); + assertThat(validFlagResult.key, equalTo(flag.getKey())); + assertItemEquals(flag, validFlagResult.item); + + String validSegmentInput = "{\"path\":\"/segments/segment1\", \"data\":" + segmentJson + "}"; + PatchData validSegmentResult = parsePatchData(jsonReaderFrom(validSegmentInput)); + assertThat(validSegmentResult.kind, equalTo(SEGMENTS)); + assertThat(validSegmentResult.key, equalTo(segment.getKey())); + assertItemEquals(segment, validSegmentResult.item); + + String validFlagInputWithDataBeforePath = "{\"data\":" + flagJson + ",\"path\":\"/flags/flag1\"}"; + PatchData validFlagResultWithDataBeforePath = parsePatchData( + jsonReaderFrom(validFlagInputWithDataBeforePath)); + assertThat(validFlagResultWithDataBeforePath.kind, equalTo(FEATURES)); + assertThat(validFlagResultWithDataBeforePath.key, equalTo(flag.getKey())); + assertItemEquals(flag, validFlagResultWithDataBeforePath.item); + + String validSegmentInputWithDataBeforePath = "{\"data\":" + segmentJson + ",\"path\":\"/segments/segment1\"}"; + PatchData validSegmentResultWithDataBeforePath = parsePatchData( + jsonReaderFrom(validSegmentInputWithDataBeforePath)); + assertThat(validSegmentResultWithDataBeforePath.kind, equalTo(SEGMENTS)); + assertThat(validSegmentResultWithDataBeforePath.key, equalTo(segment.getKey())); + assertItemEquals(segment, validSegmentResultWithDataBeforePath.item); + + String inputWithUnrecognizedPath = "{\"path\":\"/cats/lucy\", \"data\":" + flagJson + "}"; + PatchData resultWithUnrecognizedPath = parsePatchData( + jsonReaderFrom(inputWithUnrecognizedPath)); + assertThat(resultWithUnrecognizedPath.kind, nullValue()); + + String inputWithMissingPath = "{\"data\":" + flagJson + "}"; + assertThrows(JsonParseException.class, + () -> parsePatchData(jsonReaderFrom(inputWithMissingPath))); + + String inputWithMissingData = "{\"path\":\"/flags/flag1\"}"; + assertThrows(JsonParseException.class, + () -> parsePatchData(jsonReaderFrom(inputWithMissingData))); + } + + @Test + public void parsingDeleteData() { + String validFlagInput = "{\"path\":\"/flags/flag1\", \"version\": 3}"; + DeleteData validFlagResult = parseDeleteData(jsonReaderFrom(validFlagInput)); + assertThat(validFlagResult.kind, equalTo(FEATURES)); + assertThat(validFlagResult.key, equalTo("flag1")); + assertThat(validFlagResult.version, equalTo(3)); + + String validSegmentInput = "{\"path\":\"/segments/segment1\", \"version\": 4}"; + DeleteData validSegmentResult = parseDeleteData(jsonReaderFrom(validSegmentInput)); + assertThat(validSegmentResult.kind, equalTo(SEGMENTS)); + assertThat(validSegmentResult.key, equalTo("segment1")); + assertThat(validSegmentResult.version, equalTo(4)); + + String inputWithUnrecognizedPath = "{\"path\":\"/cats/macavity\", \"version\": 9}"; + DeleteData resultWithUnrecognizedPath = parseDeleteData(jsonReaderFrom(inputWithUnrecognizedPath)); + assertThat(resultWithUnrecognizedPath.kind, nullValue()); + + String inputWithMissingPath = "{\"version\": 1}"; + assertThrows(JsonParseException.class, + () -> parseDeleteData(jsonReaderFrom(inputWithMissingPath))); + + String inputWithMissingVersion = "{\"path\": \"/flags/flag1\"}"; + assertThrows(JsonParseException.class, + () -> parseDeleteData(jsonReaderFrom(inputWithMissingVersion))); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/TestUtil.java b/src/test/java/com/launchdarkly/sdk/server/TestUtil.java index 7e3a0ba91..37eaea5ef 100644 --- a/src/test/java/com/launchdarkly/sdk/server/TestUtil.java +++ b/src/test/java/com/launchdarkly/sdk/server/TestUtil.java @@ -4,10 +4,12 @@ import com.google.common.collect.ImmutableSet; import com.google.common.collect.Iterables; import com.google.gson.Gson; +import com.google.gson.stream.JsonReader; import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.server.DataModel.FeatureFlag; import com.launchdarkly.sdk.server.DataModel.Segment; +import com.launchdarkly.sdk.server.DataModel.VersionedData; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; @@ -17,6 +19,7 @@ import com.launchdarkly.testhelpers.Assertions; import java.io.IOException; +import java.io.StringReader; import java.net.InetAddress; import java.net.InetSocketAddress; import java.net.Socket; @@ -35,6 +38,7 @@ import static com.launchdarkly.sdk.server.DataModel.FEATURES; import static com.launchdarkly.sdk.server.DataModel.SEGMENTS; import static com.launchdarkly.sdk.server.DataStoreTestTypes.toDataMap; +import static com.launchdarkly.sdk.server.JsonHelpers.serialize; import static com.launchdarkly.testhelpers.ConcurrentHelpers.assertNoMoreValues; import static com.launchdarkly.testhelpers.ConcurrentHelpers.awaitValue; import static com.launchdarkly.testhelpers.JsonAssertions.assertJsonEquals; @@ -42,6 +46,7 @@ import static org.hamcrest.Matchers.equalTo; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.fail; @SuppressWarnings("javadoc") public class TestUtil { @@ -108,6 +113,12 @@ public static void assertDataSetEquals(FullDataSet expected, Ful assertJsonEquals(expectedJson, actualJson); } + public static void assertItemEquals(VersionedData expected, ItemDescriptor item) { + assertEquals(expected.getVersion(), item.getVersion()); + assertEquals(expected.getClass(), item.getItem().getClass()); + assertJsonEquals(serialize(expected), serialize(item.getItem())); + } + public static String describeDataSet(FullDataSet data) { return Joiner.on(", ").join( Iterables.transform(data.getData(), entry -> { @@ -122,6 +133,10 @@ public static String describeDataSet(FullDataSet data) { })); } + public static JsonReader jsonReaderFrom(String data) { + return new JsonReader(new StringReader(data)); + } + public static interface ActionCanThrowAnyException { void apply(T param) throws Exception; } @@ -213,6 +228,15 @@ public static void assertFullyUnequal(T a, T b) { assertNotEquals(b, a); } + public static void assertThrows(Class exceptionClass, Runnable r) { + try { + r.run(); + fail("expected exception"); + } catch (RuntimeException e) { + assertThat(e.getClass(), equalTo(exceptionClass)); + } + } + public interface BuilderPropertyTester { void assertDefault(TValue defaultValue); void assertCanSet(TValue newValue); From 2dfdaf773bbc309f32db16f9342c9daa13752650 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Sun, 26 Jun 2022 17:05:08 -0700 Subject: [PATCH 640/673] fix tests --- .../sdk/server/DataModelSerializationTest.java | 2 +- .../sdk/server/StreamProcessorEventsTest.java | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java b/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java index 9ab24a7e2..9f0741a99 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java @@ -81,7 +81,7 @@ public void deserializeInvalidSegmentFromParsedJson() { deserializeFromParsedJson(DataModel.SEGMENTS, element); } - @Test(expected=IllegalArgumentException.class) + @Test(expected=SerializationException.class) public void deserializeInvalidDataKindFromParsedJson() { String json = "{\"key\":\"something\",\"version\":1}"; JsonElement element = JsonHelpers.gsonInstance().fromJson(json, JsonElement.class); diff --git a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorEventsTest.java b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorEventsTest.java index 58118b596..f81d20b0f 100644 --- a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorEventsTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorEventsTest.java @@ -9,6 +9,7 @@ import com.launchdarkly.sdk.server.StreamProcessorEvents.PutData; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.SerializationException; import org.junit.Test; @@ -51,7 +52,7 @@ public void parsingPutData() throws Exception { assertDataSetEquals(expectedAllData, validResult.data); String inputWithoutData = "{\"path\":\"/\"}"; - assertThrows(JsonParseException.class, + assertThrows(SerializationException.class, () -> parsePutData(jsonReaderFrom(inputWithoutData))); } @@ -93,11 +94,11 @@ public void parsingPatchData() throws Exception { assertThat(resultWithUnrecognizedPath.kind, nullValue()); String inputWithMissingPath = "{\"data\":" + flagJson + "}"; - assertThrows(JsonParseException.class, + assertThrows(SerializationException.class, () -> parsePatchData(jsonReaderFrom(inputWithMissingPath))); String inputWithMissingData = "{\"path\":\"/flags/flag1\"}"; - assertThrows(JsonParseException.class, + assertThrows(SerializationException.class, () -> parsePatchData(jsonReaderFrom(inputWithMissingData))); } @@ -120,11 +121,11 @@ public void parsingDeleteData() { assertThat(resultWithUnrecognizedPath.kind, nullValue()); String inputWithMissingPath = "{\"version\": 1}"; - assertThrows(JsonParseException.class, + assertThrows(SerializationException.class, () -> parseDeleteData(jsonReaderFrom(inputWithMissingPath))); String inputWithMissingVersion = "{\"path\": \"/flags/flag1\"}"; - assertThrows(JsonParseException.class, + assertThrows(SerializationException.class, () -> parseDeleteData(jsonReaderFrom(inputWithMissingVersion))); } } From 00388db913cf8a41d62318edeb5e6083fd0c0ee0 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 27 Jun 2022 10:54:21 -0700 Subject: [PATCH 641/673] rm unused --- .../launchdarkly/sdk/server/JsonHelpers.java | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/JsonHelpers.java b/src/main/java/com/launchdarkly/sdk/server/JsonHelpers.java index e910617f8..73885d3c7 100644 --- a/src/main/java/com/launchdarkly/sdk/server/JsonHelpers.java +++ b/src/main/java/com/launchdarkly/sdk/server/JsonHelpers.java @@ -11,7 +11,6 @@ import com.launchdarkly.sdk.server.interfaces.SerializationException; import java.io.IOException; -import java.io.Reader; abstract class JsonHelpers { private JsonHelpers() {} @@ -69,14 +68,14 @@ static T deserialize(String json, Class objectClass) throws Serialization } } - static T deserialize(Reader reader, Class objectClass) throws SerializationException { - try { - return gsonInstance().fromJson(reader, objectClass); - } catch (Exception e) { - throw new SerializationException(e); - } - } - + /** + * Deserializes an object from a JSON stream. + * + * @param reader the JSON reader + * @param objectClass class of object to create + * @return the deserialized object + * @throws SerializationException if Gson throws an exception + */ static T deserialize(JsonReader reader, Class objectClass) throws SerializationException { try { return gsonInstance().fromJson(reader, objectClass); From 505b9da183ffa1e46f34b9f9e2ed438095fd59c4 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 27 Jun 2022 11:29:50 -0700 Subject: [PATCH 642/673] rm unused --- .../com/launchdarkly/sdk/server/StreamProcessorEventsTest.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorEventsTest.java b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorEventsTest.java index f81d20b0f..45c8e9f90 100644 --- a/src/test/java/com/launchdarkly/sdk/server/StreamProcessorEventsTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/StreamProcessorEventsTest.java @@ -1,6 +1,5 @@ package com.launchdarkly.sdk.server; -import com.google.gson.JsonParseException; import com.launchdarkly.sdk.server.DataModel.FeatureFlag; import com.launchdarkly.sdk.server.DataModel.Segment; import com.launchdarkly.sdk.server.DataStoreTestTypes.DataBuilder; From 7023c91b7436f1604de7fd9fce917763fb141332 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 28 Jun 2022 20:33:31 -0700 Subject: [PATCH 643/673] use okhttp-eventsource 2.6.0 --- build.gradle | 2 +- src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 58cdd7f30..34ae02c5a 100644 --- a/build.gradle +++ b/build.gradle @@ -74,7 +74,7 @@ ext.versions = [ "jackson": "2.11.2", "launchdarklyJavaSdkCommon": "1.3.0", "okhttp": "4.8.1", // specify this for the SDK build instead of relying on the transitive dependency from okhttp-eventsource - "okhttpEventsource": "2.6.0-SNAPSHOT", + "okhttpEventsource": "2.6.0", "slf4j": "1.7.21", "snakeyaml": "1.26", "jedis": "2.9.0" diff --git a/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java index 6558ecaa1..0602ca819 100644 --- a/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/StreamProcessor.java @@ -191,6 +191,7 @@ public Future start() { .threadPriority(threadPriority) .readBufferSize(5000) .streamEventData(true) + .expectFields("event") .loggerBaseName(Loggers.DATA_SOURCE_LOGGER_NAME) .clientBuilderActions(new EventSource.Builder.ClientConfigurer() { public void configure(OkHttpClient.Builder builder) { From 9d9f1a46ee3bbe67d5c2fdc5a04b356e5d58518e Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 29 Jun 2022 13:42:04 -0700 Subject: [PATCH 644/673] update eventsource to 2.6.1 to fix pom/manifest problem --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 34ae02c5a..4e36496a0 100644 --- a/build.gradle +++ b/build.gradle @@ -74,7 +74,7 @@ ext.versions = [ "jackson": "2.11.2", "launchdarklyJavaSdkCommon": "1.3.0", "okhttp": "4.8.1", // specify this for the SDK build instead of relying on the transitive dependency from okhttp-eventsource - "okhttpEventsource": "2.6.0", + "okhttpEventsource": "2.6.1", "slf4j": "1.7.21", "snakeyaml": "1.26", "jedis": "2.9.0" From dff98fdbbcd41cbe596705ebfc1a2ddcccfdf6d6 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 30 Jun 2022 15:37:50 -0700 Subject: [PATCH 645/673] increase efficiency of summary event data structures (#335) --- .../sdk/server/DefaultEventProcessor.java | 5 +- .../sdk/server/EventOutputFormatter.java | 70 +++---- .../sdk/server/EventSummarizer.java | 198 ++++++++++++++---- .../sdk/server/EventSummarizerTest.java | 133 ++++++------ 4 files changed, 264 insertions(+), 142 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java index 854de0240..42ef720cc 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/sdk/server/DefaultEventProcessor.java @@ -460,6 +460,7 @@ private void triggerFlush(EventBuffer outbox, BlockingQueue payloa } 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 + outbox.summarizer.restoreTo(payload.summary); synchronized(busyFlushWorkersCount) { busyFlushWorkersCount.decrementAndGet(); busyFlushWorkersCount.notify(); @@ -506,7 +507,7 @@ void addToSummary(Event e) { } boolean isEmpty() { - return events.isEmpty() && summarizer.snapshot().isEmpty(); + return events.isEmpty() && summarizer.isEmpty(); } long getAndClearDroppedCount() { @@ -517,7 +518,7 @@ long getAndClearDroppedCount() { FlushPayload getPayload() { Event[] eventsOut = events.toArray(new Event[events.size()]); - EventSummarizer.EventSummary summary = summarizer.snapshot(); + EventSummarizer.EventSummary summary = summarizer.getSummaryAndReset(); return new FlushPayload(eventsOut, summary); } diff --git a/src/main/java/com/launchdarkly/sdk/server/EventOutputFormatter.java b/src/main/java/com/launchdarkly/sdk/server/EventOutputFormatter.java index afd74e16f..85f8c3b9d 100644 --- a/src/main/java/com/launchdarkly/sdk/server/EventOutputFormatter.java +++ b/src/main/java/com/launchdarkly/sdk/server/EventOutputFormatter.java @@ -5,12 +5,14 @@ import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; -import com.launchdarkly.sdk.server.EventSummarizer.CounterKey; import com.launchdarkly.sdk.server.EventSummarizer.CounterValue; +import com.launchdarkly.sdk.server.EventSummarizer.FlagInfo; +import com.launchdarkly.sdk.server.EventSummarizer.SimpleIntKeyedMap; import com.launchdarkly.sdk.server.interfaces.Event; import java.io.IOException; import java.io.Writer; +import java.util.Map; /** * Transforms analytics events and summary data into the JSON format that we send to LaunchDarkly. @@ -28,6 +30,7 @@ final class EventOutputFormatter { this.gson = JsonHelpers.gsonInstanceForEventsSerialization(config); } + @SuppressWarnings("resource") final int writeOutputEvents(Event[] events, EventSummarizer.EventSummary summary, Writer writer) throws IOException { int count = events.length; try (JsonWriter jsonWriter = new JsonWriter(writer)) { @@ -112,59 +115,48 @@ private final void writeSummaryEvent(EventSummarizer.EventSummary summary, JsonW 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); + for (Map.Entry flag: summary.counters.entrySet()) { + String flagKey = flag.getKey(); + FlagInfo flagInfo = flag.getValue(); jw.name(flagKey); jw.beginObject(); - writeLDValue("default", firstValue.defaultVal, jw); + writeLDValue("default", flagInfo.defaultVal, jw); jw.name("counters"); jw.beginArray(); - for (int j = i; j < unprocessedKeys.length; j++) { - CounterKey keyForThisFlag = unprocessedKeys[j]; - if (j != i && (keyForThisFlag == null || !keyForThisFlag.key.equals(flagKey))) { - continue; - } - CounterValue value = keyForThisFlag == key ? firstValue : summary.counters.get(keyForThisFlag); - unprocessedKeys[j] = null; - - jw.beginObject(); - - if (keyForThisFlag.variation >= 0) { - jw.name("variation"); - jw.value(keyForThisFlag.variation); + for (int i = 0; i < flagInfo.versionsAndVariations.size(); i++) { + int version = flagInfo.versionsAndVariations.keyAt(i); + SimpleIntKeyedMap variations = flagInfo.versionsAndVariations.valueAt(i); + for (int j = 0; j < variations.size(); j++) { + int variation = variations.keyAt(j); + CounterValue counter = variations.valueAt(j); + + jw.beginObject(); + + if (variation >= 0) { + jw.name("variation").value(variation); + } + if (version >= 0) { + jw.name("version").value(version); + } else { + jw.name("unknown").value(true); + } + writeLDValue("value", counter.flagValue, jw); + jw.name("count").value(counter.count); + + jw.endObject(); } - if (keyForThisFlag.version >= 0) { - 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(); + jw.endObject(); // end of summary event object } private final void startEvent(Event event, String kind, String key, JsonWriter jw) throws IOException { diff --git a/src/main/java/com/launchdarkly/sdk/server/EventSummarizer.java b/src/main/java/com/launchdarkly/sdk/server/EventSummarizer.java index 3f6f28b44..0aaf1276e 100644 --- a/src/main/java/com/launchdarkly/sdk/server/EventSummarizer.java +++ b/src/main/java/com/launchdarkly/sdk/server/EventSummarizer.java @@ -24,30 +24,50 @@ final class EventSummarizer { * @param event an event */ void summarizeEvent(Event event) { - if (event instanceof Event.FeatureRequest) { - Event.FeatureRequest fe = (Event.FeatureRequest)event; - eventsState.incrementCounter(fe.getKey(), fe.getVariation(), fe.getVersion(), fe.getValue(), fe.getDefaultVal()); - eventsState.noteTimestamp(fe.getCreationDate()); + if (!(event instanceof Event.FeatureRequest)) { + return; } + Event.FeatureRequest fe = (Event.FeatureRequest)event; + eventsState.incrementCounter(fe.getKey(), fe.getVariation(), fe.getVersion(), fe.getValue(), fe.getDefaultVal()); + eventsState.noteTimestamp(fe.getCreationDate()); } /** - * Returns a snapshot of the current summarized event data. + * Gets the current summarized event data, and resets the EventSummarizer's state to contain + * a new empty EventSummary. + * * @return the summary state */ - EventSummary snapshot() { - return new EventSummary(eventsState); + EventSummary getSummaryAndReset() { + EventSummary ret = eventsState; + clear(); + return ret; } /** - * Resets the summary counters. + * Indicates that we decided not to send the summary values returned by {@link #getSummaryAndReset()}, + * and instead we should return to using the previous state object and keep accumulating data + * in it. + */ + void restoreTo(EventSummary previousState) { + eventsState = previousState; + } + + /** + * Returns true if there is no summary data in the current state. + * + * @return true if the state is empty */ + boolean isEmpty() { + return eventsState.isEmpty(); + } + void clear() { eventsState = new EventSummary(); } static final class EventSummary { - final Map counters; + final Map counters; long startDate; long endDate; @@ -66,13 +86,23 @@ boolean isEmpty() { } void incrementCounter(String flagKey, int variation, int version, LDValue flagValue, LDValue defaultVal) { - CounterKey key = new CounterKey(flagKey, variation, version); - - CounterValue value = counters.get(key); - if (value != null) { - value.increment(); + FlagInfo flagInfo = counters.get(flagKey); + if (flagInfo == null) { + flagInfo = new FlagInfo(defaultVal, new SimpleIntKeyedMap<>()); + counters.put(flagKey, flagInfo); + } + + SimpleIntKeyedMap variations = flagInfo.versionsAndVariations.get(version); + if (variations == null) { + variations = new SimpleIntKeyedMap<>(); + flagInfo.versionsAndVariations.put(version, variations); + } + + CounterValue value = variations.get(variation); + if (value == null) { + variations.put(variation, new CounterValue(1, flagValue)); } else { - counters.put(key, new CounterValue(1, flagValue, defaultVal)); + value.increment(); } } @@ -103,47 +133,42 @@ public int hashCode() { } } - static final class CounterKey { - final String key; - final int variation; - final int version; + static final class FlagInfo { + final LDValue defaultVal; + final SimpleIntKeyedMap> versionsAndVariations; - CounterKey(String key, int variation, int version) { - this.key = key; - this.variation = variation; - this.version = version; + FlagInfo(LDValue defaultVal, SimpleIntKeyedMap> versionsAndVariations) { + this.defaultVal = defaultVal; + this.versionsAndVariations = versionsAndVariations; } @Override public boolean equals(Object other) { - if (other instanceof CounterKey) { - CounterKey o = (CounterKey)other; - return o.key.equals(this.key) && o.variation == this.variation && - o.version == this.version; + if (other instanceof FlagInfo) { + FlagInfo o = (FlagInfo)other; + return o.defaultVal.equals(this.defaultVal) && o.versionsAndVariations.equals(this.versionsAndVariations); } return false; } @Override public int hashCode() { - return key.hashCode() + 31 * (variation + 31 * version); + return this.defaultVal.hashCode() + 31 * versionsAndVariations.hashCode(); } @Override - public String toString() { - return "(" + key + "," + variation + "," + version + ")"; + public String toString() { // used only in tests + return "(default=" + defaultVal + ", counters=" + versionsAndVariations + ")"; } } static final class CounterValue { long count; final LDValue flagValue; - final LDValue defaultVal; - CounterValue(long count, LDValue flagValue, LDValue defaultVal) { + CounterValue(long count, LDValue flagValue) { this.count = count; this.flagValue = flagValue; - this.defaultVal = defaultVal; } void increment() { @@ -155,15 +180,114 @@ public boolean equals(Object other) { if (other instanceof CounterValue) { CounterValue o = (CounterValue)other; - return count == o.count && Objects.equals(flagValue, o.flagValue) && - Objects.equals(defaultVal, o.defaultVal); + return count == o.count && Objects.equals(flagValue, o.flagValue); } return false; } @Override - public String toString() { - return "(" + count + "," + flagValue + "," + defaultVal + ")"; + public String toString() { // used only in tests + return "(" + count + "," + flagValue + ")"; + } + } + + // A very simple array-backed structure with map-like semantics for primitive int keys. This + // is highly specialized for the EventSummarizer use case (which is why it is an inner class + // of EventSummarizer, to emphasize that it should not be used elsewhere). It makes the + // following assumptions: + // - The number of keys will almost always be small: most flags have only a few variations, + // and most flags will have only one version or a few versions during the lifetime of an + // event payload. Therefore, we use simple iteration and int comparisons for the keys; the + // overhead of this is likely less than the overhead of maintaining a hashtable and creating + // objects for its keys and iterators. + // - Data will never be deleted from the map after being added (the summarizer simply makes + // a new map when it's time to start over). + static final class SimpleIntKeyedMap { + private static final int INITIAL_CAPACITY = 4; + + private int[] keys; + private Object[] values; + private int n; + + SimpleIntKeyedMap() { + keys = new int[INITIAL_CAPACITY]; + values = new Object[INITIAL_CAPACITY]; + } + + int size() { + return n; + } + + int capacity() { + return keys.length; + } + + int keyAt(int index) { + return keys[index]; + } + + @SuppressWarnings("unchecked") + T valueAt(int index) { + return (T)values[index]; + } + + @SuppressWarnings("unchecked") + T get(int key) { + for (int i = 0; i < n; i++) { + if (keys[i] == key) { + return (T)values[i]; + } + } + return null; + } + + SimpleIntKeyedMap put(int key, T value) { + for (int i = 0; i < n; i++) { + if (keys[i] == key) { + values[i] = value; + return this; + } + } + if (n == keys.length) { + int[] newKeys = new int[keys.length * 2]; + System.arraycopy(keys, 0, newKeys, 0, n); + Object[] newValues = new Object[keys.length * 2]; + System.arraycopy(values, 0, newValues, 0, n); + keys = newKeys; + values = newValues; + } + keys[n] = key; + values[n] = value; + n++; + return this; + } + + @SuppressWarnings("unchecked") + @Override + public boolean equals(Object o) { // used only in tests + if (o instanceof SimpleIntKeyedMap) { + SimpleIntKeyedMap other = (SimpleIntKeyedMap)o; + if (this.n == other.n) { + for (int i = 0; i < n; i++) { + T value1 = (T)values[i], value2 = other.get(keys[i]); + if (!Objects.equals(value1, value2)) { + return false; + } + } + return true; + } + } + return false; + } + + @Override + public String toString() { // used only in tests + StringBuilder s = new StringBuilder("{"); + for (int i = 0; i < n; i++) { + s.append(keys[i]).append("=").append(values[i] == null ? "null" : values[i].toString()); + } + s.append("}"); + return s.toString(); } } } diff --git a/src/test/java/com/launchdarkly/sdk/server/EventSummarizerTest.java b/src/test/java/com/launchdarkly/sdk/server/EventSummarizerTest.java index f65e4c3e5..f3d2af2e6 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EventSummarizerTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EventSummarizerTest.java @@ -1,18 +1,17 @@ package com.launchdarkly.sdk.server; +import com.google.common.collect.ImmutableMap; import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; -import com.launchdarkly.sdk.server.EventSummarizer.CounterKey; import com.launchdarkly.sdk.server.EventSummarizer.CounterValue; import com.launchdarkly.sdk.server.EventSummarizer.EventSummary; +import com.launchdarkly.sdk.server.EventSummarizer.FlagInfo; +import com.launchdarkly.sdk.server.EventSummarizer.SimpleIntKeyedMap; import com.launchdarkly.sdk.server.interfaces.Event; import org.junit.Test; -import java.util.HashMap; -import java.util.Map; - import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; import static com.launchdarkly.sdk.server.TestUtil.simpleEvaluation; import static org.hamcrest.MatcherAssert.assertThat; @@ -20,6 +19,7 @@ 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.assertTrue; @SuppressWarnings("javadoc") @@ -32,35 +32,31 @@ public class EventSummarizerTest { @Test public void summarizerCanBeCleared() { EventSummarizer es = new EventSummarizer(); - assertTrue(es.snapshot().isEmpty()); + assertTrue(es.isEmpty()); DataModel.FeatureFlag flag = flagBuilder("key").build(); Event event = eventFactory.newFeatureRequestEvent(flag, user, null, null); es.summarizeEvent(event); - assertFalse(es.snapshot().isEmpty()); + assertFalse(es.isEmpty()); es.clear(); - assertTrue(es.snapshot().isEmpty()); + assertTrue(es.isEmpty()); } @Test public void summarizeEventDoesNothingForIdentifyEvent() { EventSummarizer es = new EventSummarizer(); - EventSummarizer.EventSummary snapshot = es.snapshot(); es.summarizeEvent(eventFactory.newIdentifyEvent(user)); - - assertEquals(snapshot, es.snapshot()); + assertTrue(es.isEmpty()); } @Test public void summarizeEventDoesNothingForCustomEvent() { EventSummarizer es = new EventSummarizer(); - EventSummarizer.EventSummary snapshot = es.snapshot(); es.summarizeEvent(eventFactory.newCustomEvent("whatever", user, null, null)); - - assertEquals(snapshot, es.snapshot()); + assertTrue(es.isEmpty()); } @Test @@ -76,7 +72,7 @@ public void summarizeEventSetsStartAndEndDates() { es.summarizeEvent(event1); es.summarizeEvent(event2); es.summarizeEvent(event3); - EventSummarizer.EventSummary data = es.snapshot(); + EventSummarizer.EventSummary data = es.getSummaryAndReset(); assertEquals(1000, data.startDate); assertEquals(2000, data.endDate); @@ -88,75 +84,56 @@ public void summarizeEventIncrementsCounters() { DataModel.FeatureFlag flag1 = flagBuilder("key1").version(11).build(); DataModel.FeatureFlag flag2 = flagBuilder("key2").version(22).build(); String unknownFlagKey = "badkey"; + LDValue value1 = LDValue.of("value1"), value2 = LDValue.of("value2"), value99 = LDValue.of("value99"), + default1 = LDValue.of("default1"), default2 = LDValue.of("default2"), default3 = LDValue.of("default3"); Event event1 = eventFactory.newFeatureRequestEvent(flag1, user, - simpleEvaluation(1, LDValue.of("value1")), LDValue.of("default1")); + simpleEvaluation(1, value1), default1); Event event2 = eventFactory.newFeatureRequestEvent(flag1, user, - simpleEvaluation(2, LDValue.of("value2")), LDValue.of("default1")); + simpleEvaluation(2, value2), default1); Event event3 = eventFactory.newFeatureRequestEvent(flag2, user, - simpleEvaluation(1, LDValue.of("value99")), LDValue.of("default2")); + simpleEvaluation(1, value99), default2); Event event4 = eventFactory.newFeatureRequestEvent(flag1, user, - simpleEvaluation(1, LDValue.of("value1")), LDValue.of("default1")); - Event event5 = eventFactory.newUnknownFeatureRequestEvent(unknownFlagKey, user, LDValue.of("default3"), EvaluationReason.ErrorKind.FLAG_NOT_FOUND); + simpleEvaluation(1, value1), default1); + Event event5 = eventFactory.newUnknownFeatureRequestEvent(unknownFlagKey, user, default3, EvaluationReason.ErrorKind.FLAG_NOT_FOUND); es.summarizeEvent(event1); es.summarizeEvent(event2); es.summarizeEvent(event3); es.summarizeEvent(event4); es.summarizeEvent(event5); - EventSummarizer.EventSummary data = es.snapshot(); - - Map expected = new HashMap<>(); - expected.put(new EventSummarizer.CounterKey(flag1.getKey(), 1, flag1.getVersion()), - new EventSummarizer.CounterValue(2, LDValue.of("value1"), LDValue.of("default1"))); - expected.put(new EventSummarizer.CounterKey(flag1.getKey(), 2, flag1.getVersion()), - new EventSummarizer.CounterValue(1, LDValue.of("value2"), LDValue.of("default1"))); - expected.put(new EventSummarizer.CounterKey(flag2.getKey(), 1, flag2.getVersion()), - new EventSummarizer.CounterValue(1, LDValue.of("value99"), LDValue.of("default2"))); - expected.put(new EventSummarizer.CounterKey(unknownFlagKey, -1, -1), - new EventSummarizer.CounterValue(1, LDValue.of("default3"), LDValue.of("default3"))); - assertThat(data.counters, equalTo(expected)); - } - - @Test - public void counterKeyEquality() { - // This must be correct in order for CounterKey to be used as a map key. - CounterKey key1 = new CounterKey("a", 1, 10); - CounterKey key2 = new CounterKey("a", 1, 10); - assertEquals(key1, key2); - assertEquals(key2, key1); - assertEquals(key1.hashCode(), key2.hashCode()); - - for (CounterKey notEqualValue: new CounterKey[] { - new CounterKey("b", 1, 10), - new CounterKey("a", 2, 10), - new CounterKey("a", 1, 11) - }) { - assertNotEquals(key1, notEqualValue); - assertNotEquals(notEqualValue, key1); - assertNotEquals(key1.hashCode(), notEqualValue.hashCode()); - } + EventSummarizer.EventSummary data = es.getSummaryAndReset(); - assertNotEquals(key1, null); - assertNotEquals(key1, "x"); + assertThat(data.counters, equalTo(ImmutableMap.builder() + .put(flag1.getKey(), new FlagInfo(default1, + new SimpleIntKeyedMap>() + .put(flag1.getVersion(), new SimpleIntKeyedMap() + .put(1, new CounterValue(2, value1)) + .put(2, new CounterValue(1, value2)) + ))) + .put(flag2.getKey(), new FlagInfo(default2, + new SimpleIntKeyedMap>() + .put(flag2.getVersion(), new SimpleIntKeyedMap() + .put(1, new CounterValue(1, value99)) + ))) + .put(unknownFlagKey, new FlagInfo(default3, + new SimpleIntKeyedMap>() + .put(-1, new SimpleIntKeyedMap() + .put(-1, new CounterValue(1, default3)) + ))) + .build())); } // The following implementations are used only in debug/test code, but may as well test them - @Test - public void counterKeyToString() { - assertEquals("(a,1,10)", new CounterKey("a", 1, 10).toString()); - } - @Test public void counterValueEquality() { - CounterValue value1 = new CounterValue(1, LDValue.of("a"), LDValue.of("d")); - CounterValue value2 = new CounterValue(1, LDValue.of("a"), LDValue.of("d")); + CounterValue value1 = new CounterValue(1, LDValue.of("a")); + CounterValue value2 = new CounterValue(1, LDValue.of("a")); assertEquals(value1, value2); assertEquals(value2, value1); for (CounterValue notEqualValue: new CounterValue[] { - new CounterValue(2, LDValue.of("a"), LDValue.of("d")), - new CounterValue(1, LDValue.of("b"), LDValue.of("d")), - new CounterValue(1, LDValue.of("a"), LDValue.of("e")) + new CounterValue(2, LDValue.of("a")), + new CounterValue(1, LDValue.of("b")) }) { assertNotEquals(value1, notEqualValue); assertNotEquals(notEqualValue, value1); @@ -168,7 +145,7 @@ public void counterValueEquality() { @Test public void counterValueToString() { - assertEquals("(1,\"a\",\"d\")", new CounterValue(1, LDValue.of("a"), LDValue.of("d")).toString()); + assertEquals("(1,\"a\")", new CounterValue(1, LDValue.of("a")).toString()); } @Test @@ -220,4 +197,32 @@ public void eventSummaryEquality() { assertNotEquals(es1, null); assertNotEquals(es1, "x"); } + + @Test + public void simpleIntKeyedMapBehavior() { + // Tests the behavior of the inner class that we use instead of a Map. + SimpleIntKeyedMap m = new SimpleIntKeyedMap<>(); + int initialCapacity = m.capacity(); + + assertEquals(0, m.size()); + assertNotEquals(0, initialCapacity); + assertNull(m.get(1)); + + for (int i = 0; i < initialCapacity; i++) { + m.put(i * 100, "value" + i); + } + + assertEquals(initialCapacity, m.size()); + assertEquals(initialCapacity, m.capacity()); + + for (int i = 0; i < initialCapacity; i++) { + assertEquals("value" + i, m.get(i * 100)); + } + assertNull(m.get(33)); + + m.put(33, "other"); + assertNotEquals(initialCapacity, m.capacity()); + assertEquals(initialCapacity + 1, m.size()); + assertEquals("other", m.get(33)); + } } From fadc01da22556ce9021d3068b295081aeda25efb Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 6 Jul 2022 18:31:27 -0700 Subject: [PATCH 646/673] make reusable EvaluationDetail instances as part of flag preprocessing (#336) --- .../launchdarkly/sdk/server/DataModel.java | 79 ++--- .../sdk/server/DataModelPreprocessing.java | 293 ++++++++++++++++++ .../launchdarkly/sdk/server/Evaluator.java | 125 +++++--- .../sdk/server/EvaluatorHelpers.java | 22 ++ .../sdk/server/EvaluatorOperators.java | 7 +- .../sdk/server/EvaluatorPreprocessing.java | 162 ---------- .../sdk/server/FeatureFlagsState.java | 2 +- .../com/launchdarkly/sdk/server/LDClient.java | 6 +- ...t.java => DataModelPreprocessingTest.java} | 159 +++++++++- .../EvaluatorOperatorsParameterizedTest.java | 2 +- .../sdk/server/EvaluatorRuleTest.java | 14 +- .../sdk/server/EvaluatorTest.java | 2 +- .../server/FlagModelDeserializationTest.java | 61 ++-- .../sdk/server/ModelBuilders.java | 9 + 14 files changed, 646 insertions(+), 297 deletions(-) create mode 100644 src/main/java/com/launchdarkly/sdk/server/DataModelPreprocessing.java create mode 100644 src/main/java/com/launchdarkly/sdk/server/EvaluatorHelpers.java delete mode 100644 src/main/java/com/launchdarkly/sdk/server/EvaluatorPreprocessing.java rename src/test/java/com/launchdarkly/sdk/server/{EvaluatorPreprocessingTest.java => DataModelPreprocessingTest.java} (52%) diff --git a/src/main/java/com/launchdarkly/sdk/server/DataModel.java b/src/main/java/com/launchdarkly/sdk/server/DataModel.java index 37ac88987..fb32db264 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DataModel.java +++ b/src/main/java/com/launchdarkly/sdk/server/DataModel.java @@ -2,9 +2,13 @@ import com.google.common.collect.ImmutableList; import com.google.gson.annotations.JsonAdapter; -import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.server.DataModelPreprocessing.ClausePreprocessed; +import com.launchdarkly.sdk.server.DataModelPreprocessing.FlagPreprocessed; +import com.launchdarkly.sdk.server.DataModelPreprocessing.FlagRulePreprocessed; +import com.launchdarkly.sdk.server.DataModelPreprocessing.PrerequisitePreprocessed; +import com.launchdarkly.sdk.server.DataModelPreprocessing.TargetPreprocessed; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.DataKind; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; @@ -15,6 +19,35 @@ import static java.util.Collections.emptyList; import static java.util.Collections.emptySet; +// IMPLEMENTATION NOTES: +// +// - FeatureFlag, Segment, and all other data model classes contained within them, must be package-private. +// We don't want application code to see these types, because we need to be free to change their details without +// breaking the application. +// +// - We expose our DataKind instances publicly because application code may need to reference them if it is +// implementing a custom component such as a data store. But beyond the mere fact of there being these kinds of +// data, applications should not be considered with their structure. +// +// - For all classes that can be deserialized from JSON, there must be an empty constructor, and the fields +// cannot be final. This is because of how Gson works: it creates an instance first, then sets the fields. If +// we are able to move away from using Gson reflective deserialization in the future, we can make them final. +// +// - There should also be a constructor that takes all the fields; we should use that whenever we need to +// create these objects programmatically (so that if we are able at some point to make the fields final, that +// won't break anything). +// +// - For properties that have a collection type such as List, the getter method should always include a null +// guard and return an empty collection if the field is null (so that we don't have to worry about null guards +// every time we might want to iterate over these collections). Semantically there is no difference in the data +// model between an empty list and a null list, and in some languages (particularly Go) it is easy for an +// uninitialized list to be serialized to JSON as null. +// +// - Some classes have a "preprocessed" field containing types defined in DataModelPreprocessing. These fields +// must always be marked transient, so Gson will not serialize them. They are populated when we deserialize a +// FeatureFlag or Segment, because those types implement JsonHelpers.PostProcessingDeserializable (the +// afterDeserialized() method). + /** * Contains information about the internal data model for feature flags and user segments. *

            @@ -104,6 +137,8 @@ static final class FeatureFlag implements VersionedData, JsonHelpers.PostProcess private Long debugEventsUntilDate; private boolean deleted; + transient FlagPreprocessed preprocessed; + // We need this so Gson doesn't complain in certain java environments that restrict unsafe allocation FeatureFlag() {} @@ -191,9 +226,8 @@ boolean isClientSide() { return clientSide; } - // Precompute some invariant values for improved efficiency during evaluations - called from JsonHelpers.PostProcessingDeserializableTypeAdapter public void afterDeserialized() { - EvaluatorPreprocessing.preprocessFlag(this); + DataModelPreprocessing.preprocessFlag(this); } } @@ -201,7 +235,7 @@ static final class Prerequisite { private String key; private int variation; - private transient EvaluationReason prerequisiteFailedReason; + transient PrerequisitePreprocessed preprocessed; Prerequisite() {} @@ -217,21 +251,14 @@ String getKey() { int getVariation() { return variation; } - - // This value is precomputed when we deserialize a FeatureFlag from JSON - EvaluationReason getPrerequisiteFailedReason() { - return prerequisiteFailedReason; - } - - void setPrerequisiteFailedReason(EvaluationReason prerequisiteFailedReason) { - this.prerequisiteFailedReason = prerequisiteFailedReason; - } } static final class Target { private Set values; private int variation; + transient TargetPreprocessed preprocessed; + Target() {} Target(Set values, int variation) { @@ -259,7 +286,7 @@ static final class Rule extends VariationOrRollout { private List clauses; private boolean trackEvents; - private transient EvaluationReason ruleMatchReason; + transient FlagRulePreprocessed preprocessed; Rule() { super(); @@ -284,15 +311,6 @@ List getClauses() { boolean isTrackEvents() { return trackEvents; } - - // This value is precomputed when we deserialize a FeatureFlag from JSON - EvaluationReason getRuleMatchReason() { - return ruleMatchReason; - } - - void setRuleMatchReason(EvaluationReason ruleMatchReason) { - this.ruleMatchReason = ruleMatchReason; - } } static final class Clause { @@ -301,9 +319,7 @@ static final class Clause { private List values; //interpreted as an OR of values private boolean negate; - // The following property is marked transient because it is not to be serialized or deserialized; - // it is (if necessary) precomputed in FeatureFlag.afterDeserialized() to speed up evaluations. - transient EvaluatorPreprocessing.ClauseExtra preprocessed; + transient ClausePreprocessed preprocessed; Clause() { } @@ -331,14 +347,6 @@ List getValues() { boolean isNegate() { return negate; } - - EvaluatorPreprocessing.ClauseExtra getPreprocessed() { - return preprocessed; - } - - void setPreprocessed(EvaluatorPreprocessing.ClauseExtra preprocessed) { - this.preprocessed = preprocessed; - } } static final class Rollout { @@ -508,9 +516,8 @@ public Integer getGeneration() { return generation; } - // Precompute some invariant values for improved efficiency during evaluations - called from JsonHelpers.PostProcessingDeserializableTypeAdapter public void afterDeserialized() { - EvaluatorPreprocessing.preprocessSegment(this); + DataModelPreprocessing.preprocessSegment(this); } } diff --git a/src/main/java/com/launchdarkly/sdk/server/DataModelPreprocessing.java b/src/main/java/com/launchdarkly/sdk/server/DataModelPreprocessing.java new file mode 100644 index 000000000..494f86513 --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/DataModelPreprocessing.java @@ -0,0 +1,293 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.EvaluationReason.ErrorKind; +import com.launchdarkly.sdk.server.DataModel.Clause; +import com.launchdarkly.sdk.server.DataModel.FeatureFlag; +import com.launchdarkly.sdk.server.DataModel.Operator; +import com.launchdarkly.sdk.server.DataModel.Prerequisite; +import com.launchdarkly.sdk.server.DataModel.Rule; +import com.launchdarkly.sdk.server.DataModel.Segment; +import com.launchdarkly.sdk.server.DataModel.SegmentRule; +import com.launchdarkly.sdk.server.DataModel.Target; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.function.Function; +import java.util.regex.Pattern; + +import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; +import static com.launchdarkly.sdk.server.EvaluatorHelpers.resultForVariation; + +/** + * Additional information that we attach to our data model to reduce the overhead of feature flag + * evaluations. The methods that create these objects are called by the afterDeserialized() methods + * of FeatureFlag and Segment, after those objects have been deserialized from JSON but before they + * have been made available to any other code (so these methods do not need to be thread-safe). + *

            + * If for some reason these methods have not been called before an evaluation happens, the evaluation + * logic must still be able to work without the precomputed data. + */ +abstract class DataModelPreprocessing { + private DataModelPreprocessing() {} + + static final class EvaluationDetailsForSingleVariation { + private final EvaluationDetail regularResult; + private final EvaluationDetail inExperimentResult; + + EvaluationDetailsForSingleVariation( + LDValue value, + int variationIndex, + EvaluationReason regularReason, + EvaluationReason inExperimentReason + ) { + this.regularResult = EvaluationDetail.fromValue(value, variationIndex, regularReason); + this.inExperimentResult = EvaluationDetail.fromValue(value, variationIndex, inExperimentReason); + } + + EvaluationDetail getResult(boolean inExperiment) { + return inExperiment ? inExperimentResult : regularResult; + } + } + + static final class EvaluationDetailFactoryMultiVariations { + private final ImmutableList variations; + + EvaluationDetailFactoryMultiVariations( + ImmutableList variations + ) { + this.variations = variations; + } + + EvaluationDetail forVariation(int index, boolean inExperiment) { + if (index < 0 || index >= variations.size()) { + return EvaluationDetail.error(ErrorKind.MALFORMED_FLAG, LDValue.ofNull()); + } + return variations.get(index).getResult(inExperiment); + } + } + + static final class FlagPreprocessed { + EvaluationDetail offResult; + EvaluationDetailFactoryMultiVariations fallthroughResults; + + FlagPreprocessed(EvaluationDetail offResult, + EvaluationDetailFactoryMultiVariations fallthroughResults) { + this.offResult = offResult; + this.fallthroughResults = fallthroughResults; + } + } + + static final class PrerequisitePreprocessed { + final EvaluationDetail prerequisiteFailedResult; + + PrerequisitePreprocessed(EvaluationDetail prerequisiteFailedResult) { + this.prerequisiteFailedResult = prerequisiteFailedResult; + } + } + + static final class TargetPreprocessed { + final EvaluationDetail targetMatchResult; + + TargetPreprocessed(EvaluationDetail targetMatchResult) { + this.targetMatchResult = targetMatchResult; + } + } + + static final class FlagRulePreprocessed { + final EvaluationDetailFactoryMultiVariations allPossibleResults; + + FlagRulePreprocessed( + EvaluationDetailFactoryMultiVariations allPossibleResults + ) { + this.allPossibleResults = allPossibleResults; + } + } + + static final class ClausePreprocessed { + final Set valuesSet; + final List valuesExtra; + + ClausePreprocessed(Set valuesSet, List valuesExtra) { + this.valuesSet = valuesSet; + this.valuesExtra = valuesExtra; + } + + static final class ValueData { + final Instant parsedDate; + final Pattern parsedRegex; + final SemanticVersion parsedSemVer; + + ValueData(Instant parsedDate, Pattern parsedRegex, SemanticVersion parsedSemVer) { + this.parsedDate = parsedDate; + this.parsedRegex = parsedRegex; + this.parsedSemVer = parsedSemVer; + } + } + } + + static void preprocessFlag(FeatureFlag f) { + f.preprocessed = new FlagPreprocessed( + precomputeSingleVariationResult(f, f.getOffVariation(), EvaluationReason.off()), + precomputeMultiVariationResults(f, EvaluationReason.fallthrough(false), + EvaluationReason.fallthrough(true)) + ); + + for (Prerequisite p: f.getPrerequisites()) { + preprocessPrerequisite(p, f); + } + for (Target t: f.getTargets()) { + preprocessTarget(t, f); + } + List rules = f.getRules(); + int n = rules.size(); + for (int i = 0; i < n; i++) { + preprocessFlagRule(rules.get(i), i, f); + } + preprocessValueList(f.getVariations()); + } + + static void preprocessSegment(Segment s) { + List rules = s.getRules(); + int n = rules.size(); + for (int i = 0; i < n; i++) { + preprocessSegmentRule(rules.get(i), i); + } + } + + static void preprocessPrerequisite(Prerequisite p, FeatureFlag f) { + // Precompute an immutable EvaluationDetail instance that will be used if the prerequisite fails. + // This behaves the same as an "off" result except for the reason. + EvaluationDetail failureResult = precomputeSingleVariationResult(f, + f.getOffVariation(), EvaluationReason.prerequisiteFailed(p.getKey())); + p.preprocessed = new PrerequisitePreprocessed(failureResult); + } + + static void preprocessTarget(Target t, FeatureFlag f) { + // Precompute an immutable EvaluationDetail instance that will be used if this target matches. + t.preprocessed = new TargetPreprocessed( + resultForVariation(t.getVariation(), f, EvaluationReason.targetMatch()) + ); + } + + static void preprocessFlagRule(Rule r, int ruleIndex, FeatureFlag f) { + EvaluationReason ruleMatchReason = EvaluationReason.ruleMatch(ruleIndex, r.getId(), false); + EvaluationReason ruleMatchReasonInExperiment = EvaluationReason.ruleMatch(ruleIndex, r.getId(), true); + r.preprocessed = new FlagRulePreprocessed(precomputeMultiVariationResults(f, + ruleMatchReason, ruleMatchReasonInExperiment)); + + for (Clause c: r.getClauses()) { + preprocessClause(c); + } + } + + static void preprocessSegmentRule(SegmentRule r, int ruleIndex) { + for (Clause c: r.getClauses()) { + preprocessClause(c); + } + } + + static void preprocessClause(Clause c) { + // If the clause values contain a null (which is valid in terms of the JSON schema, even if it + // can't ever produce a true result), Gson will give us an actual null. Change this to + // LDValue.ofNull() to avoid NPEs down the line. It's more efficient to do this just once at + // deserialization time than to do it in every clause match. + List values = c.getValues(); + preprocessValueList(values); + + Operator op = c.getOp(); + if (op == null) { + return; + } + switch (op) { + case in: + // This is a special case where the clause is testing for an exact match against any of the + // clause values. Converting the value list to a Set allows us to do a fast lookup instead of + // a linear search. We do not do this for other operators (or if there are fewer than two + // values) because the slight extra overhead of a Set is not worthwhile in those case. + if (values.size() > 1) { + c.preprocessed = new ClausePreprocessed(ImmutableSet.copyOf(values), null); + } + break; + case matches: + c.preprocessed = preprocessClauseValues(c.getValues(), v -> + new ClausePreprocessed.ValueData(null, EvaluatorTypeConversion.valueToRegex(v), null) + ); + break; + case after: + case before: + c.preprocessed = preprocessClauseValues(c.getValues(), v -> + new ClausePreprocessed.ValueData(EvaluatorTypeConversion.valueToDateTime(v), null, null) + ); + break; + case semVerEqual: + case semVerGreaterThan: + case semVerLessThan: + c.preprocessed = preprocessClauseValues(c.getValues(), v -> + new ClausePreprocessed.ValueData(null, null, EvaluatorTypeConversion.valueToSemVer(v)) + ); + break; + default: + break; + } + } + + static void preprocessValueList(List values) { + // If a list of values contains a null (which is valid in terms of the JSON schema, even if it + // isn't useful because the SDK considers this a non-value), Gson will give us an actual null. + // Change this to LDValue.ofNull() to avoid NPEs down the line. It's more efficient to do this + // just once at deserialization time than to do it in every clause match. + for (int i = 0; i < values.size(); i++) { + if (values.get(i) == null) { + values.set(i, LDValue.ofNull()); + } + } + } + + private static ClausePreprocessed preprocessClauseValues( + List values, + Function f + ) { + List valuesExtra = new ArrayList<>(values.size()); + for (LDValue v: values) { + valuesExtra.add(f.apply(v)); + } + return new ClausePreprocessed(null, valuesExtra); + } + + private static EvaluationDetail precomputeSingleVariationResult( + FeatureFlag f, + Integer index, + EvaluationReason reason + ) { + if (index == null) { + return EvaluationDetail.fromValue(LDValue.ofNull(), NO_VARIATION, reason); + } + int i = index.intValue(); + if (i < 0 || i >= f.getVariations().size()) { + return EvaluationDetail.fromValue(LDValue.ofNull(), NO_VARIATION, + EvaluationReason.error(ErrorKind.MALFORMED_FLAG)); + } + return EvaluationDetail.fromValue(f.getVariations().get(i), index, reason); + } + + private static EvaluationDetailFactoryMultiVariations precomputeMultiVariationResults( + FeatureFlag f, + EvaluationReason regularReason, + EvaluationReason inExperimentReason + ) { + ImmutableList.Builder builder = + ImmutableList.builderWithExpectedSize(f.getVariations().size()); + for (int i = 0; i < f.getVariations().size(); i++) { + builder.add(new EvaluationDetailsForSingleVariation(f.getVariations().get(i), i, + regularReason, inExperimentReason)); + } + return new EvaluationDetailFactoryMultiVariations(builder.build()); + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/Evaluator.java b/src/main/java/com/launchdarkly/sdk/server/Evaluator.java index d66818f0c..1547c4518 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Evaluator.java +++ b/src/main/java/com/launchdarkly/sdk/server/Evaluator.java @@ -3,11 +3,17 @@ import com.google.common.collect.ImmutableList; import com.launchdarkly.sdk.EvaluationDetail; import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.EvaluationReason.Kind; import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.LDValueType; -import com.launchdarkly.sdk.EvaluationReason.Kind; +import com.launchdarkly.sdk.server.DataModel.FeatureFlag; +import com.launchdarkly.sdk.server.DataModel.Rule; +import com.launchdarkly.sdk.server.DataModel.Target; +import com.launchdarkly.sdk.server.DataModel.VariationOrRollout; import com.launchdarkly.sdk.server.DataModel.WeightedVariation; +import com.launchdarkly.sdk.server.DataModelPreprocessing.ClausePreprocessed; +import com.launchdarkly.sdk.server.DataModelPreprocessing.EvaluationDetailFactoryMultiVariations; import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreTypes; import com.launchdarkly.sdk.server.interfaces.Event; @@ -19,6 +25,7 @@ import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; import static com.launchdarkly.sdk.server.EvaluatorBucketing.bucketUser; +import static com.launchdarkly.sdk.server.EvaluatorHelpers.resultForVariation; /** * Encapsulates the feature flag evaluation logic. The Evaluator has no knowledge of the rest of the SDK environment; @@ -63,43 +70,39 @@ static interface Getters { * replace null values with default values, */ static class EvalResult { - private LDValue value = LDValue.ofNull(); - private int variationIndex = NO_VARIATION; - private EvaluationReason reason = null; + private EvaluationDetail detail; private List prerequisiteEvents; - + + public EvalResult(EvaluationDetail detail) { + this.detail = detail; + } + public EvalResult(LDValue value, int variationIndex, EvaluationReason reason) { - this.value = value; - this.variationIndex = variationIndex; - this.reason = reason; + this.detail = EvaluationDetail.fromValue(LDValue.normalize(value), variationIndex, reason); } public static EvalResult error(EvaluationReason.ErrorKind errorKind) { return new EvalResult(LDValue.ofNull(), NO_VARIATION, EvaluationReason.error(errorKind)); } - LDValue getValue() { - return LDValue.normalize(value); + EvaluationDetail getDetails() { + return detail; } - void setValue(LDValue value) { - this.value = value; + void setDetails(EvaluationDetail detail) { + this.detail = detail; } - int getVariationIndex() { - return variationIndex; + LDValue getValue() { + return detail.getValue(); } - boolean isDefault() { - return variationIndex < 0; + int getVariationIndex() { + return detail.getVariationIndex(); } EvaluationReason getReason() { - return reason; - } - - EvaluationDetail getDetails() { - return EvaluationDetail.fromValue(LDValue.normalize(value), variationIndex, reason); + return detail.getReason(); } Iterable getPrerequisiteEvents() { @@ -109,10 +112,6 @@ Iterable getPrerequisiteEvents() { private void setPrerequisiteEvents(List prerequisiteEvents) { this.prerequisiteEvents = prerequisiteEvents; } - - private void setBigSegmentsStatus(EvaluationReason.BigSegmentsStatus bigSegmentsStatus) { - this.reason = this.reason.withBigSegmentsStatus(bigSegmentsStatus); - } } static class BigSegmentsState { @@ -153,7 +152,10 @@ EvalResult evaluate(DataModel.FeatureFlag flag, LDUser user, EventFactory eventF result.setPrerequisiteEvents(prerequisiteEvents); } if (bigSegmentsState.bigSegmentsStatus != null) { - result.setBigSegmentsStatus(bigSegmentsState.bigSegmentsStatus); + result.setDetails( + EvaluationDetail.fromValue(result.getDetails().getValue(), result.getDetails().getVariationIndex(), + result.getDetails().getReason().withBigSegmentsStatus(bigSegmentsState.bigSegmentsStatus)) + ); } return result; } @@ -164,15 +166,15 @@ private EvalResult evaluateInternal(DataModel.FeatureFlag flag, LDUser user, Eve return getOffValue(flag, EvaluationReason.off()); } - EvaluationReason prereqFailureReason = checkPrerequisites(flag, user, eventFactory, eventsOut, bigSegmentsState); - if (prereqFailureReason != null) { - return getOffValue(flag, prereqFailureReason); + EvalResult prereqFailureResult = checkPrerequisites(flag, user, eventFactory, eventsOut, bigSegmentsState); + if (prereqFailureResult != null) { + return prereqFailureResult; } // Check to see if targets match for (DataModel.Target target: flag.getTargets()) { // getTargets() and getValues() are guaranteed non-null if (target.getValues().contains(user.getKey())) { - return getVariation(flag, target.getVariation(), EvaluationReason.targetMatch()); + return targetMatchResult(flag, target); } } // Now walk through the rules and see if any match @@ -180,18 +182,18 @@ private EvalResult evaluateInternal(DataModel.FeatureFlag flag, LDUser user, Eve for (int i = 0; i < rules.size(); i++) { DataModel.Rule rule = rules.get(i); if (ruleMatchesUser(flag, rule, user, bigSegmentsState)) { - EvaluationReason precomputedReason = rule.getRuleMatchReason(); - EvaluationReason reason = precomputedReason != null ? precomputedReason : EvaluationReason.ruleMatch(i, rule.getId()); - return getValueForVariationOrRollout(flag, rule, user, reason); + return ruleMatchResult(flag, user, rule, i); } } // Walk through the fallthrough and see if it matches - return getValueForVariationOrRollout(flag, flag.getFallthrough(), user, EvaluationReason.fallthrough()); + return getValueForVariationOrRollout(flag, flag.getFallthrough(), user, + flag.preprocessed == null ? null : flag.preprocessed.fallthroughResults, + EvaluationReason.fallthrough()); } - // Checks prerequisites if any; returns null if successful, or an EvaluationReason if we have to + // Checks prerequisites if any; returns null if successful, or an EvalResult if we have to // short-circuit due to a prerequisite failure. - private EvaluationReason checkPrerequisites(DataModel.FeatureFlag flag, LDUser user, EventFactory eventFactory, + private EvalResult checkPrerequisites(DataModel.FeatureFlag flag, LDUser user, EventFactory eventFactory, List eventsOut, BigSegmentsState bigSegmentsState) { for (DataModel.Prerequisite prereq: flag.getPrerequisites()) { // getPrerequisites() is guaranteed non-null boolean prereqOk = true; @@ -203,7 +205,7 @@ private EvaluationReason checkPrerequisites(DataModel.FeatureFlag flag, LDUser u EvalResult prereqEvalResult = evaluateInternal(prereqFeatureFlag, user, eventFactory, eventsOut, bigSegmentsState); // 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.getVariationIndex() != prereq.getVariation()) { + if (!prereqFeatureFlag.isOn() || prereqEvalResult.getDetails().getVariationIndex() != prereq.getVariation()) { prereqOk = false; } // COVERAGE: currently eventsOut is never null because we preallocate the list in evaluate() if there are any prereqs @@ -212,14 +214,17 @@ private EvaluationReason checkPrerequisites(DataModel.FeatureFlag flag, LDUser u } } if (!prereqOk) { - EvaluationReason precomputedReason = prereq.getPrerequisiteFailedReason(); - return precomputedReason != null ? precomputedReason : EvaluationReason.prerequisiteFailed(prereq.getKey()); + if (prereq.preprocessed != null) { + return new EvalResult(prereq.preprocessed.prerequisiteFailedResult); + } + EvaluationReason reason = EvaluationReason.prerequisiteFailed(prereq.getKey()); + return getOffValue(flag, reason); } } return null; } - private EvalResult getVariation(DataModel.FeatureFlag flag, int variation, EvaluationReason reason) { + private static EvalResult getVariation(DataModel.FeatureFlag flag, int variation, EvaluationReason reason) { List variations = flag.getVariations(); if (variation < 0 || variation >= variations.size()) { logger.error("Data inconsistency in feature flag \"{}\": invalid variation index", flag.getKey()); @@ -230,6 +235,9 @@ private EvalResult getVariation(DataModel.FeatureFlag flag, int variation, Evalu } private EvalResult getOffValue(DataModel.FeatureFlag flag, EvaluationReason reason) { + if (flag.preprocessed != null) { + return new EvalResult(flag.preprocessed.offResult); + } Integer offVariation = flag.getOffVariation(); if (offVariation == null) { // off variation unspecified - return default value return new EvalResult(null, NO_VARIATION, reason); @@ -238,7 +246,13 @@ private EvalResult getOffValue(DataModel.FeatureFlag flag, EvaluationReason reas } } - private EvalResult getValueForVariationOrRollout(DataModel.FeatureFlag flag, DataModel.VariationOrRollout vr, LDUser user, EvaluationReason reason) { + private static EvalResult getValueForVariationOrRollout( + FeatureFlag flag, + VariationOrRollout vr, + LDUser user, + EvaluationDetailFactoryMultiVariations precomputedResults, + EvaluationReason reason + ) { int variation = -1; boolean inExperiment = false; Integer maybeVariation = vr.getVariation(); @@ -273,12 +287,14 @@ private EvalResult getValueForVariationOrRollout(DataModel.FeatureFlag flag, Dat if (variation < 0) { logger.error("Data inconsistency in feature flag \"{}\": variation/rollout object with no variation or rollout", flag.getKey()); return EvalResult.error(EvaluationReason.ErrorKind.MALFORMED_FLAG); - } else { - return getVariation(flag, variation, inExperiment ? experimentize(reason) : reason); } + if (precomputedResults != null) { + return new EvalResult(precomputedResults.forVariation(variation, inExperiment)); + } + return getVariation(flag, variation, inExperiment ? experimentize(reason) : reason); } - private EvaluationReason experimentize(EvaluationReason reason) { + private static EvaluationReason experimentize(EvaluationReason reason) { if (reason.getKind() == Kind.FALLTHROUGH) { return EvaluationReason.fallthrough(true); } else if (reason.getKind() == Kind.RULE_MATCH) { @@ -344,7 +360,7 @@ private boolean clauseMatchesUserNoSegments(DataModel.Clause clause, LDUser user static boolean clauseMatchAny(DataModel.Clause clause, LDValue userValue) { DataModel.Operator op = clause.getOp(); if (op != null) { - EvaluatorPreprocessing.ClauseExtra preprocessed = clause.getPreprocessed(); + ClausePreprocessed preprocessed = clause.preprocessed; if (op == DataModel.Operator.in) { // see if we have precomputed a Set for fast equality matching Set vs = preprocessed == null ? null : preprocessed.valuesSet; @@ -353,12 +369,12 @@ static boolean clauseMatchAny(DataModel.Clause clause, LDValue userValue) { } } List values = clause.getValues(); - List preprocessedValues = + List preprocessedValues = preprocessed == null ? null : preprocessed.valuesExtra; int n = values.size(); for (int i = 0; i < n; i++) { // the preprocessed list, if present, will always have the same size as the values list - EvaluatorPreprocessing.ClauseExtra.ValueExtra p = preprocessedValues == null ? null : preprocessedValues.get(i); + ClausePreprocessed.ValueData p = preprocessedValues == null ? null : preprocessedValues.get(i); LDValue v = values.get(i); if (EvaluatorOperators.apply(op, userValue, v, p)) { return true; @@ -435,6 +451,21 @@ private boolean segmentRuleMatchesUser(DataModel.SegmentRule segmentRule, LDUser return bucket < weight; } + private static EvalResult targetMatchResult(FeatureFlag flag, Target target) { + if (target.preprocessed != null) { + return new EvalResult(target.preprocessed.targetMatchResult); + } + return new EvalResult(resultForVariation(target.getVariation(), flag, EvaluationReason.targetMatch())); + } + + private static EvalResult ruleMatchResult(FeatureFlag flag, LDUser user, Rule rule, int ruleIndex) { + if (rule.preprocessed != null) { + return getValueForVariationOrRollout(flag, rule, user, rule.preprocessed.allPossibleResults, null); + } + EvaluationReason reason = EvaluationReason.ruleMatch(ruleIndex, rule.getId()); + return getValueForVariationOrRollout(flag, rule, user, null, reason); + } + static String makeBigSegmentRef(DataModel.Segment segment) { return String.format("%s.g%d", segment.getKey(), segment.getGeneration()); } diff --git a/src/main/java/com/launchdarkly/sdk/server/EvaluatorHelpers.java b/src/main/java/com/launchdarkly/sdk/server/EvaluatorHelpers.java new file mode 100644 index 000000000..d2822c8cf --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/EvaluatorHelpers.java @@ -0,0 +1,22 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.EvaluationReason.ErrorKind; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.DataModel.FeatureFlag; + +import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; + +abstract class EvaluatorHelpers { + static EvaluationDetail resultForVariation(int variation, FeatureFlag flag, EvaluationReason reason) { + if (variation < 0 || variation >= flag.getVariations().size()) { + return EvaluationDetail.fromValue(LDValue.ofNull(), NO_VARIATION, + EvaluationReason.error(ErrorKind.MALFORMED_FLAG)); + } + return EvaluationDetail.fromValue( + LDValue.normalize(flag.getVariations().get(variation)), + variation, + reason); + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/EvaluatorOperators.java b/src/main/java/com/launchdarkly/sdk/server/EvaluatorOperators.java index 3b383942f..d3043742b 100644 --- a/src/main/java/com/launchdarkly/sdk/server/EvaluatorOperators.java +++ b/src/main/java/com/launchdarkly/sdk/server/EvaluatorOperators.java @@ -1,6 +1,7 @@ package com.launchdarkly.sdk.server; import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.DataModelPreprocessing.ClausePreprocessed; import java.time.Instant; import java.util.regex.Pattern; @@ -44,7 +45,7 @@ static boolean apply( DataModel.Operator op, LDValue userValue, LDValue clauseValue, - EvaluatorPreprocessing.ClauseExtra.ValueExtra preprocessed + ClausePreprocessed.ValueData preprocessed ) { switch (op) { case in: @@ -116,7 +117,7 @@ private static boolean compareDate( ComparisonOp op, LDValue userValue, LDValue clauseValue, - EvaluatorPreprocessing.ClauseExtra.ValueExtra preprocessed + ClausePreprocessed.ValueData preprocessed ) { // If preprocessed is non-null, it means we've already tried to parse the clause value as a date/time, // in which case if preprocessed.parsedDate is null it was not a valid date/time. @@ -135,7 +136,7 @@ private static boolean compareSemVer( ComparisonOp op, LDValue userValue, LDValue clauseValue, - EvaluatorPreprocessing.ClauseExtra.ValueExtra preprocessed + ClausePreprocessed.ValueData preprocessed ) { // If preprocessed is non-null, it means we've already tried to parse the clause value as a version, // in which case if preprocessed.parsedSemVer is null it was not a valid version. diff --git a/src/main/java/com/launchdarkly/sdk/server/EvaluatorPreprocessing.java b/src/main/java/com/launchdarkly/sdk/server/EvaluatorPreprocessing.java deleted file mode 100644 index b31f9a3ff..000000000 --- a/src/main/java/com/launchdarkly/sdk/server/EvaluatorPreprocessing.java +++ /dev/null @@ -1,162 +0,0 @@ -package com.launchdarkly.sdk.server; - -import com.google.common.collect.ImmutableSet; -import com.launchdarkly.sdk.EvaluationReason; -import com.launchdarkly.sdk.LDValue; -import com.launchdarkly.sdk.server.DataModel.Clause; -import com.launchdarkly.sdk.server.DataModel.FeatureFlag; -import com.launchdarkly.sdk.server.DataModel.Operator; -import com.launchdarkly.sdk.server.DataModel.Prerequisite; -import com.launchdarkly.sdk.server.DataModel.Rule; -import com.launchdarkly.sdk.server.DataModel.Segment; -import com.launchdarkly.sdk.server.DataModel.SegmentRule; - -import java.time.Instant; -import java.util.ArrayList; -import java.util.List; -import java.util.Set; -import java.util.function.Function; -import java.util.regex.Pattern; - -/** - * These methods precompute data that may help to reduce the overhead of feature flag evaluations. They - * are called from the afterDeserialized() methods of FeatureFlag and Segment, after those objects have - * been deserialized from JSON but before they have been made available to any other code (so these - * methods do not need to be thread-safe). - *

            - * If for some reason these methods have not been called before an evaluation happens, the evaluation - * logic must still be able to work without the precomputed data. - */ -abstract class EvaluatorPreprocessing { - private EvaluatorPreprocessing() {} - - static final class ClauseExtra { - final Set valuesSet; - final List valuesExtra; - - ClauseExtra(Set valuesSet, List valuesExtra) { - this.valuesSet = valuesSet; - this.valuesExtra = valuesExtra; - } - - static final class ValueExtra { - final Instant parsedDate; - final Pattern parsedRegex; - final SemanticVersion parsedSemVer; - - ValueExtra(Instant parsedDate, Pattern parsedRegex, SemanticVersion parsedSemVer) { - this.parsedDate = parsedDate; - this.parsedRegex = parsedRegex; - this.parsedSemVer = parsedSemVer; - } - } - } - - static void preprocessFlag(FeatureFlag f) { - for (Prerequisite p: f.getPrerequisites()) { - EvaluatorPreprocessing.preprocessPrerequisite(p); - } - List rules = f.getRules(); - int n = rules.size(); - for (int i = 0; i < n; i++) { - preprocessFlagRule(rules.get(i), i); - } - preprocessValueList(f.getVariations()); - } - - static void preprocessSegment(Segment s) { - List rules = s.getRules(); - int n = rules.size(); - for (int i = 0; i < n; i++) { - preprocessSegmentRule(rules.get(i), i); - } - } - - static void preprocessPrerequisite(Prerequisite p) { - // Precompute an immutable EvaluationReason instance that will be used if the prerequisite fails. - p.setPrerequisiteFailedReason(EvaluationReason.prerequisiteFailed(p.getKey())); - } - - static void preprocessFlagRule(Rule r, int ruleIndex) { - // Precompute an immutable EvaluationReason instance that will be used if a user matches this rule. - r.setRuleMatchReason(EvaluationReason.ruleMatch(ruleIndex, r.getId())); - - for (Clause c: r.getClauses()) { - preprocessClause(c); - } - } - - static void preprocessSegmentRule(SegmentRule r, int ruleIndex) { - for (Clause c: r.getClauses()) { - preprocessClause(c); - } - } - - static void preprocessClause(Clause c) { - // If the clause values contain a null (which is valid in terms of the JSON schema, even if it - // can't ever produce a true result), Gson will give us an actual null. Change this to - // LDValue.ofNull() to avoid NPEs down the line. It's more efficient to do this just once at - // deserialization time than to do it in every clause match. - List values = c.getValues(); - preprocessValueList(values); - - Operator op = c.getOp(); - if (op == null) { - return; - } - switch (op) { - case in: - // This is a special case where the clause is testing for an exact match against any of the - // clause values. Converting the value list to a Set allows us to do a fast lookup instead of - // a linear search. We do not do this for other operators (or if there are fewer than two - // values) because the slight extra overhead of a Set is not worthwhile in those case. - if (values.size() > 1) { - c.setPreprocessed(new ClauseExtra(ImmutableSet.copyOf(values), null)); - } - break; - case matches: - c.setPreprocessed(preprocessClauseValues(c.getValues(), v -> - new ClauseExtra.ValueExtra(null, EvaluatorTypeConversion.valueToRegex(v), null) - )); - break; - case after: - case before: - c.setPreprocessed(preprocessClauseValues(c.getValues(), v -> - new ClauseExtra.ValueExtra(EvaluatorTypeConversion.valueToDateTime(v), null, null) - )); - break; - case semVerEqual: - case semVerGreaterThan: - case semVerLessThan: - c.setPreprocessed(preprocessClauseValues(c.getValues(), v -> - new ClauseExtra.ValueExtra(null, null, EvaluatorTypeConversion.valueToSemVer(v)) - )); - break; - default: - break; - } - } - - static void preprocessValueList(List values) { - // If a list of values contains a null (which is valid in terms of the JSON schema, even if it - // isn't useful because the SDK considers this a non-value), Gson will give us an actual null. - // Change this to LDValue.ofNull() to avoid NPEs down the line. It's more efficient to do this - // just once at deserialization time than to do it in every clause match. - for (int i = 0; i < values.size(); i++) { - if (values.get(i) == null) { - values.set(i, LDValue.ofNull()); - } - } - } - - private static ClauseExtra preprocessClauseValues( - List values, - Function f - ) { - List valuesExtra = new ArrayList<>(values.size()); - for (LDValue v: values) { - valuesExtra.add(f.apply(v)); - } - return new ClauseExtra(null, valuesExtra); - } -} diff --git a/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java b/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java index cb44a98be..b0a38c552 100644 --- a/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java +++ b/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java @@ -270,7 +270,7 @@ Builder addFlag(DataModel.FeatureFlag flag, Evaluator.EvalResult eval) { return add( flag.getKey(), eval.getValue(), - eval.isDefault() ? null : eval.getVariationIndex(), + eval.getDetails().isDefaultValue() ? null : eval.getVariationIndex(), eval.getReason(), flag.getVersion(), flag.isTrackEvents() || requireExperimentData, diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClient.java b/src/main/java/com/launchdarkly/sdk/server/LDClient.java index 3e927d401..8aca767f2 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClient.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClient.java @@ -494,8 +494,10 @@ private Evaluator.EvalResult evaluateInternal(String featureKey, LDUser user, LD for (Event.FeatureRequest event : evalResult.getPrerequisiteEvents()) { eventProcessor.sendEvent(event); } - if (evalResult.isDefault()) { - evalResult.setValue(defaultValue); + if (evalResult.getDetails().isDefaultValue()) { + evalResult.setDetails( + EvaluationDetail.fromValue(defaultValue, + evalResult.getDetails().getVariationIndex(), evalResult.getDetails().getReason())); } else { LDValue value = evalResult.getValue(); // guaranteed not to be an actual Java null, but can be LDValue.ofNull() if (requireType != null && diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorPreprocessingTest.java b/src/test/java/com/launchdarkly/sdk/server/DataModelPreprocessingTest.java similarity index 52% rename from src/test/java/com/launchdarkly/sdk/server/EvaluatorPreprocessingTest.java rename to src/test/java/com/launchdarkly/sdk/server/DataModelPreprocessingTest.java index beafff7fa..61bc9a3df 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EvaluatorPreprocessingTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataModelPreprocessingTest.java @@ -2,14 +2,19 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.UserAttribute; import com.launchdarkly.sdk.server.DataModel.Clause; import com.launchdarkly.sdk.server.DataModel.FeatureFlag; import com.launchdarkly.sdk.server.DataModel.Operator; +import com.launchdarkly.sdk.server.DataModel.Prerequisite; import com.launchdarkly.sdk.server.DataModel.Rule; import com.launchdarkly.sdk.server.DataModel.Segment; import com.launchdarkly.sdk.server.DataModel.SegmentRule; +import com.launchdarkly.sdk.server.DataModel.Target; +import com.launchdarkly.sdk.server.DataModelPreprocessing.ClausePreprocessed; import org.junit.Test; @@ -17,15 +22,20 @@ import java.time.ZonedDateTime; import java.util.List; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.notNullValue; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; @SuppressWarnings("javadoc") -public class EvaluatorPreprocessingTest { +public class DataModelPreprocessingTest { // We deliberately use the data model constructors here instead of the more convenient ModelBuilders // equivalents, to make sure we're testing the afterDeserialization() behavior and not just the builder. + private static final LDValue aValue = LDValue.of("a"), bValue = LDValue.of("b"); + private FeatureFlag flagFromClause(Clause c) { return new FeatureFlag("key", 0, false, null, null, null, rulesFromClause(c), null, null, null, false, false, false, null, false); @@ -34,6 +44,121 @@ private FeatureFlag flagFromClause(Clause c) { private List rulesFromClause(Clause c) { return ImmutableList.of(new Rule("", ImmutableList.of(c), null, null, false)); } + + @Test + public void preprocessFlagAddsPrecomputedOffResult() { + FeatureFlag f = new FeatureFlag("key", 0, false, null, null, null, + ImmutableList.of(), null, + 0, + ImmutableList.of(aValue, bValue), + false, false, false, null, false); + + f.afterDeserialized(); + + assertThat(f.preprocessed, notNullValue()); + assertThat(f.preprocessed.offResult, + equalTo(EvaluationDetail.fromValue(aValue, 0, EvaluationReason.off()))); + } + + @Test + public void preprocessFlagAddsPrecomputedOffResultForNullOffVariation() { + FeatureFlag f = new FeatureFlag("key", 0, false, null, null, null, + ImmutableList.of(), null, + null, + ImmutableList.of(aValue, bValue), + false, false, false, null, false); + + f.afterDeserialized(); + + assertThat(f.preprocessed, notNullValue()); + assertThat(f.preprocessed.offResult, + equalTo(EvaluationDetail.fromValue(LDValue.ofNull(), EvaluationDetail.NO_VARIATION, EvaluationReason.off()))); + } + + @Test + public void preprocessFlagAddsPrecomputedFallthroughResults() { + FeatureFlag f = new FeatureFlag("key", 0, false, null, null, null, + ImmutableList.of(), null, 0, + ImmutableList.of(aValue, bValue), + false, false, false, null, false); + + f.afterDeserialized(); + + assertThat(f.preprocessed, notNullValue()); + assertThat(f.preprocessed.fallthroughResults, notNullValue()); + EvaluationReason regularReason = EvaluationReason.fallthrough(false); + EvaluationReason inExperimentReason = EvaluationReason.fallthrough(true); + + assertThat(f.preprocessed.fallthroughResults.forVariation(0, false), + equalTo(EvaluationDetail.fromValue(aValue, 0, regularReason))); + assertThat(f.preprocessed.fallthroughResults.forVariation(0, true), + equalTo(EvaluationDetail.fromValue(aValue, 0, inExperimentReason))); + + assertThat(f.preprocessed.fallthroughResults.forVariation(1, false), + equalTo(EvaluationDetail.fromValue(bValue, 1, regularReason))); + assertThat(f.preprocessed.fallthroughResults.forVariation(1, true), + equalTo(EvaluationDetail.fromValue(bValue, 1, inExperimentReason))); + } + + @Test + public void preprocessFlagAddsPrecomputedTargetMatchResults() { + FeatureFlag f = new FeatureFlag("key", 0, false, null, null, + ImmutableList.of(new Target(ImmutableSet.of(), 1)), + ImmutableList.of(), null, 0, + ImmutableList.of(aValue, bValue), + false, false, false, null, false); + + f.afterDeserialized(); + + Target t = f.getTargets().get(0); + assertThat(t.preprocessed, notNullValue()); + assertThat(t.preprocessed.targetMatchResult, + equalTo(EvaluationDetail.fromValue(bValue, 1, EvaluationReason.targetMatch()))); + } + + @Test + public void preprocessFlagAddsPrecomputedPrerequisiteFailedResults() { + FeatureFlag f = new FeatureFlag("key", 0, false, + ImmutableList.of(new Prerequisite("abc", 1)), + null, null, + ImmutableList.of(), null, 0, + ImmutableList.of(aValue, bValue), + false, false, false, null, false); + + f.afterDeserialized(); + + Prerequisite p = f.getPrerequisites().get(0); + assertThat(p.preprocessed, notNullValue()); + assertThat(p.preprocessed.prerequisiteFailedResult, + equalTo(EvaluationDetail.fromValue(aValue, 0, EvaluationReason.prerequisiteFailed("abc")))); + } + + @Test + public void preprocessFlagAddsPrecomputedResultsToFlagRules() { + FeatureFlag f = new FeatureFlag("key", 0, false, null, null, null, + ImmutableList.of(new Rule("ruleid0", ImmutableList.of(), null, null, false)), + null, null, + ImmutableList.of(aValue, bValue), + false, false, false, null, false); + + f.afterDeserialized(); + + Rule rule = f.getRules().get(0); + assertThat(rule.preprocessed, notNullValue()); + assertThat(rule.preprocessed.allPossibleResults, notNullValue()); + EvaluationReason regularReason = EvaluationReason.ruleMatch(0, "ruleid0", false); + EvaluationReason inExperimentReason = EvaluationReason.ruleMatch(0, "ruleid0", true); + + assertThat(rule.preprocessed.allPossibleResults.forVariation(0, false), + equalTo(EvaluationDetail.fromValue(aValue, 0, regularReason))); + assertThat(rule.preprocessed.allPossibleResults.forVariation(0, true), + equalTo(EvaluationDetail.fromValue(aValue, 0, inExperimentReason))); + + assertThat(rule.preprocessed.allPossibleResults.forVariation(1, false), + equalTo(EvaluationDetail.fromValue(bValue, 1, regularReason))); + assertThat(rule.preprocessed.allPossibleResults.forVariation(1, true), + equalTo(EvaluationDetail.fromValue(bValue, 1, inExperimentReason))); + } @Test public void preprocessFlagCreatesClauseValuesMapForMultiValueEqualityTest() { @@ -45,11 +170,11 @@ public void preprocessFlagCreatesClauseValuesMapForMultiValueEqualityTest() { ); FeatureFlag f = flagFromClause(c); - assertNull(f.getRules().get(0).getClauses().get(0).getPreprocessed()); + assertNull(f.getRules().get(0).getClauses().get(0).preprocessed); f.afterDeserialized(); - EvaluatorPreprocessing.ClauseExtra ce = f.getRules().get(0).getClauses().get(0).getPreprocessed(); + ClausePreprocessed ce = f.getRules().get(0).getClauses().get(0).preprocessed; assertNotNull(ce); assertEquals(ImmutableSet.of(LDValue.of("a"), LDValue.of(0)), ce.valuesSet); } @@ -64,11 +189,11 @@ public void preprocessFlagDoesNotCreateClauseValuesMapForSingleValueEqualityTest ); FeatureFlag f = flagFromClause(c); - assertNull(f.getRules().get(0).getClauses().get(0).getPreprocessed()); + assertNull(f.getRules().get(0).getClauses().get(0).preprocessed); f.afterDeserialized(); - assertNull(f.getRules().get(0).getClauses().get(0).getPreprocessed()); + assertNull(f.getRules().get(0).getClauses().get(0).preprocessed); } @Test @@ -81,11 +206,11 @@ public void preprocessFlagDoesNotCreateClauseValuesMapForEmptyEqualityTest() { ); FeatureFlag f = flagFromClause(c); - assertNull(f.getRules().get(0).getClauses().get(0).getPreprocessed()); + assertNull(f.getRules().get(0).getClauses().get(0).preprocessed); f.afterDeserialized(); - assertNull(f.getRules().get(0).getClauses().get(0).getPreprocessed()); + assertNull(f.getRules().get(0).getClauses().get(0).preprocessed); } @Test @@ -104,11 +229,11 @@ public void preprocessFlagDoesNotCreateClauseValuesMapForNonEqualityOperators() // matters is that there's more than one of them, so that it *would* build a map if the operator were "in" FeatureFlag f = flagFromClause(c); - assertNull(op.name(), f.getRules().get(0).getClauses().get(0).getPreprocessed()); + assertNull(op.name(), f.getRules().get(0).getClauses().get(0).preprocessed); f.afterDeserialized(); - EvaluatorPreprocessing.ClauseExtra ce = f.getRules().get(0).getClauses().get(0).getPreprocessed(); + ClausePreprocessed ce = f.getRules().get(0).getClauses().get(0).preprocessed; // this might be non-null if we preprocessed the values list, but there should still not be a valuesSet if (ce != null) { assertNull(ce.valuesSet); @@ -132,11 +257,11 @@ public void preprocessFlagParsesClauseDate() { ); FeatureFlag f = flagFromClause(c); - assertNull(f.getRules().get(0).getClauses().get(0).getPreprocessed()); + assertNull(f.getRules().get(0).getClauses().get(0).preprocessed); f.afterDeserialized(); - EvaluatorPreprocessing.ClauseExtra ce = f.getRules().get(0).getClauses().get(0).getPreprocessed(); + ClausePreprocessed ce = f.getRules().get(0).getClauses().get(0).preprocessed; assertNotNull(op.name(), ce); assertNotNull(op.name(), ce.valuesExtra); assertEquals(op.name(), 4, ce.valuesExtra.size()); @@ -157,11 +282,11 @@ public void preprocessFlagParsesClauseRegex() { ); FeatureFlag f = flagFromClause(c); - assertNull(f.getRules().get(0).getClauses().get(0).getPreprocessed()); + assertNull(f.getRules().get(0).getClauses().get(0).preprocessed); f.afterDeserialized(); - EvaluatorPreprocessing.ClauseExtra ce = f.getRules().get(0).getClauses().get(0).getPreprocessed(); + ClausePreprocessed ce = f.getRules().get(0).getClauses().get(0).preprocessed; assertNotNull(ce); assertNotNull(ce.valuesExtra); assertEquals(3, ce.valuesExtra.size()); @@ -186,11 +311,11 @@ public void preprocessFlagParsesClauseSemVer() { ); FeatureFlag f = flagFromClause(c); - assertNull(f.getRules().get(0).getClauses().get(0).getPreprocessed()); + assertNull(f.getRules().get(0).getClauses().get(0).preprocessed); f.afterDeserialized(); - EvaluatorPreprocessing.ClauseExtra ce = f.getRules().get(0).getClauses().get(0).getPreprocessed(); + ClausePreprocessed ce = f.getRules().get(0).getClauses().get(0).preprocessed; assertNotNull(op.name(), ce); assertNotNull(op.name(), ce.valuesExtra); assertEquals(op.name(), 3, ce.valuesExtra.size()); @@ -213,11 +338,11 @@ public void preprocessSegmentPreprocessesClausesInRules() { SegmentRule rule = new SegmentRule(ImmutableList.of(c), null, null); Segment s = new Segment("key", null, null, null, ImmutableList.of(rule), 0, false, false, null); - assertNull(s.getRules().get(0).getClauses().get(0).getPreprocessed()); + assertNull(s.getRules().get(0).getClauses().get(0).preprocessed); s.afterDeserialized(); - EvaluatorPreprocessing.ClauseExtra ce = s.getRules().get(0).getClauses().get(0).getPreprocessed(); + ClausePreprocessed ce = s.getRules().get(0).getClauses().get(0).preprocessed; assertNotNull(ce.valuesExtra); assertEquals(1, ce.valuesExtra.size()); assertNotNull(ce.valuesExtra.get(0).parsedRegex); diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorOperatorsParameterizedTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorOperatorsParameterizedTest.java index 9dec6a0ba..1445cce23 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EvaluatorOperatorsParameterizedTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorOperatorsParameterizedTest.java @@ -180,7 +180,7 @@ public void parameterizedTestComparison() { assertEquals("without preprocessing", shouldBe, Evaluator.clauseMatchAny(clause1, userValue)); Clause clause2 = new Clause(userAttr, op, values, false); - EvaluatorPreprocessing.preprocessClause(clause2); + DataModelPreprocessing.preprocessClause(clause2); assertEquals("without preprocessing", shouldBe, Evaluator.clauseMatchAny(clause2, userValue)); } } diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorRuleTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorRuleTest.java index f3a658956..e579d44cc 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EvaluatorRuleTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorRuleTest.java @@ -41,7 +41,7 @@ private RuleBuilder buildTestRule(String id, DataModel.Clause... clauses) { } @Test - public void ruleMatchReasonInstanceIsReusedForSameRule() { + public void ruleMatchResultInstanceIsReusedForSameRule() { DataModel.Clause clause0 = clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of("wrongkey")); DataModel.Clause clause1 = clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of("userkey")); DataModel.Rule rule0 = buildTestRule("ruleid0", clause0).build(); @@ -56,14 +56,14 @@ public void ruleMatchReasonInstanceIsReusedForSameRule() { Evaluator.EvalResult otherResult = BASE_EVALUATOR.evaluate(f, otherUser, EventFactory.DEFAULT); assertEquals(EvaluationReason.ruleMatch(1, "ruleid1"), sameResult0.getDetails().getReason()); - assertSame(sameResult0.getDetails().getReason(), sameResult1.getDetails().getReason()); + assertSame(sameResult0.getDetails(), sameResult1.getDetails()); assertEquals(EvaluationReason.ruleMatch(0, "ruleid0"), otherResult.getDetails().getReason()); } @Test - public void ruleMatchReasonInstanceCanBeCreatedFromScratch() { - // Normally we will always do the preprocessing step that creates the reason instances ahead of time, + public void ruleMatchResultInstanceCanBeCreatedFromScratch() { + // Normally we will always do the preprocessing step that creates the result instances ahead of time, // but if somehow we didn't, it should create them as needed DataModel.Clause clause = clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of("userkey")); DataModel.Rule rule = buildTestRule("ruleid", clause).build(); @@ -72,14 +72,14 @@ public void ruleMatchReasonInstanceCanBeCreatedFromScratch() { DataModel.FeatureFlag f = buildBooleanFlagWithRules("feature", rule) .disablePreprocessing(true) .build(); - assertNull(f.getRules().get(0).getRuleMatchReason()); + assertNull(f.getRules().get(0).preprocessed); Evaluator.EvalResult result1 = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); Evaluator.EvalResult result2 = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); assertEquals(EvaluationReason.ruleMatch(0, "ruleid"), result1.getDetails().getReason()); - assertNotSame(result1.getDetails().getReason(), result2.getDetails().getReason()); // they were created individually - assertEquals(result1.getDetails().getReason(), result2.getDetails().getReason()); // but they're equal + assertNotSame(result1.getDetails(), result2.getDetails()); // they were created individually + assertEquals(result1.getDetails(), result2.getDetails()); // but they're equal } @Test diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java index 6864c9bf3..2c3ecbbdb 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java @@ -371,7 +371,7 @@ public void prerequisiteFailedReasonInstanceCanBeCreatedFromScratch() throws Exc .prerequisites(prerequisite("feature1", GREEN_VARIATION)) .disablePreprocessing(true) .build(); - assertNull(f0.getPrerequisites().get(0).getPrerequisiteFailedReason()); + assertNull(f0.getPrerequisites().get(0).preprocessed); Evaluator e = evaluatorBuilder().withNonexistentFlag("feature1").build(); Evaluator.EvalResult result0 = e.evaluate(f0, BASE_USER, EventFactory.DEFAULT); diff --git a/src/test/java/com/launchdarkly/sdk/server/FlagModelDeserializationTest.java b/src/test/java/com/launchdarkly/sdk/server/FlagModelDeserializationTest.java index 83d0dea83..24e437483 100644 --- a/src/test/java/com/launchdarkly/sdk/server/FlagModelDeserializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/FlagModelDeserializationTest.java @@ -1,35 +1,56 @@ package com.launchdarkly.sdk.server; import com.google.gson.Gson; -import com.launchdarkly.sdk.EvaluationReason; -import com.launchdarkly.sdk.server.DataModel; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.server.DataModel.Clause; +import com.launchdarkly.sdk.server.DataModel.FeatureFlag; +import com.launchdarkly.sdk.server.DataModel.Operator; +import com.launchdarkly.sdk.server.DataModel.Prerequisite; +import com.launchdarkly.sdk.server.DataModel.Rule; +import com.launchdarkly.sdk.server.DataModel.Target; import org.junit.Test; -import static org.junit.Assert.assertEquals; +import static com.launchdarkly.sdk.server.ModelBuilders.clause; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.ruleBuilder; +import static com.launchdarkly.sdk.server.ModelBuilders.target; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.notNullValue; import static org.junit.Assert.assertNotNull; @SuppressWarnings("javadoc") public class FlagModelDeserializationTest { private static final Gson gson = new Gson(); + // The details of the preprocessed data are verified by DataModelPreprocessingTest; here we're + // just verifying that the preprocessing is actually being done whenever we deserialize a flag. @Test - public void precomputedReasonsAreAddedToPrerequisites() { - String flagJson = "{\"key\":\"flagkey\",\"prerequisites\":[{\"key\":\"prereq0\"},{\"key\":\"prereq1\"}]}"; - DataModel.FeatureFlag flag = gson.fromJson(flagJson, DataModel.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\"}]}"; - DataModel.FeatureFlag flag = gson.fromJson(flagJson, DataModel.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()); + public void preprocessingIsDoneOnDeserialization() { + FeatureFlag originalFlag = flagBuilder("flagkey") + .variations("a", "b") + .prerequisites(new Prerequisite("abc", 0)) + .targets(target(0, "x")) + .rules(ruleBuilder().clauses( + clause(UserAttribute.KEY, Operator.in, LDValue.of("x"), LDValue.of("y")) + ).build()) + .build(); + String flagJson = JsonHelpers.serialize(originalFlag); + + FeatureFlag flag = gson.fromJson(flagJson, FeatureFlag.class); + assertNotNull(flag.preprocessed); + for (Prerequisite p: flag.getPrerequisites()) { + assertNotNull(p.preprocessed); + } + for (Target t: flag.getTargets()) { + assertNotNull(t.preprocessed); + } + for (Rule r: flag.getRules()) { + assertThat(r.preprocessed, notNullValue()); + for (Clause c: r.getClauses()) { + assertThat(c.preprocessed, notNullValue()); + } + } } } diff --git a/src/test/java/com/launchdarkly/sdk/server/ModelBuilders.java b/src/test/java/com/launchdarkly/sdk/server/ModelBuilders.java index ef38b83c9..ad4b1c1f0 100644 --- a/src/test/java/com/launchdarkly/sdk/server/ModelBuilders.java +++ b/src/test/java/com/launchdarkly/sdk/server/ModelBuilders.java @@ -197,6 +197,15 @@ FlagBuilder variations(boolean... variations) { this.variations = values; return this; } + + FlagBuilder variations(String... variations) { + List values = new ArrayList<>(); + for (String v: variations) { + values.add(LDValue.of(v)); + } + this.variations = values; + return this; + } FlagBuilder generatedVariations(int numVariations) { variations.clear(); From b41d1d0069881dd446bcf0c1f24ab5e812f04978 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 7 Jul 2022 16:23:23 -0700 Subject: [PATCH 647/673] make evaluator result object immutable and reuse instances --- .../sdk/server/DataModelPreprocessing.java | 102 +++---- .../launchdarkly/sdk/server/EvalResult.java | 256 ++++++++++++++++++ .../launchdarkly/sdk/server/Evaluator.java | 206 ++++---------- .../sdk/server/EvaluatorHelpers.java | 47 +++- .../launchdarkly/sdk/server/EventFactory.java | 61 ++--- .../sdk/server/FeatureFlagsState.java | 9 +- .../com/launchdarkly/sdk/server/LDClient.java | 78 +++--- .../server/DataModelPreprocessingTest.java | 24 +- .../DefaultEventProcessorOutputTest.java | 2 +- .../sdk/server/EvalResultTest.java | 157 +++++++++++ .../sdk/server/EvaluatorBigSegmentTest.java | 32 +-- .../sdk/server/EvaluatorBucketingTest.java | 6 +- .../sdk/server/EvaluatorClauseTest.java | 5 +- .../sdk/server/EvaluatorRuleTest.java | 46 ++-- .../sdk/server/EvaluatorSegmentMatchTest.java | 3 +- .../sdk/server/EvaluatorTest.java | 237 ++++++++-------- .../sdk/server/EvaluatorTestUtil.java | 34 +++ .../sdk/server/EventFactoryTest.java | 135 +++++---- .../sdk/server/EventOutputTest.java | 16 +- .../sdk/server/FeatureFlagsStateTest.java | 6 +- .../RolloutRandomizationConsistencyTest.java | 4 +- .../com/launchdarkly/sdk/server/TestUtil.java | 10 +- 22 files changed, 947 insertions(+), 529 deletions(-) create mode 100644 src/main/java/com/launchdarkly/sdk/server/EvalResult.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/EvalResultTest.java diff --git a/src/main/java/com/launchdarkly/sdk/server/DataModelPreprocessing.java b/src/main/java/com/launchdarkly/sdk/server/DataModelPreprocessing.java index 494f86513..af49227db 100644 --- a/src/main/java/com/launchdarkly/sdk/server/DataModelPreprocessing.java +++ b/src/main/java/com/launchdarkly/sdk/server/DataModelPreprocessing.java @@ -2,10 +2,9 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; -import com.launchdarkly.sdk.EvaluationDetail; import com.launchdarkly.sdk.EvaluationReason; -import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.EvaluationReason.ErrorKind; +import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.server.DataModel.Clause; import com.launchdarkly.sdk.server.DataModel.FeatureFlag; import com.launchdarkly.sdk.server.DataModel.Operator; @@ -22,9 +21,6 @@ import java.util.function.Function; import java.util.regex.Pattern; -import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; -import static com.launchdarkly.sdk.server.EvaluatorHelpers.resultForVariation; - /** * Additional information that we attach to our data model to reduce the overhead of feature flag * evaluations. The methods that create these objects are called by the afterDeserialized() methods @@ -36,75 +32,76 @@ */ abstract class DataModelPreprocessing { private DataModelPreprocessing() {} - - static final class EvaluationDetailsForSingleVariation { - private final EvaluationDetail regularResult; - private final EvaluationDetail inExperimentResult; + + static final class EvalResultsForSingleVariation { + private final EvalResult regularResult; + private final EvalResult inExperimentResult; - EvaluationDetailsForSingleVariation( + EvalResultsForSingleVariation( LDValue value, int variationIndex, EvaluationReason regularReason, - EvaluationReason inExperimentReason + EvaluationReason inExperimentReason, + boolean alwaysInExperiment ) { - this.regularResult = EvaluationDetail.fromValue(value, variationIndex, regularReason); - this.inExperimentResult = EvaluationDetail.fromValue(value, variationIndex, inExperimentReason); + this.regularResult = EvalResult.of(value, variationIndex, regularReason).withForceReasonTracking(alwaysInExperiment); + this.inExperimentResult = EvalResult.of(value, variationIndex, inExperimentReason).withForceReasonTracking(true); } - EvaluationDetail getResult(boolean inExperiment) { + EvalResult getResult(boolean inExperiment) { return inExperiment ? inExperimentResult : regularResult; } } - static final class EvaluationDetailFactoryMultiVariations { - private final ImmutableList variations; + static final class EvalResultFactoryMultiVariations { + private final ImmutableList variations; - EvaluationDetailFactoryMultiVariations( - ImmutableList variations + EvalResultFactoryMultiVariations( + ImmutableList variations ) { this.variations = variations; } - EvaluationDetail forVariation(int index, boolean inExperiment) { + EvalResult forVariation(int index, boolean inExperiment) { if (index < 0 || index >= variations.size()) { - return EvaluationDetail.error(ErrorKind.MALFORMED_FLAG, LDValue.ofNull()); + return EvalResult.error(ErrorKind.MALFORMED_FLAG); } return variations.get(index).getResult(inExperiment); } } static final class FlagPreprocessed { - EvaluationDetail offResult; - EvaluationDetailFactoryMultiVariations fallthroughResults; + EvalResult offResult; + EvalResultFactoryMultiVariations fallthroughResults; - FlagPreprocessed(EvaluationDetail offResult, - EvaluationDetailFactoryMultiVariations fallthroughResults) { + FlagPreprocessed(EvalResult offResult, + EvalResultFactoryMultiVariations fallthroughResults) { this.offResult = offResult; this.fallthroughResults = fallthroughResults; } } static final class PrerequisitePreprocessed { - final EvaluationDetail prerequisiteFailedResult; + final EvalResult prerequisiteFailedResult; - PrerequisitePreprocessed(EvaluationDetail prerequisiteFailedResult) { + PrerequisitePreprocessed(EvalResult prerequisiteFailedResult) { this.prerequisiteFailedResult = prerequisiteFailedResult; } } static final class TargetPreprocessed { - final EvaluationDetail targetMatchResult; + final EvalResult targetMatchResult; - TargetPreprocessed(EvaluationDetail targetMatchResult) { + TargetPreprocessed(EvalResult targetMatchResult) { this.targetMatchResult = targetMatchResult; } } static final class FlagRulePreprocessed { - final EvaluationDetailFactoryMultiVariations allPossibleResults; + final EvalResultFactoryMultiVariations allPossibleResults; FlagRulePreprocessed( - EvaluationDetailFactoryMultiVariations allPossibleResults + EvalResultFactoryMultiVariations allPossibleResults ) { this.allPossibleResults = allPossibleResults; } @@ -134,9 +131,9 @@ static final class ValueData { static void preprocessFlag(FeatureFlag f) { f.preprocessed = new FlagPreprocessed( - precomputeSingleVariationResult(f, f.getOffVariation(), EvaluationReason.off()), + EvaluatorHelpers.offResult(f), precomputeMultiVariationResults(f, EvaluationReason.fallthrough(false), - EvaluationReason.fallthrough(true)) + EvaluationReason.fallthrough(true), f.isTrackEventsFallthrough()) ); for (Prerequisite p: f.getPrerequisites()) { @@ -164,23 +161,19 @@ static void preprocessSegment(Segment s) { static void preprocessPrerequisite(Prerequisite p, FeatureFlag f) { // Precompute an immutable EvaluationDetail instance that will be used if the prerequisite fails. // This behaves the same as an "off" result except for the reason. - EvaluationDetail failureResult = precomputeSingleVariationResult(f, - f.getOffVariation(), EvaluationReason.prerequisiteFailed(p.getKey())); - p.preprocessed = new PrerequisitePreprocessed(failureResult); + p.preprocessed = new PrerequisitePreprocessed(EvaluatorHelpers.prerequisiteFailedResult(f, p)); } static void preprocessTarget(Target t, FeatureFlag f) { - // Precompute an immutable EvaluationDetail instance that will be used if this target matches. - t.preprocessed = new TargetPreprocessed( - resultForVariation(t.getVariation(), f, EvaluationReason.targetMatch()) - ); + // Precompute an immutable EvalResult instance that will be used if this target matches. + t.preprocessed = new TargetPreprocessed(EvaluatorHelpers.targetMatchResult(f, t)); } static void preprocessFlagRule(Rule r, int ruleIndex, FeatureFlag f) { EvaluationReason ruleMatchReason = EvaluationReason.ruleMatch(ruleIndex, r.getId(), false); EvaluationReason ruleMatchReasonInExperiment = EvaluationReason.ruleMatch(ruleIndex, r.getId(), true); r.preprocessed = new FlagRulePreprocessed(precomputeMultiVariationResults(f, - ruleMatchReason, ruleMatchReasonInExperiment)); + ruleMatchReason, ruleMatchReasonInExperiment, r.isTrackEvents())); for (Clause c: r.getClauses()) { preprocessClause(c); @@ -261,33 +254,18 @@ private static ClausePreprocessed preprocessClauseValues( return new ClausePreprocessed(null, valuesExtra); } - private static EvaluationDetail precomputeSingleVariationResult( - FeatureFlag f, - Integer index, - EvaluationReason reason - ) { - if (index == null) { - return EvaluationDetail.fromValue(LDValue.ofNull(), NO_VARIATION, reason); - } - int i = index.intValue(); - if (i < 0 || i >= f.getVariations().size()) { - return EvaluationDetail.fromValue(LDValue.ofNull(), NO_VARIATION, - EvaluationReason.error(ErrorKind.MALFORMED_FLAG)); - } - return EvaluationDetail.fromValue(f.getVariations().get(i), index, reason); - } - - private static EvaluationDetailFactoryMultiVariations precomputeMultiVariationResults( + private static EvalResultFactoryMultiVariations precomputeMultiVariationResults( FeatureFlag f, EvaluationReason regularReason, - EvaluationReason inExperimentReason + EvaluationReason inExperimentReason, + boolean alwaysInExperiment ) { - ImmutableList.Builder builder = + ImmutableList.Builder builder = ImmutableList.builderWithExpectedSize(f.getVariations().size()); for (int i = 0; i < f.getVariations().size(); i++) { - builder.add(new EvaluationDetailsForSingleVariation(f.getVariations().get(i), i, - regularReason, inExperimentReason)); + builder.add(new EvalResultsForSingleVariation(f.getVariations().get(i), i, + regularReason, inExperimentReason, alwaysInExperiment)); } - return new EvaluationDetailFactoryMultiVariations(builder.build()); + return new EvalResultFactoryMultiVariations(builder.build()); } } diff --git a/src/main/java/com/launchdarkly/sdk/server/EvalResult.java b/src/main/java/com/launchdarkly/sdk/server/EvalResult.java new file mode 100644 index 000000000..cf772042b --- /dev/null +++ b/src/main/java/com/launchdarkly/sdk/server/EvalResult.java @@ -0,0 +1,256 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.EvaluationReason.ErrorKind; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.LDValueType; + +import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; + +/** + * Internal container for the results of an evaluation. This consists of: + *

              + *
            • an {@link EvaluationDetail} in a type-agnostic form using {@link LDValue} + *
            • if appropriate, an additional precomputed {@link EvaluationDetail} for specific Java types + * such as Boolean, so that calling a method like boolVariationDetail won't always have to create + * a new instance + *
            • the boolean forceReasonTracking property (see isForceReasonTracking) + */ +final class EvalResult { + private static final EvaluationDetail WRONG_TYPE_BOOLEAN = wrongTypeWithValue(false); + private static final EvaluationDetail WRONG_TYPE_INTEGER = wrongTypeWithValue((int)0); + private static final EvaluationDetail WRONG_TYPE_DOUBLE = wrongTypeWithValue((double)0); + private static final EvaluationDetail WRONG_TYPE_STRING = wrongTypeWithValue((String)null); + + private final EvaluationDetail anyType; + private final EvaluationDetail asBoolean; + private final EvaluationDetail asInteger; + private final EvaluationDetail asDouble; + private final EvaluationDetail asString; + private final boolean forceReasonTracking; + + /** + * Constructs an instance that wraps the specified EvaluationDetail and also precomputes + * any appropriate type-specific variants (asBoolean, etc.). + * + * @param original the original value + * @return an EvaluatorResult + */ + static EvalResult of(EvaluationDetail original) { + return new EvalResult(original); + } + + /** + * Same as {@link #of(EvaluationDetail)} but specifies the individual properties. + * + * @param value the value + * @param variationIndex the variation index + * @param reason the evaluation reason + * @return an EvaluatorResult + */ + static EvalResult of(LDValue value, int variationIndex, EvaluationReason reason) { + return of(EvaluationDetail.fromValue(value, variationIndex, reason)); + } + + /** + * Constructs an instance for an error result. The value is always null in this case because + * this is a generalized result that wasn't produced by an individual variation() call, so + * we do not know what the application might specify as a default value. + * + * @param errorKind the error kind + * @return an instance + */ + static EvalResult error(ErrorKind errorKind) { + return of(LDValue.ofNull(), EvaluationDetail.NO_VARIATION, EvaluationReason.error(errorKind)); + } + + private EvalResult(EvaluationDetail original) { + this.anyType = original.getValue() == null ? + EvaluationDetail.fromValue(LDValue.ofNull(), original.getVariationIndex(), original.getReason()) : + original; + this.forceReasonTracking = original.getReason().isInExperiment(); + + LDValue value = anyType.getValue(); + int index = anyType.getVariationIndex(); + EvaluationReason reason = anyType.getReason(); + + this.asBoolean = value.getType() == LDValueType.BOOLEAN ? + EvaluationDetail.fromValue(Boolean.valueOf(value.booleanValue()), index, reason) : + WRONG_TYPE_BOOLEAN; + this.asInteger = value.isNumber() ? + EvaluationDetail.fromValue(Integer.valueOf(value.intValue()), index, reason) : + WRONG_TYPE_INTEGER; + this.asDouble = value.isNumber() ? + EvaluationDetail.fromValue(Double.valueOf(value.doubleValue()), index, reason) : + WRONG_TYPE_DOUBLE; + this.asString = value.isString() || value.isNull() ? + EvaluationDetail.fromValue(value.stringValue(), index, reason) : + WRONG_TYPE_STRING; + } + + private EvalResult(EvalResult from, EvaluationReason newReason) { + this.anyType = transformReason(from.anyType, newReason); + this.asBoolean = transformReason(from.asBoolean, newReason); + this.asInteger = transformReason(from.asInteger, newReason); + this.asDouble = transformReason(from.asDouble, newReason); + this.asString = transformReason(from.asString, newReason); + this.forceReasonTracking = from.forceReasonTracking; + } + + private EvalResult(EvalResult from, boolean newForceTracking) { + this.anyType = from.anyType; + this.asBoolean = from.asBoolean; + this.asInteger = from.asInteger; + this.asDouble = from.asDouble; + this.asString = from.asString; + this.forceReasonTracking = newForceTracking; + } + + /** + * Returns the result as an {@code EvaluationDetail} where the value is an {@code LDValue}, + * allowing it to be of any JSON type. + * + * @return the result properties + */ + public EvaluationDetail getAnyType() { + return anyType; + } + + /** + * Returns the result as an {@code EvaluationDetail} where the value is a {@code Boolean}. + * If the result was not a boolean, the returned object has a value of false and a reason + * that is a {@code WRONG_TYPE} error. + *

              + * Note: the "wrong type" logic is just a safety measure to ensure that we never return + * null. Normally, the result will already have been transformed by LDClient.evaluateInternal + * if the wrong type was requested. + * + * @return the result properties + */ + public EvaluationDetail getAsBoolean() { + return asBoolean; + } + + /** + * Returns the result as an {@code EvaluationDetail} where the value is an {@code Integer}. + * If the result was not a number, the returned object has a value of zero and a reason + * that is a {@code WRONG_TYPE} error (see {@link #getAsBoolean()}). + * + * @return the result properties + */ + public EvaluationDetail getAsInteger() { + return asInteger; + } + + /** + * Returns the result as an {@code EvaluationDetail} where the value is a {@code Double}. + * If the result was not a number, the returned object has a value of zero and a reason + * that is a {@code WRONG_TYPE} error (see {@link #getAsBoolean()}). + * + * @return the result properties + */ + public EvaluationDetail getAsDouble() { + return asDouble; + } + + /** + * Returns the result as an {@code EvaluationDetail} where the value is a {@code String}. + * If the result was not a string, the returned object has a value of {@code null} and a + * reason that is a {@code WRONG_TYPE} error (see {@link #getAsBoolean()}). + * + * @return the result properties + */ + public EvaluationDetail getAsString() { + return asString; + } + + /** + * Returns the result value, which may be of any JSON type. + * @return the result value + */ + public LDValue getValue() { return anyType.getValue(); } + + /** + * Returns the variation index, or {@link EvaluationDetail#NO_VARIATION} if evaluation failed + * @return the variation index or {@link EvaluationDetail#NO_VARIATION} + */ + public int getVariationIndex() { return anyType.getVariationIndex(); } + + /** + * Returns the evaluation reason. This is never null, even though we may not always put the + * reason into events. + * @return the evaluation reason + */ + public EvaluationReason getReason() { return anyType.getReason(); } + + /** + * Returns true if the variation index is {@link EvaluationDetail#NO_VARIATION}, indicating + * that evaluation failed or at least that no variation was selected. + * @return true if there is no variation + */ + public boolean isNoVariation() { return anyType.isDefaultValue(); } + + /** + * Returns true if we need to send an evaluation reason in event data whenever we get this + * result. This is true if any of the following are true: 1. the evaluation reason's + * inExperiment property was true, which can happen if the evaluation involved a rollout + * or experiment; 2. the evaluation reason was FALLTHROUGH, and the flag's trackEventsFallthrough + * property was true; 3. the evaluation reason was RULE_MATCH, and the rule-level trackEvents + * property was true. The consequence is that we will tell the event processor "definitely send + * a individual event for this evaluation, even if the flag-level trackEvents was not true", + * and also we will include the evaluation reason in the event even if the application did not + * call a VariationDetail method. + * @return true if reason tracking is required for this result + */ + public boolean isForceReasonTracking() { return forceReasonTracking; } + + /** + * Returns a transformed copy of this EvalResult with a different evaluation reason. + * @param newReason the new evaluation reason + * @return a transformed copy + */ + public EvalResult withReason(EvaluationReason newReason) { + return newReason.equals(this.anyType.getReason()) ? this : new EvalResult(this, newReason); + } + + /** + * Returns a transformed copy of this EvalResult with a different value for {@link #isForceReasonTracking()}. + * @param newValue the new value for the property + * @return a transformed copy + */ + public EvalResult withForceReasonTracking(boolean newValue) { + return this.forceReasonTracking == newValue ? this : new EvalResult(this, newValue); + } + + @Override + public boolean equals(Object other) { + if (other instanceof EvalResult) { + EvalResult o = (EvalResult)other; + return anyType.equals(o.anyType) && forceReasonTracking == o.forceReasonTracking; + } + return false; + } + + @Override + public int hashCode() { + return anyType.hashCode() + (forceReasonTracking ? 1 : 0); + } + + @Override + public String toString() { + if (forceReasonTracking) { + return anyType.toString() + "(forceReasonTracking=true)"; + } + return anyType.toString(); + } + + private static EvaluationDetail transformReason(EvaluationDetail from, EvaluationReason newReason) { + return from == null ? null : + EvaluationDetail.fromValue(from.getValue(), from.getVariationIndex(), newReason); + } + + private static EvaluationDetail wrongTypeWithValue(T value) { + return EvaluationDetail.fromValue(value, NO_VARIATION, EvaluationReason.error(ErrorKind.WRONG_TYPE)); + } +} diff --git a/src/main/java/com/launchdarkly/sdk/server/Evaluator.java b/src/main/java/com/launchdarkly/sdk/server/Evaluator.java index 1547c4518..d0943836b 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Evaluator.java +++ b/src/main/java/com/launchdarkly/sdk/server/Evaluator.java @@ -1,7 +1,5 @@ package com.launchdarkly.sdk.server; -import com.google.common.collect.ImmutableList; -import com.launchdarkly.sdk.EvaluationDetail; import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.EvaluationReason.Kind; import com.launchdarkly.sdk.LDUser; @@ -9,23 +7,17 @@ import com.launchdarkly.sdk.LDValueType; import com.launchdarkly.sdk.server.DataModel.FeatureFlag; import com.launchdarkly.sdk.server.DataModel.Rule; -import com.launchdarkly.sdk.server.DataModel.Target; import com.launchdarkly.sdk.server.DataModel.VariationOrRollout; import com.launchdarkly.sdk.server.DataModel.WeightedVariation; import com.launchdarkly.sdk.server.DataModelPreprocessing.ClausePreprocessed; -import com.launchdarkly.sdk.server.DataModelPreprocessing.EvaluationDetailFactoryMultiVariations; import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreTypes; -import com.launchdarkly.sdk.server.interfaces.Event; import org.slf4j.Logger; -import java.util.ArrayList; import java.util.List; import java.util.Set; -import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; import static com.launchdarkly.sdk.server.EvaluatorBucketing.bucketUser; -import static com.launchdarkly.sdk.server.EvaluatorHelpers.resultForVariation; /** * Encapsulates the feature flag evaluation logic. The Evaluator has no knowledge of the rest of the SDK environment; @@ -57,66 +49,25 @@ static interface Getters { } /** - * Internal container for the results of an evaluation. This consists of the same information that is in an - * {@link EvaluationDetail}, plus a list of any feature request events generated by prerequisite flags. - * - * Unlike all the other simple data containers in the SDK, this is mutable. The reason is that flag evaluations - * may be done very frequently and we would like to minimize the amount of heap churn from intermediate objects, - * and Java does not support multiple return values as Go does, or value types as C# does. - * - * We never expose an EvalResult to application code and we never preserve a reference to it outside of a single - * xxxVariation() or xxxVariationDetail() call, so the risks from mutability are minimal. The only setter method - * that is accessible from outside of the Evaluator class is setValue(), which is exposed so that LDClient can - * replace null values with default values, + * An interface for the caller to receive information about prerequisite flags that were evaluated as a side + * effect of evaluating a flag. Evaluator pushes information to this object to avoid the overhead of building + * and returning lists of evaluation events. */ - static class EvalResult { - private EvaluationDetail detail; - private List prerequisiteEvents; - - public EvalResult(EvaluationDetail detail) { - this.detail = detail; - } - - public EvalResult(LDValue value, int variationIndex, EvaluationReason reason) { - this.detail = EvaluationDetail.fromValue(LDValue.normalize(value), variationIndex, reason); - } - - public static EvalResult error(EvaluationReason.ErrorKind errorKind) { - return new EvalResult(LDValue.ofNull(), NO_VARIATION, EvaluationReason.error(errorKind)); - } - - EvaluationDetail getDetails() { - return detail; - } - - void setDetails(EvaluationDetail detail) { - this.detail = detail; - } - - LDValue getValue() { - return detail.getValue(); - } - - int getVariationIndex() { - return detail.getVariationIndex(); - } - - EvaluationReason getReason() { - return detail.getReason(); - } - - Iterable getPrerequisiteEvents() { - return prerequisiteEvents == null ? ImmutableList.of() : prerequisiteEvents; - } - - private void setPrerequisiteEvents(List prerequisiteEvents) { - this.prerequisiteEvents = prerequisiteEvents; - } + static interface PrerequisiteEvaluationSink { + void recordPrerequisiteEvaluation( + FeatureFlag flag, + FeatureFlag prereqOfFlag, + LDUser user, + EvalResult result + ); } - - static class BigSegmentsState { - private BigSegmentStoreTypes.Membership bigSegmentsMembership; - private EvaluationReason.BigSegmentsStatus bigSegmentsStatus; + + /** + * This object holds mutable state that Evaluator may need during an evaluation. + */ + private static class EvaluatorState { + private BigSegmentStoreTypes.Membership bigSegmentsMembership = null; + private EvaluationReason.BigSegmentsStatus bigSegmentsStatus = null; } Evaluator(Getters getters) { @@ -131,7 +82,7 @@ static class BigSegmentsState { * @param eventFactory produces feature request events * @return an {@link EvalResult} - guaranteed non-null */ - EvalResult evaluate(DataModel.FeatureFlag flag, LDUser user, EventFactory eventFactory) { + EvalResult evaluate(DataModel.FeatureFlag flag, LDUser user, PrerequisiteEvaluationSink prereqEvals) { if (flag.getKey() == INVALID_FLAG_KEY_THAT_THROWS_EXCEPTION) { throw EXPECTED_EXCEPTION_FROM_INVALID_FLAG; } @@ -139,34 +90,28 @@ EvalResult evaluate(DataModel.FeatureFlag flag, LDUser user, EventFactory eventF 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", flag.getKey()); - return new EvalResult(null, NO_VARIATION, EvaluationReason.error(EvaluationReason.ErrorKind.USER_NOT_SPECIFIED)); + return EvalResult.error(EvaluationReason.ErrorKind.USER_NOT_SPECIFIED); } - BigSegmentsState bigSegmentsState = new BigSegmentsState(); - // If the flag doesn't have any prerequisites (which most flags don't) then it cannot generate any feature - // request events for prerequisites and we can skip allocating a List. - List prerequisiteEvents = flag.getPrerequisites().isEmpty() ? - null : new ArrayList(); // note, getPrerequisites() is guaranteed non-null - EvalResult result = evaluateInternal(flag, user, eventFactory, prerequisiteEvents, bigSegmentsState); - if (prerequisiteEvents != null) { - result.setPrerequisiteEvents(prerequisiteEvents); - } - if (bigSegmentsState.bigSegmentsStatus != null) { - result.setDetails( - EvaluationDetail.fromValue(result.getDetails().getValue(), result.getDetails().getVariationIndex(), - result.getDetails().getReason().withBigSegmentsStatus(bigSegmentsState.bigSegmentsStatus)) + EvaluatorState state = new EvaluatorState(); + + EvalResult result = evaluateInternal(flag, user, prereqEvals, state); + + if (state.bigSegmentsStatus != null) { + return result.withReason( + result.getReason().withBigSegmentsStatus(state.bigSegmentsStatus) ); } return result; } - private EvalResult evaluateInternal(DataModel.FeatureFlag flag, LDUser user, EventFactory eventFactory, - List eventsOut, BigSegmentsState bigSegmentsState) { + private EvalResult evaluateInternal(DataModel.FeatureFlag flag, LDUser user, + PrerequisiteEvaluationSink prereqEvals, EvaluatorState state) { if (!flag.isOn()) { - return getOffValue(flag, EvaluationReason.off()); + return EvaluatorHelpers.offResult(flag); } - EvalResult prereqFailureResult = checkPrerequisites(flag, user, eventFactory, eventsOut, bigSegmentsState); + EvalResult prereqFailureResult = checkPrerequisites(flag, user, prereqEvals, state); if (prereqFailureResult != null) { return prereqFailureResult; } @@ -174,15 +119,15 @@ private EvalResult evaluateInternal(DataModel.FeatureFlag flag, LDUser user, Eve // Check to see if targets match for (DataModel.Target target: flag.getTargets()) { // getTargets() and getValues() are guaranteed non-null if (target.getValues().contains(user.getKey())) { - return targetMatchResult(flag, target); + return EvaluatorHelpers.targetMatchResult(flag, target); } } // Now walk through the rules and see if any match List rules = flag.getRules(); // guaranteed non-null for (int i = 0; i < rules.size(); i++) { DataModel.Rule rule = rules.get(i); - if (ruleMatchesUser(flag, rule, user, bigSegmentsState)) { - return ruleMatchResult(flag, user, rule, i); + if (ruleMatchesUser(flag, rule, user, state)) { + return computeRuleMatch(flag, user, rule, i); } } // Walk through the fallthrough and see if it matches @@ -193,8 +138,8 @@ private EvalResult evaluateInternal(DataModel.FeatureFlag flag, LDUser user, Eve // Checks prerequisites if any; returns null if successful, or an EvalResult if we have to // short-circuit due to a prerequisite failure. - private EvalResult checkPrerequisites(DataModel.FeatureFlag flag, LDUser user, EventFactory eventFactory, - List eventsOut, BigSegmentsState bigSegmentsState) { + private EvalResult checkPrerequisites(DataModel.FeatureFlag flag, LDUser user, + PrerequisiteEvaluationSink prereqEvals, EvaluatorState state) { for (DataModel.Prerequisite prereq: flag.getPrerequisites()) { // getPrerequisites() is guaranteed non-null boolean prereqOk = true; DataModel.FeatureFlag prereqFeatureFlag = getters.getFlag(prereq.getKey()); @@ -202,55 +147,28 @@ private EvalResult checkPrerequisites(DataModel.FeatureFlag flag, LDUser user, E logger.error("Could not retrieve prerequisite flag \"{}\" when evaluating \"{}\"", prereq.getKey(), flag.getKey()); prereqOk = false; } else { - EvalResult prereqEvalResult = evaluateInternal(prereqFeatureFlag, user, eventFactory, eventsOut, bigSegmentsState); + EvalResult prereqEvalResult = evaluateInternal(prereqFeatureFlag, user, prereqEvals, state); // 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.getDetails().getVariationIndex() != prereq.getVariation()) { + if (!prereqFeatureFlag.isOn() || prereqEvalResult.getVariationIndex() != prereq.getVariation()) { prereqOk = false; } - // COVERAGE: currently eventsOut is never null because we preallocate the list in evaluate() if there are any prereqs - if (eventsOut != null) { - eventsOut.add(eventFactory.newPrerequisiteFeatureRequestEvent(prereqFeatureFlag, user, prereqEvalResult, flag)); + if (prereqEvals != null) { + prereqEvals.recordPrerequisiteEvaluation(prereqFeatureFlag, flag, user, prereqEvalResult); } } if (!prereqOk) { - if (prereq.preprocessed != null) { - return new EvalResult(prereq.preprocessed.prerequisiteFailedResult); - } - EvaluationReason reason = EvaluationReason.prerequisiteFailed(prereq.getKey()); - return getOffValue(flag, reason); + return EvaluatorHelpers.prerequisiteFailedResult(flag, prereq); } } return null; } - private static EvalResult getVariation(DataModel.FeatureFlag flag, int variation, EvaluationReason reason) { - List variations = flag.getVariations(); - if (variation < 0 || variation >= variations.size()) { - logger.error("Data inconsistency in feature flag \"{}\": invalid variation index", flag.getKey()); - return EvalResult.error(EvaluationReason.ErrorKind.MALFORMED_FLAG); - } else { - return new EvalResult(variations.get(variation), variation, reason); - } - } - - private EvalResult getOffValue(DataModel.FeatureFlag flag, EvaluationReason reason) { - if (flag.preprocessed != null) { - return new EvalResult(flag.preprocessed.offResult); - } - Integer offVariation = flag.getOffVariation(); - if (offVariation == null) { // off variation unspecified - return default value - return new EvalResult(null, NO_VARIATION, reason); - } else { - return getVariation(flag, offVariation, reason); - } - } - private static EvalResult getValueForVariationOrRollout( FeatureFlag flag, VariationOrRollout vr, LDUser user, - EvaluationDetailFactoryMultiVariations precomputedResults, + DataModelPreprocessing.EvalResultFactoryMultiVariations precomputedResults, EvaluationReason reason ) { int variation = -1; @@ -288,10 +206,13 @@ private static EvalResult getValueForVariationOrRollout( logger.error("Data inconsistency in feature flag \"{}\": variation/rollout object with no variation or rollout", flag.getKey()); return EvalResult.error(EvaluationReason.ErrorKind.MALFORMED_FLAG); } + // Normally, we will always have precomputedResults if (precomputedResults != null) { - return new EvalResult(precomputedResults.forVariation(variation, inExperiment)); - } - return getVariation(flag, variation, inExperiment ? experimentize(reason) : reason); + return precomputedResults.forVariation(variation, inExperiment); + } + // If for some reason we don't, synthesize an equivalent result + return EvalResult.of(EvaluatorHelpers.evaluationDetailForVariation( + flag, variation, inExperiment ? experimentize(reason) : reason)); } private static EvaluationReason experimentize(EvaluationReason reason) { @@ -303,16 +224,16 @@ private static EvaluationReason experimentize(EvaluationReason reason) { return reason; } - private boolean ruleMatchesUser(DataModel.FeatureFlag flag, DataModel.Rule rule, LDUser user, BigSegmentsState bigSegmentsState) { + private boolean ruleMatchesUser(DataModel.FeatureFlag flag, DataModel.Rule rule, LDUser user, EvaluatorState state) { for (DataModel.Clause clause: rule.getClauses()) { // getClauses() is guaranteed non-null - if (!clauseMatchesUser(clause, user, bigSegmentsState)) { + if (!clauseMatchesUser(clause, user, state)) { return false; } } return true; } - private boolean clauseMatchesUser(DataModel.Clause clause, LDUser user, BigSegmentsState bigSegmentsState) { + private boolean clauseMatchesUser(DataModel.Clause clause, LDUser user, EvaluatorState state) { // In the case of a segment match operator, we check if the user is in any of the segments, // and possibly negate if (clause.getOp() == DataModel.Operator.segmentMatch) { @@ -320,7 +241,7 @@ private boolean clauseMatchesUser(DataModel.Clause clause, LDUser user, BigSegme if (j.isString()) { DataModel.Segment segment = getters.getSegment(j.stringValue()); if (segment != null) { - if (segmentMatchesUser(segment, user, bigSegmentsState)) { + if (segmentMatchesUser(segment, user, state)) { return maybeNegate(clause, true); } } @@ -388,7 +309,7 @@ private boolean maybeNegate(DataModel.Clause clause, boolean b) { return clause.isNegate() ? !b : b; } - private boolean segmentMatchesUser(DataModel.Segment segment, LDUser user, BigSegmentsState bigSegmentsState) { + private boolean segmentMatchesUser(DataModel.Segment segment, LDUser user, EvaluatorState state) { String userKey = user.getKey(); // we've already verified that the key is non-null at the top of evaluate() if (segment.isUnbounded()) { if (segment.getGeneration() == null) { @@ -396,24 +317,24 @@ private boolean segmentMatchesUser(DataModel.Segment segment, LDUser user, BigSe // probably means the data store was populated by an older SDK that doesn't know about the // generation property and therefore dropped it from the JSON data. We'll treat that as a // "not configured" condition. - bigSegmentsState.bigSegmentsStatus = EvaluationReason.BigSegmentsStatus.NOT_CONFIGURED; + state.bigSegmentsStatus = EvaluationReason.BigSegmentsStatus.NOT_CONFIGURED; return false; } // Even if multiple Big Segments are referenced within a single flag evaluation, we only need // to do this query once, since it returns *all* of the user's segment memberships. - if (bigSegmentsState.bigSegmentsStatus == null) { + if (state.bigSegmentsStatus == null) { BigSegmentStoreWrapper.BigSegmentsQueryResult queryResult = getters.getBigSegments(user.getKey()); if (queryResult == null) { // The SDK hasn't been configured to be able to use big segments - bigSegmentsState.bigSegmentsStatus = EvaluationReason.BigSegmentsStatus.NOT_CONFIGURED; + state.bigSegmentsStatus = EvaluationReason.BigSegmentsStatus.NOT_CONFIGURED; } else { - bigSegmentsState.bigSegmentsStatus = queryResult.status; - bigSegmentsState.bigSegmentsMembership = queryResult.membership; + state.bigSegmentsStatus = queryResult.status; + state.bigSegmentsMembership = queryResult.membership; } } - Boolean membership = bigSegmentsState.bigSegmentsMembership == null ? - null : bigSegmentsState.bigSegmentsMembership.checkMembership(makeBigSegmentRef(segment)); + Boolean membership = state.bigSegmentsMembership == null ? + null : state.bigSegmentsMembership.checkMembership(makeBigSegmentRef(segment)); if (membership != null) { return membership; } @@ -451,14 +372,7 @@ private boolean segmentRuleMatchesUser(DataModel.SegmentRule segmentRule, LDUser return bucket < weight; } - private static EvalResult targetMatchResult(FeatureFlag flag, Target target) { - if (target.preprocessed != null) { - return new EvalResult(target.preprocessed.targetMatchResult); - } - return new EvalResult(resultForVariation(target.getVariation(), flag, EvaluationReason.targetMatch())); - } - - private static EvalResult ruleMatchResult(FeatureFlag flag, LDUser user, Rule rule, int ruleIndex) { + private static EvalResult computeRuleMatch(FeatureFlag flag, LDUser user, Rule rule, int ruleIndex) { if (rule.preprocessed != null) { return getValueForVariationOrRollout(flag, rule, user, rule.preprocessed.allPossibleResults, null); } diff --git a/src/main/java/com/launchdarkly/sdk/server/EvaluatorHelpers.java b/src/main/java/com/launchdarkly/sdk/server/EvaluatorHelpers.java index d2822c8cf..68b53de0e 100644 --- a/src/main/java/com/launchdarkly/sdk/server/EvaluatorHelpers.java +++ b/src/main/java/com/launchdarkly/sdk/server/EvaluatorHelpers.java @@ -5,11 +5,56 @@ import com.launchdarkly.sdk.EvaluationReason.ErrorKind; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.server.DataModel.FeatureFlag; +import com.launchdarkly.sdk.server.DataModel.Prerequisite; +import com.launchdarkly.sdk.server.DataModel.Target; import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; +/** + * Low-level helpers for producing various kinds of evaluation results. + *

              + * For all of the methods that return an {@link EvalResult}, the behavior is as follows: + * First we check if the flag data contains a preprocessed value for this kind of result; if + * so, we return that same EvalResult instance, for efficiency. That will normally always be + * the case, because preprocessing happens as part of deserializing a flag. But if somehow + * no preprocessed value is available, we construct one less efficiently on the fly. (The + * reason we can't absolutely guarantee that the preprocessed data is available, by putting + * it in a constructor, is because of how deserialization works: Gson doesn't pass values to + * a constructor, it sets fields directly, so we have to run our preprocessing logic after.) + */ abstract class EvaluatorHelpers { - static EvaluationDetail resultForVariation(int variation, FeatureFlag flag, EvaluationReason reason) { + static EvalResult offResult(FeatureFlag flag) { + if (flag.preprocessed != null) { + return flag.preprocessed.offResult; + } + return EvalResult.of(evaluationDetailForOffVariation(flag, EvaluationReason.off())); + } + + static EvalResult targetMatchResult(FeatureFlag flag, Target target) { + if (target.preprocessed != null) { + return target.preprocessed.targetMatchResult; + } + return EvalResult.of(evaluationDetailForVariation( + flag, target.getVariation(), EvaluationReason.targetMatch())); + } + + static EvalResult prerequisiteFailedResult(FeatureFlag flag, Prerequisite prereq) { + if (prereq.preprocessed != null) { + return prereq.preprocessed.prerequisiteFailedResult; + } + return EvalResult.of(evaluationDetailForOffVariation( + flag, EvaluationReason.prerequisiteFailed(prereq.getKey()))); + } + + static EvaluationDetail evaluationDetailForOffVariation(FeatureFlag flag, EvaluationReason reason) { + Integer offVariation = flag.getOffVariation(); + if (offVariation == null) { // off variation unspecified - return default value + return EvaluationDetail.fromValue(LDValue.ofNull(), NO_VARIATION, reason); + } + return evaluationDetailForVariation(flag, offVariation, reason); + } + + static EvaluationDetail evaluationDetailForVariation(FeatureFlag flag, int variation, EvaluationReason reason) { if (variation < 0 || variation >= flag.getVariations().size()) { return EvaluationDetail.fromValue(LDValue.ofNull(), NO_VARIATION, EvaluationReason.error(ErrorKind.MALFORMED_FLAG)); diff --git a/src/main/java/com/launchdarkly/sdk/server/EventFactory.java b/src/main/java/com/launchdarkly/sdk/server/EventFactory.java index 73a815e6e..bc06127fd 100644 --- a/src/main/java/com/launchdarkly/sdk/server/EventFactory.java +++ b/src/main/java/com/launchdarkly/sdk/server/EventFactory.java @@ -5,7 +5,6 @@ import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.server.DataModel.FeatureFlag; -import com.launchdarkly.sdk.server.DataModel.VariationOrRollout; import com.launchdarkly.sdk.server.interfaces.Event; import com.launchdarkly.sdk.server.interfaces.Event.Custom; import com.launchdarkly.sdk.server.interfaces.Event.FeatureRequest; @@ -23,6 +22,7 @@ abstract Event.FeatureRequest newFeatureRequestEvent( LDValue value, int variationIndex, EvaluationReason reason, + boolean forceReasonTracking, LDValue defaultValue, String prereqOf ); @@ -43,15 +43,16 @@ abstract Event.FeatureRequest newUnknownFeatureRequestEvent( final Event.FeatureRequest newFeatureRequestEvent( DataModel.FeatureFlag flag, LDUser user, - Evaluator.EvalResult details, + EvalResult result, LDValue defaultValue ) { return newFeatureRequestEvent( flag, user, - details == null ? null : details.getValue(), - details == null ? -1 : details.getVariationIndex(), - details == null ? null : details.getReason(), + result == null ? null : result.getValue(), + result == null ? -1 : result.getVariationIndex(), + result == null ? null : result.getReason(), + result != null && result.isForceReasonTracking(), defaultValue, null ); @@ -69,6 +70,7 @@ final Event.FeatureRequest newDefaultFeatureRequestEvent( defaultVal, -1, EvaluationReason.error(errorKind), + false, defaultVal, null ); @@ -77,15 +79,16 @@ final Event.FeatureRequest newDefaultFeatureRequestEvent( final Event.FeatureRequest newPrerequisiteFeatureRequestEvent( DataModel.FeatureFlag prereqFlag, LDUser user, - Evaluator.EvalResult details, + EvalResult result, DataModel.FeatureFlag prereqOf ) { return newFeatureRequestEvent( prereqFlag, user, - details == null ? null : details.getValue(), - details == null ? -1 : details.getVariationIndex(), - details == null ? null : details.getReason(), + result == null ? null : result.getValue(), + result == null ? -1 : result.getVariationIndex(), + result == null ? null : result.getReason(), + result != null && result.isForceReasonTracking(), LDValue.ofNull(), prereqOf.getKey() ); @@ -118,9 +121,9 @@ static class Default extends EventFactory { } @Override - final Event.FeatureRequest newFeatureRequestEvent(DataModel.FeatureFlag flag, LDUser user, LDValue value, - int variationIndex, EvaluationReason reason, LDValue defaultValue, String prereqOf){ - boolean requireExperimentData = isExperiment(flag, reason); + final Event.FeatureRequest newFeatureRequestEvent(DataModel.FeatureFlag flag, LDUser user, + LDValue value, int variationIndex, EvaluationReason reason, boolean forceReasonTracking, + LDValue defaultValue, String prereqOf){ return new Event.FeatureRequest( timestampFn.get(), flag.getKey(), @@ -129,9 +132,9 @@ final Event.FeatureRequest newFeatureRequestEvent(DataModel.FeatureFlag flag, LD variationIndex, value, defaultValue, - (requireExperimentData || includeReasons) ? reason : null, + (forceReasonTracking || includeReasons) ? reason : null, prereqOf, - requireExperimentData || flag.isTrackEvents(), + forceReasonTracking || flag.isTrackEvents(), flag.getDebugEventsUntilDate() == null ? 0 : flag.getDebugEventsUntilDate().longValue(), false ); @@ -181,7 +184,7 @@ static final class Disabled extends EventFactory { @Override final FeatureRequest newFeatureRequestEvent(FeatureFlag flag, LDUser user, LDValue value, int variationIndex, - EvaluationReason reason, LDValue defaultValue, String prereqOf) { + EvaluationReason reason, boolean inExperiment, LDValue defaultValue, String prereqOf) { return null; } @@ -205,32 +208,4 @@ Event.AliasEvent newAliasEvent(LDUser user, LDUser previousUser) { return null; } } - - static boolean isExperiment(DataModel.FeatureFlag flag, EvaluationReason reason) { - if (reason == null) { - // doesn't happen in real life, but possible in testing - return false; - } - - // If the reason says we're in an experiment, we are. Otherwise, apply - // the legacy rule exclusion logic. - if (reason.isInExperiment()) return true; - - switch (reason.getKind()) { - case FALLTHROUGH: - return flag.isTrackEventsFallthrough(); - case RULE_MATCH: - int ruleIndex = reason.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()) { - DataModel.Rule rule = flag.getRules().get(ruleIndex); - return rule.isTrackEvents(); - } - return false; - default: - return false; - } - } } diff --git a/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java b/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java index b0a38c552..071ee5ee0 100644 --- a/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java +++ b/src/main/java/com/launchdarkly/sdk/server/FeatureFlagsState.java @@ -265,16 +265,15 @@ public Builder add( return this; } - Builder addFlag(DataModel.FeatureFlag flag, Evaluator.EvalResult eval) { - boolean requireExperimentData = EventFactory.isExperiment(flag, eval.getReason()); + Builder addFlag(DataModel.FeatureFlag flag, EvalResult eval) { return add( flag.getKey(), eval.getValue(), - eval.getDetails().isDefaultValue() ? null : eval.getVariationIndex(), + eval.isNoVariation() ? null : eval.getVariationIndex(), eval.getReason(), flag.getVersion(), - flag.isTrackEvents() || requireExperimentData, - requireExperimentData, + flag.isTrackEvents() || eval.isForceReasonTracking(), + eval.isForceReasonTracking(), flag.getDebugEventsUntilDate() ); } diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClient.java b/src/main/java/com/launchdarkly/sdk/server/LDClient.java index 8aca767f2..ceebec328 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClient.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClient.java @@ -12,6 +12,7 @@ import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.LDValueType; +import com.launchdarkly.sdk.server.DataModel.FeatureFlag; import com.launchdarkly.sdk.server.integrations.EventProcessorBuilder; import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreStatusProvider; import com.launchdarkly.sdk.server.interfaces.BigSegmentsConfiguration; @@ -69,6 +70,8 @@ public final class LDClient implements LDClientInterface { private final ScheduledExecutorService sharedExecutor; private final EventFactory eventFactoryDefault; private final EventFactory eventFactoryWithReasons; + private final Evaluator.PrerequisiteEvaluationSink prereqEvalsDefault; + private final Evaluator.PrerequisiteEvaluationSink prereqEvalsWithReasons; /** * Creates a new client instance that connects to LaunchDarkly with the default configuration. @@ -250,6 +253,9 @@ public BigSegmentStoreWrapper.BigSegmentsQueryResult getBigSegments(String key) this.dataSource = config.dataSourceFactory.createDataSource(context, dataSourceUpdates); this.dataSourceStatusProvider = new DataSourceStatusProviderImpl(dataSourceStatusNotifier, dataSourceUpdates); + this.prereqEvalsDefault = makePrerequisiteEventSender(false); + this.prereqEvalsWithReasons = makePrerequisiteEventSender(true); + Future startFuture = dataSource.start(); if (!config.startWait.isZero() && !config.startWait.isNegative()) { if (!(dataSource instanceof ComponentsImpl.NullDataSource)) { @@ -353,12 +359,12 @@ public FeatureFlagsState allFlagsState(LDUser user, FlagsStateOption... options) continue; } try { - Evaluator.EvalResult result = evaluator.evaluate(flag, user, eventFactoryDefault); + EvalResult result = evaluator.evaluate(flag, user, prereqEvalsDefault); builder.addFlag(flag, result); } catch (Exception e) { Loggers.EVALUATION.error("Exception caught for feature flag \"{}\" when evaluating all flags: {}", entry.getKey(), e.toString()); Loggers.EVALUATION.debug(e.toString(), e); - builder.addFlag(flag, new Evaluator.EvalResult(LDValue.ofNull(), NO_VARIATION, EvaluationReason.exception(e))); + builder.addFlag(flag, EvalResult.of(LDValue.ofNull(), NO_VARIATION, EvaluationReason.exception(e))); } } return builder.build(); @@ -391,40 +397,36 @@ public LDValue jsonValueVariation(String featureKey, LDUser user, LDValue defaul @Override public EvaluationDetail boolVariationDetail(String featureKey, LDUser user, boolean defaultValue) { - Evaluator.EvalResult result = evaluateInternal(featureKey, user, LDValue.of(defaultValue), - LDValueType.BOOLEAN, eventFactoryWithReasons); - return EvaluationDetail.fromValue(result.getValue().booleanValue(), - result.getVariationIndex(), result.getReason()); + EvalResult result = evaluateInternal(featureKey, user, LDValue.of(defaultValue), + LDValueType.BOOLEAN, true); + return result.getAsBoolean(); } @Override public EvaluationDetail intVariationDetail(String featureKey, LDUser user, int defaultValue) { - Evaluator.EvalResult result = evaluateInternal(featureKey, user, LDValue.of(defaultValue), - LDValueType.NUMBER, eventFactoryWithReasons); - return EvaluationDetail.fromValue(result.getValue().intValue(), - result.getVariationIndex(), result.getReason()); + EvalResult result = evaluateInternal(featureKey, user, LDValue.of(defaultValue), + LDValueType.NUMBER, true); + return result.getAsInteger(); } @Override public EvaluationDetail doubleVariationDetail(String featureKey, LDUser user, double defaultValue) { - Evaluator.EvalResult result = evaluateInternal(featureKey, user, LDValue.of(defaultValue), - LDValueType.NUMBER, eventFactoryWithReasons); - return EvaluationDetail.fromValue(result.getValue().doubleValue(), - result.getVariationIndex(), result.getReason()); + EvalResult result = evaluateInternal(featureKey, user, LDValue.of(defaultValue), + LDValueType.NUMBER, true); + return result.getAsDouble(); } @Override public EvaluationDetail stringVariationDetail(String featureKey, LDUser user, String defaultValue) { - Evaluator.EvalResult result = evaluateInternal(featureKey, user, LDValue.of(defaultValue), - LDValueType.STRING, eventFactoryWithReasons); - return EvaluationDetail.fromValue(result.getValue().stringValue(), - result.getVariationIndex(), result.getReason()); + EvalResult result = evaluateInternal(featureKey, user, LDValue.of(defaultValue), + LDValueType.STRING, true); + return result.getAsString(); } @Override public EvaluationDetail jsonValueVariationDetail(String featureKey, LDUser user, LDValue defaultValue) { - Evaluator.EvalResult result = evaluateInternal(featureKey, user, LDValue.normalize(defaultValue), - null, eventFactoryWithReasons); + EvalResult result = evaluateInternal(featureKey, user, LDValue.normalize(defaultValue), + null, true); return EvaluationDetail.fromValue(result.getValue(), result.getVariationIndex(), result.getReason()); } @@ -452,15 +454,16 @@ public boolean isFlagKnown(String featureKey) { } private LDValue evaluate(String featureKey, LDUser user, LDValue defaultValue, LDValueType requireType) { - return evaluateInternal(featureKey, user, defaultValue, requireType, eventFactoryDefault).getValue(); + return evaluateInternal(featureKey, user, defaultValue, requireType, false).getValue(); } - private Evaluator.EvalResult errorResult(EvaluationReason.ErrorKind errorKind, final LDValue defaultValue) { - return new Evaluator.EvalResult(defaultValue, NO_VARIATION, EvaluationReason.error(errorKind)); + private EvalResult errorResult(EvaluationReason.ErrorKind errorKind, final LDValue defaultValue) { + return EvalResult.of(defaultValue, NO_VARIATION, EvaluationReason.error(errorKind)); } - private Evaluator.EvalResult evaluateInternal(String featureKey, LDUser user, LDValue defaultValue, - LDValueType requireType, EventFactory eventFactory) { + private EvalResult evaluateInternal(String featureKey, LDUser user, LDValue defaultValue, + LDValueType requireType, boolean withDetail) { + EventFactory eventFactory = withDetail ? eventFactoryWithReasons : eventFactoryDefault; if (!isInitialized()) { if (dataStore.isInitialized()) { Loggers.EVALUATION.warn("Evaluation called before client initialized for feature flag \"{}\"; using last known values from data store", featureKey); @@ -490,14 +493,10 @@ private Evaluator.EvalResult evaluateInternal(String featureKey, LDUser user, LD if (user.getKey().isEmpty()) { Loggers.EVALUATION.warn("User key is blank. Flag evaluation will proceed, but the user will not be stored in LaunchDarkly"); } - Evaluator.EvalResult evalResult = evaluator.evaluate(featureFlag, user, eventFactory); - for (Event.FeatureRequest event : evalResult.getPrerequisiteEvents()) { - eventProcessor.sendEvent(event); - } - if (evalResult.getDetails().isDefaultValue()) { - evalResult.setDetails( - EvaluationDetail.fromValue(defaultValue, - evalResult.getDetails().getVariationIndex(), evalResult.getDetails().getReason())); + EvalResult evalResult = evaluator.evaluate(featureFlag, user, + withDetail ? prereqEvalsWithReasons : prereqEvalsDefault); + if (evalResult.isNoVariation()) { + evalResult = EvalResult.of(defaultValue, evalResult.getVariationIndex(), evalResult.getReason()); } else { LDValue value = evalResult.getValue(); // guaranteed not to be an actual Java null, but can be LDValue.ofNull() if (requireType != null && @@ -521,7 +520,7 @@ private Evaluator.EvalResult evaluateInternal(String featureKey, LDUser user, LD sendFlagRequestEvent(eventFactory.newDefaultFeatureRequestEvent(featureFlag, user, defaultValue, EvaluationReason.ErrorKind.EXCEPTION)); } - return new Evaluator.EvalResult(defaultValue, NO_VARIATION, EvaluationReason.exception(e)); + return EvalResult.of(defaultValue, NO_VARIATION, EvaluationReason.exception(e)); } } @@ -612,4 +611,15 @@ private ScheduledExecutorService createSharedExecutor(LDConfig config) { .build(); return Executors.newSingleThreadScheduledExecutor(threadFactory); } + + private Evaluator.PrerequisiteEvaluationSink makePrerequisiteEventSender(boolean withReasons) { + final EventFactory factory = withReasons ? eventFactoryWithReasons : eventFactoryDefault; + return new Evaluator.PrerequisiteEvaluationSink() { + @Override + public void recordPrerequisiteEvaluation(FeatureFlag flag, FeatureFlag prereqOfFlag, LDUser user, EvalResult result) { + eventProcessor.sendEvent( + factory.newPrerequisiteFeatureRequestEvent(flag, user, result, prereqOfFlag)); + } + }; + } } diff --git a/src/test/java/com/launchdarkly/sdk/server/DataModelPreprocessingTest.java b/src/test/java/com/launchdarkly/sdk/server/DataModelPreprocessingTest.java index 61bc9a3df..ea6171e1b 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataModelPreprocessingTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataModelPreprocessingTest.java @@ -57,7 +57,7 @@ public void preprocessFlagAddsPrecomputedOffResult() { assertThat(f.preprocessed, notNullValue()); assertThat(f.preprocessed.offResult, - equalTo(EvaluationDetail.fromValue(aValue, 0, EvaluationReason.off()))); + equalTo(EvalResult.of(aValue, 0, EvaluationReason.off()))); } @Test @@ -72,7 +72,7 @@ public void preprocessFlagAddsPrecomputedOffResultForNullOffVariation() { assertThat(f.preprocessed, notNullValue()); assertThat(f.preprocessed.offResult, - equalTo(EvaluationDetail.fromValue(LDValue.ofNull(), EvaluationDetail.NO_VARIATION, EvaluationReason.off()))); + equalTo(EvalResult.of(LDValue.ofNull(), EvaluationDetail.NO_VARIATION, EvaluationReason.off()))); } @Test @@ -90,14 +90,14 @@ public void preprocessFlagAddsPrecomputedFallthroughResults() { EvaluationReason inExperimentReason = EvaluationReason.fallthrough(true); assertThat(f.preprocessed.fallthroughResults.forVariation(0, false), - equalTo(EvaluationDetail.fromValue(aValue, 0, regularReason))); + equalTo(EvalResult.of(aValue, 0, regularReason))); assertThat(f.preprocessed.fallthroughResults.forVariation(0, true), - equalTo(EvaluationDetail.fromValue(aValue, 0, inExperimentReason))); + equalTo(EvalResult.of(aValue, 0, inExperimentReason))); assertThat(f.preprocessed.fallthroughResults.forVariation(1, false), - equalTo(EvaluationDetail.fromValue(bValue, 1, regularReason))); + equalTo(EvalResult.of(bValue, 1, regularReason))); assertThat(f.preprocessed.fallthroughResults.forVariation(1, true), - equalTo(EvaluationDetail.fromValue(bValue, 1, inExperimentReason))); + equalTo(EvalResult.of(bValue, 1, inExperimentReason))); } @Test @@ -113,7 +113,7 @@ public void preprocessFlagAddsPrecomputedTargetMatchResults() { Target t = f.getTargets().get(0); assertThat(t.preprocessed, notNullValue()); assertThat(t.preprocessed.targetMatchResult, - equalTo(EvaluationDetail.fromValue(bValue, 1, EvaluationReason.targetMatch()))); + equalTo(EvalResult.of(bValue, 1, EvaluationReason.targetMatch()))); } @Test @@ -130,7 +130,7 @@ public void preprocessFlagAddsPrecomputedPrerequisiteFailedResults() { Prerequisite p = f.getPrerequisites().get(0); assertThat(p.preprocessed, notNullValue()); assertThat(p.preprocessed.prerequisiteFailedResult, - equalTo(EvaluationDetail.fromValue(aValue, 0, EvaluationReason.prerequisiteFailed("abc")))); + equalTo(EvalResult.of(aValue, 0, EvaluationReason.prerequisiteFailed("abc")))); } @Test @@ -150,14 +150,14 @@ public void preprocessFlagAddsPrecomputedResultsToFlagRules() { EvaluationReason inExperimentReason = EvaluationReason.ruleMatch(0, "ruleid0", true); assertThat(rule.preprocessed.allPossibleResults.forVariation(0, false), - equalTo(EvaluationDetail.fromValue(aValue, 0, regularReason))); + equalTo(EvalResult.of(aValue, 0, regularReason))); assertThat(rule.preprocessed.allPossibleResults.forVariation(0, true), - equalTo(EvaluationDetail.fromValue(aValue, 0, inExperimentReason))); + equalTo(EvalResult.of(aValue, 0, inExperimentReason))); assertThat(rule.preprocessed.allPossibleResults.forVariation(1, false), - equalTo(EvaluationDetail.fromValue(bValue, 1, regularReason))); + equalTo(EvalResult.of(bValue, 1, regularReason))); assertThat(rule.preprocessed.allPossibleResults.forVariation(1, true), - equalTo(EvaluationDetail.fromValue(bValue, 1, inExperimentReason))); + equalTo(EvalResult.of(bValue, 1, inExperimentReason))); } @Test diff --git a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorOutputTest.java b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorOutputTest.java index 90b1cb602..4088bc6fa 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorOutputTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DefaultEventProcessorOutputTest.java @@ -197,7 +197,7 @@ public void featureEventCanContainReason() throws Exception { DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); EvaluationReason reason = EvaluationReason.ruleMatch(1, null); Event.FeatureRequest fe = EventFactory.DEFAULT_WITH_REASONS.newFeatureRequestEvent(flag, user, - new Evaluator.EvalResult(LDValue.of("value"), 1, reason), LDValue.ofNull()); + EvalResult.of(LDValue.of("value"), 1, reason), LDValue.ofNull()); try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { ep.sendEvent(fe); diff --git a/src/test/java/com/launchdarkly/sdk/server/EvalResultTest.java b/src/test/java/com/launchdarkly/sdk/server/EvalResultTest.java new file mode 100644 index 000000000..a3987486f --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/EvalResultTest.java @@ -0,0 +1,157 @@ +package com.launchdarkly.sdk.server; + +import java.util.function.Function; + +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDValue; + +import org.junit.Test; + +import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; +import static com.launchdarkly.sdk.EvaluationReason.ErrorKind.WRONG_TYPE; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.sameInstance; + +public class EvalResultTest { + private static final LDValue SOME_VALUE = LDValue.of("value"); + private static final LDValue ARRAY_VALUE = LDValue.arrayOf(); + private static final LDValue OBJECT_VALUE = LDValue.buildObject().build(); + private static final int SOME_VARIATION = 11; + private static final EvaluationReason SOME_REASON = EvaluationReason.fallthrough(); + + @Test + public void getValue() { + assertThat(EvalResult.of(EvaluationDetail.fromValue(SOME_VALUE, SOME_VARIATION, SOME_REASON)).getValue(), + equalTo(SOME_VALUE)); + assertThat(EvalResult.of(SOME_VALUE, SOME_VARIATION, SOME_REASON).getValue(), + equalTo(SOME_VALUE)); + } + + @Test + public void getVariationIndex() { + assertThat(EvalResult.of(EvaluationDetail.fromValue(SOME_VALUE, SOME_VARIATION, SOME_REASON)).getVariationIndex(), + equalTo(SOME_VARIATION)); + assertThat(EvalResult.of(SOME_VALUE, SOME_VARIATION, SOME_REASON).getVariationIndex(), + equalTo(SOME_VARIATION)); + } + + @Test + public void getReason() { + assertThat(EvalResult.of(EvaluationDetail.fromValue(SOME_VALUE, SOME_VARIATION, SOME_REASON)).getReason(), + equalTo(SOME_REASON)); + assertThat(EvalResult.of(SOME_VALUE, SOME_VARIATION, SOME_REASON).getReason(), + equalTo(SOME_REASON)); + } + + @Test + public void isNoVariation() { + assertThat(EvalResult.of(EvaluationDetail.fromValue(SOME_VALUE, SOME_VARIATION, SOME_REASON)).isNoVariation(), + is(false)); + assertThat(EvalResult.of(SOME_VALUE, SOME_VARIATION, SOME_REASON).isNoVariation(), + is(false)); + + assertThat(EvalResult.of(EvaluationDetail.fromValue(SOME_VALUE, NO_VARIATION, SOME_REASON)).isNoVariation(), + is(true)); + assertThat(EvalResult.of(SOME_VALUE, NO_VARIATION, SOME_REASON).isNoVariation(), + is(true)); + } + + @Test + public void getAnyType() { + testForType(SOME_VALUE, SOME_VALUE, r -> r.getAnyType()); + } + + @Test + public void getAsBoolean() { + testForType(true, LDValue.of(true), r -> r.getAsBoolean()); + + testWrongType(false, LDValue.ofNull(), r -> r.getAsBoolean()); + testWrongType(false, LDValue.of(1), r -> r.getAsBoolean()); + testWrongType(false, LDValue.of("a"), r -> r.getAsBoolean()); + testWrongType(false, ARRAY_VALUE, r -> r.getAsBoolean()); + testWrongType(false, OBJECT_VALUE, r -> r.getAsBoolean()); + } + + @Test + public void getAsInteger() { + testForType(99, LDValue.of(99), r -> r.getAsInteger()); + testForType(99, LDValue.of(99.25), r -> r.getAsInteger()); + + testWrongType(0, LDValue.ofNull(), r -> r.getAsInteger()); + testWrongType(0, LDValue.of(true), r -> r.getAsInteger()); + testWrongType(0, LDValue.of("a"), r -> r.getAsInteger()); + testWrongType(0, ARRAY_VALUE, r -> r.getAsInteger()); + testWrongType(0, OBJECT_VALUE, r -> r.getAsInteger()); + } + + @Test + public void getAsDouble() { + testForType((double)99, LDValue.of(99), r -> r.getAsDouble()); + testForType((double)99.25, LDValue.of(99.25), r -> r.getAsDouble()); + + testWrongType((double)0, LDValue.ofNull(), r -> r.getAsDouble()); + testWrongType((double)0, LDValue.of(true), r -> r.getAsDouble()); + testWrongType((double)0, LDValue.of("a"), r -> r.getAsDouble()); + testWrongType((double)0, ARRAY_VALUE, r -> r.getAsDouble()); + testWrongType((double)0, OBJECT_VALUE, r -> r.getAsDouble()); + } + + @Test + public void getAsString() { + testForType("a", LDValue.of("a"), r -> r.getAsString()); + testForType((String)null, LDValue.ofNull(), r -> r.getAsString()); + + testWrongType((String)null, LDValue.of(true), r -> r.getAsString()); + testWrongType((String)null, LDValue.of(1), r -> r.getAsString()); + testWrongType((String)null, ARRAY_VALUE, r -> r.getAsString()); + testWrongType((String)null, OBJECT_VALUE, r -> r.getAsString()); + } + + @Test + public void withReason() { + EvalResult r = EvalResult.of(LDValue.of(true), SOME_VARIATION, EvaluationReason.fallthrough()); + + EvalResult r1 = r.withReason(EvaluationReason.off()); + assertThat(r1.getReason(), equalTo(EvaluationReason.off())); + assertThat(r1.getValue(), equalTo(r.getValue())); + assertThat(r1.getVariationIndex(), equalTo(r.getVariationIndex())); + } + + @Test + public void withForceReasonTracking() { + EvalResult r = EvalResult.of(SOME_VALUE, SOME_VARIATION, SOME_REASON); + assertThat(r.isForceReasonTracking(), is(false)); + + EvalResult r0 = r.withForceReasonTracking(false); + assertThat(r0, sameInstance(r)); + + EvalResult r1 = r.withForceReasonTracking(true); + assertThat(r1.isForceReasonTracking(), is(true)); + assertThat(r1.getAnyType(), sameInstance(r.getAnyType())); + } + + private void testForType(T value, LDValue ldValue, Function getter) { + assertThat( + getter.apply(EvalResult.of(EvaluationDetail.fromValue(ldValue, SOME_VARIATION, SOME_REASON))), + equalTo(EvaluationDetail.fromValue(value, SOME_VARIATION, SOME_REASON)) + ); + assertThat( + getter.apply(EvalResult.of(EvaluationDetail.fromValue(ldValue, SOME_VARIATION, SOME_REASON))), + equalTo(EvaluationDetail.fromValue(value, SOME_VARIATION, SOME_REASON)) + ); + } + + private void testWrongType(T value, LDValue ldValue, Function getter) { + assertThat( + getter.apply(EvalResult.of(EvaluationDetail.fromValue(ldValue, SOME_VARIATION, SOME_REASON))), + equalTo(EvaluationDetail.fromValue(value, EvaluationDetail.NO_VARIATION, EvaluationReason.error(WRONG_TYPE))) + ); + assertThat( + getter.apply(EvalResult.of(EvaluationDetail.fromValue(ldValue, SOME_VARIATION, SOME_REASON))), + equalTo(EvaluationDetail.fromValue(value, EvaluationDetail.NO_VARIATION, EvaluationReason.error(WRONG_TYPE))) + ); + } +} diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorBigSegmentTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorBigSegmentTest.java index 0ce40e8f8..88a587c0e 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EvaluatorBigSegmentTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorBigSegmentTest.java @@ -1,7 +1,16 @@ package com.launchdarkly.sdk.server; +import com.launchdarkly.sdk.EvaluationReason.BigSegmentsStatus; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; + +import org.junit.Test; + +import java.util.Collections; + import static com.launchdarkly.sdk.server.Evaluator.makeBigSegmentRef; import static com.launchdarkly.sdk.server.EvaluatorTestUtil.evaluatorBuilder; +import static com.launchdarkly.sdk.server.EvaluatorTestUtil.expectNoPrerequisiteEvals; import static com.launchdarkly.sdk.server.ModelBuilders.booleanFlagWithClauses; import static com.launchdarkly.sdk.server.ModelBuilders.clauseMatchingSegment; import static com.launchdarkly.sdk.server.ModelBuilders.clauseMatchingUser; @@ -15,15 +24,6 @@ import static org.easymock.EasyMock.strictMock; import static org.junit.Assert.assertEquals; -import com.launchdarkly.sdk.EvaluationReason.BigSegmentsStatus; -import com.launchdarkly.sdk.LDUser; -import com.launchdarkly.sdk.LDValue; -import com.launchdarkly.sdk.server.Evaluator.EvalResult; - -import org.junit.Test; - -import java.util.Collections; - @SuppressWarnings("javadoc") public class EvaluatorBigSegmentTest { private static final LDUser testUser = new LDUser("userkey"); @@ -36,7 +36,7 @@ public void bigSegmentWithNoProviderIsNotMatched() { DataModel.FeatureFlag flag = booleanFlagWithClauses("key", clauseMatchingSegment(segment)); Evaluator evaluator = evaluatorBuilder().withStoredSegments(segment).withBigSegmentQueryResult(testUser.getKey(), null).build(); - EvalResult result = evaluator.evaluate(flag, testUser, EventFactory.DEFAULT); + EvalResult result = evaluator.evaluate(flag, testUser, expectNoPrerequisiteEvals()); assertEquals(LDValue.of(false), result.getValue()); assertEquals(BigSegmentsStatus.NOT_CONFIGURED, result.getReason().getBigSegmentsStatus()); } @@ -48,7 +48,7 @@ public void bigSegmentWithNoGenerationIsNotMatched() { DataModel.FeatureFlag flag = booleanFlagWithClauses("key", clauseMatchingSegment(segment)); Evaluator evaluator = evaluatorBuilder().withStoredSegments(segment).build(); - EvalResult result = evaluator.evaluate(flag, testUser, EventFactory.DEFAULT); + EvalResult result = evaluator.evaluate(flag, testUser, expectNoPrerequisiteEvals()); assertEquals(LDValue.of(false), result.getValue()); assertEquals(BigSegmentsStatus.NOT_CONFIGURED, result.getReason().getBigSegmentsStatus()); } @@ -62,7 +62,7 @@ public void matchedWithInclude() { queryResult.membership = createMembershipFromSegmentRefs(Collections.singleton(makeBigSegmentRef(segment)), null); Evaluator evaluator = evaluatorBuilder().withStoredSegments(segment).withBigSegmentQueryResult(testUser.getKey(), queryResult).build(); - EvalResult result = evaluator.evaluate(flag, testUser, EventFactory.DEFAULT); + EvalResult result = evaluator.evaluate(flag, testUser, expectNoPrerequisiteEvals()); assertEquals(LDValue.of(true), result.getValue()); assertEquals(BigSegmentsStatus.HEALTHY, result.getReason().getBigSegmentsStatus()); } @@ -80,7 +80,7 @@ public void matchedWithRule() { queryResult.membership = createMembershipFromSegmentRefs(null, null); Evaluator evaluator = evaluatorBuilder().withStoredSegments(segment).withBigSegmentQueryResult(testUser.getKey(), queryResult).build(); - EvalResult result = evaluator.evaluate(flag, testUser, EventFactory.DEFAULT); + EvalResult result = evaluator.evaluate(flag, testUser, expectNoPrerequisiteEvals()); assertEquals(LDValue.of(true), result.getValue()); assertEquals(BigSegmentsStatus.HEALTHY, result.getReason().getBigSegmentsStatus()); } @@ -98,7 +98,7 @@ public void unmatchedByExcludeRegardlessOfRule() { queryResult.membership = createMembershipFromSegmentRefs(null, Collections.singleton(makeBigSegmentRef(segment))); Evaluator evaluator = evaluatorBuilder().withStoredSegments(segment).withBigSegmentQueryResult(testUser.getKey(), queryResult).build(); - EvalResult result = evaluator.evaluate(flag, testUser, EventFactory.DEFAULT); + EvalResult result = evaluator.evaluate(flag, testUser, expectNoPrerequisiteEvals()); assertEquals(LDValue.of(false), result.getValue()); assertEquals(BigSegmentsStatus.HEALTHY, result.getReason().getBigSegmentsStatus()); } @@ -112,7 +112,7 @@ public void bigSegmentStatusIsReturnedFromProvider() { queryResult.membership = createMembershipFromSegmentRefs(Collections.singleton(makeBigSegmentRef(segment)), null); Evaluator evaluator = evaluatorBuilder().withStoredSegments(segment).withBigSegmentQueryResult(testUser.getKey(), queryResult).build(); - EvalResult result = evaluator.evaluate(flag, testUser, EventFactory.DEFAULT); + EvalResult result = evaluator.evaluate(flag, testUser, expectNoPrerequisiteEvals()); assertEquals(LDValue.of(true), result.getValue()); assertEquals(BigSegmentsStatus.STALE, result.getReason().getBigSegmentsStatus()); } @@ -142,7 +142,7 @@ public void bigSegmentStateIsQueriedOnlyOncePerUserEvenIfFlagReferencesMultipleS replay(mockGetters); Evaluator evaluator = new Evaluator(mockGetters); - EvalResult result = evaluator.evaluate(flag, testUser, EventFactory.DEFAULT); + EvalResult result = evaluator.evaluate(flag, testUser, expectNoPrerequisiteEvals()); assertEquals(LDValue.of(true), result.getValue()); assertEquals(BigSegmentsStatus.HEALTHY, result.getReason().getBigSegmentsStatus()); } diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorBucketingTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorBucketingTest.java index f2ca34451..fcadc8e0c 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EvaluatorBucketingTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorBucketingTest.java @@ -9,7 +9,6 @@ import com.launchdarkly.sdk.server.DataModel.Rollout; import com.launchdarkly.sdk.server.DataModel.RolloutKind; import com.launchdarkly.sdk.server.DataModel.WeightedVariation; -import com.launchdarkly.sdk.server.Evaluator.EvalResult; import org.junit.Test; @@ -17,6 +16,7 @@ import java.util.List; import static com.launchdarkly.sdk.server.EvaluatorTestUtil.BASE_EVALUATOR; +import static com.launchdarkly.sdk.server.EvaluatorTestUtil.expectNoPrerequisiteEvals; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.greaterThanOrEqualTo; @@ -155,7 +155,7 @@ private static void assertVariationIndexFromRollout( .fallthrough(rollout) .salt(salt) .build(); - EvalResult result1 = BASE_EVALUATOR.evaluate(flag1, user, EventFactory.DEFAULT); + EvalResult result1 = BASE_EVALUATOR.evaluate(flag1, user, expectNoPrerequisiteEvals()); assertThat(result1.getReason(), equalTo(EvaluationReason.fallthrough())); assertThat(result1.getVariationIndex(), equalTo(expectedVariation)); @@ -169,7 +169,7 @@ private static void assertVariationIndexFromRollout( .build()) .salt(salt) .build(); - EvalResult result2 = BASE_EVALUATOR.evaluate(flag2, user, EventFactory.DEFAULT); + EvalResult result2 = BASE_EVALUATOR.evaluate(flag2, user, expectNoPrerequisiteEvals()); assertThat(result2.getReason().getKind(), equalTo(EvaluationReason.Kind.RULE_MATCH)); assertThat(result2.getVariationIndex(), equalTo(expectedVariation)); } diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorClauseTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorClauseTest.java index 01e24eb22..880f821d3 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EvaluatorClauseTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorClauseTest.java @@ -11,6 +11,7 @@ import static com.launchdarkly.sdk.EvaluationDetail.fromValue; import static com.launchdarkly.sdk.server.EvaluatorTestUtil.BASE_EVALUATOR; import static com.launchdarkly.sdk.server.EvaluatorTestUtil.evaluatorBuilder; +import static com.launchdarkly.sdk.server.EvaluatorTestUtil.expectNoPrerequisiteEvals; import static com.launchdarkly.sdk.server.ModelBuilders.booleanFlagWithClauses; import static com.launchdarkly.sdk.server.ModelBuilders.clause; import static com.launchdarkly.sdk.server.ModelBuilders.fallthroughVariation; @@ -25,7 +26,7 @@ @SuppressWarnings("javadoc") public class EvaluatorClauseTest { private static void assertMatch(Evaluator eval, DataModel.FeatureFlag flag, LDUser user, boolean expectMatch) { - assertEquals(LDValue.of(expectMatch), eval.evaluate(flag, user, EventFactory.DEFAULT).getDetails().getValue()); + assertEquals(LDValue.of(expectMatch), eval.evaluate(flag, user, expectNoPrerequisiteEvals()).getValue()); } private static DataModel.Segment makeSegmentThatMatchesUser(String segmentKey, String userKey) { @@ -195,7 +196,7 @@ public void clauseWithNullOperatorDoesNotStopSubsequentRuleFromMatching() throws .build(); LDUser user = new LDUser.Builder("key").name("Bob").build(); - EvaluationDetail details = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT).getDetails(); + EvaluationDetail details = BASE_EVALUATOR.evaluate(f, user, expectNoPrerequisiteEvals()).getAnyType(); assertEquals(fromValue(LDValue.of(true), 1, EvaluationReason.ruleMatch(1, "rule2")), details); } diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorRuleTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorRuleTest.java index e579d44cc..10825353d 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EvaluatorRuleTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorRuleTest.java @@ -1,6 +1,5 @@ package com.launchdarkly.sdk.server; -import com.launchdarkly.sdk.EvaluationDetail; import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; @@ -11,12 +10,11 @@ import org.junit.Test; import static com.launchdarkly.sdk.server.EvaluatorTestUtil.BASE_EVALUATOR; +import static com.launchdarkly.sdk.server.EvaluatorTestUtil.expectNoPrerequisiteEvals; import static com.launchdarkly.sdk.server.ModelBuilders.clause; import static com.launchdarkly.sdk.server.ModelBuilders.emptyRollout; import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; import static com.launchdarkly.sdk.server.ModelBuilders.ruleBuilder; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.emptyIterable; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotSame; import static org.junit.Assert.assertNull; @@ -51,14 +49,14 @@ public void ruleMatchResultInstanceIsReusedForSameRule() { LDUser user = new LDUser.Builder("userkey").build(); LDUser otherUser = new LDUser.Builder("wrongkey").build(); - Evaluator.EvalResult sameResult0 = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); - Evaluator.EvalResult sameResult1 = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); - Evaluator.EvalResult otherResult = BASE_EVALUATOR.evaluate(f, otherUser, EventFactory.DEFAULT); + EvalResult sameResult0 = BASE_EVALUATOR.evaluate(f, user, expectNoPrerequisiteEvals()); + EvalResult sameResult1 = BASE_EVALUATOR.evaluate(f, user, expectNoPrerequisiteEvals()); + EvalResult otherResult = BASE_EVALUATOR.evaluate(f, otherUser, expectNoPrerequisiteEvals()); - assertEquals(EvaluationReason.ruleMatch(1, "ruleid1"), sameResult0.getDetails().getReason()); - assertSame(sameResult0.getDetails(), sameResult1.getDetails()); + assertEquals(EvaluationReason.ruleMatch(1, "ruleid1"), sameResult0.getReason()); + assertSame(sameResult0, sameResult1); - assertEquals(EvaluationReason.ruleMatch(0, "ruleid0"), otherResult.getDetails().getReason()); + assertEquals(EvaluationReason.ruleMatch(0, "ruleid0"), otherResult.getReason()); } @Test @@ -74,12 +72,12 @@ public void ruleMatchResultInstanceCanBeCreatedFromScratch() { .build(); assertNull(f.getRules().get(0).preprocessed); - Evaluator.EvalResult result1 = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); - Evaluator.EvalResult result2 = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); + EvalResult result1 = BASE_EVALUATOR.evaluate(f, user, expectNoPrerequisiteEvals()); + EvalResult result2 = BASE_EVALUATOR.evaluate(f, user, expectNoPrerequisiteEvals()); - assertEquals(EvaluationReason.ruleMatch(0, "ruleid"), result1.getDetails().getReason()); - assertNotSame(result1.getDetails(), result2.getDetails()); // they were created individually - assertEquals(result1.getDetails(), result2.getDetails()); // but they're equal + assertEquals(EvaluationReason.ruleMatch(0, "ruleid"), result1.getReason()); + assertNotSame(result1, result2); // they were created individually + assertEquals(result1, result2); // but they're equal } @Test @@ -88,10 +86,9 @@ public void ruleWithTooHighVariationReturnsMalformedFlagError() { DataModel.Rule rule = buildTestRule("ruleid", clause).variation(999).build(); DataModel.FeatureFlag f = buildBooleanFlagWithRules("feature", rule).build(); LDUser user = new LDUser.Builder("userkey").build(); - Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); + EvalResult result = BASE_EVALUATOR.evaluate(f, user, expectNoPrerequisiteEvals()); - assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); - assertThat(result.getPrerequisiteEvents(), emptyIterable()); + assertEquals(EvalResult.error(EvaluationReason.ErrorKind.MALFORMED_FLAG), result); } @Test @@ -100,10 +97,9 @@ public void ruleWithNegativeVariationReturnsMalformedFlagError() { DataModel.Rule rule = buildTestRule("ruleid", clause).variation(-1).build(); DataModel.FeatureFlag f = buildBooleanFlagWithRules("feature", rule).build(); LDUser user = new LDUser.Builder("userkey").build(); - Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); + EvalResult result = BASE_EVALUATOR.evaluate(f, user, expectNoPrerequisiteEvals()); - assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); - assertThat(result.getPrerequisiteEvents(), emptyIterable()); + assertEquals(EvalResult.error(EvaluationReason.ErrorKind.MALFORMED_FLAG), result); } @Test @@ -112,10 +108,9 @@ public void ruleWithNoVariationOrRolloutReturnsMalformedFlagError() { DataModel.Rule rule = buildTestRule("ruleid", clause).variation(null).build(); DataModel.FeatureFlag f = buildBooleanFlagWithRules("feature", rule).build(); LDUser user = new LDUser.Builder("userkey").build(); - Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); + EvalResult result = BASE_EVALUATOR.evaluate(f, user, expectNoPrerequisiteEvals()); - assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); - assertThat(result.getPrerequisiteEvents(), emptyIterable()); + assertEquals(EvalResult.error(EvaluationReason.ErrorKind.MALFORMED_FLAG), result); } @Test @@ -124,9 +119,8 @@ public void ruleWithRolloutWithEmptyVariationsListReturnsMalformedFlagError() { DataModel.Rule rule = buildTestRule("ruleid", clause).variation(null).rollout(emptyRollout()).build(); DataModel.FeatureFlag f = buildBooleanFlagWithRules("feature", rule).build(); LDUser user = new LDUser.Builder("userkey").build(); - Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); + EvalResult result = BASE_EVALUATOR.evaluate(f, user, expectNoPrerequisiteEvals()); - assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); - assertThat(result.getPrerequisiteEvents(), emptyIterable()); + assertEquals(EvalResult.error(EvaluationReason.ErrorKind.MALFORMED_FLAG), result); } } diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorSegmentMatchTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorSegmentMatchTest.java index 90b02a9bc..e5f730f22 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EvaluatorSegmentMatchTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorSegmentMatchTest.java @@ -7,6 +7,7 @@ import org.junit.Test; import static com.launchdarkly.sdk.server.EvaluatorTestUtil.evaluatorBuilder; +import static com.launchdarkly.sdk.server.EvaluatorTestUtil.expectNoPrerequisiteEvals; import static com.launchdarkly.sdk.server.ModelBuilders.booleanFlagWithClauses; import static com.launchdarkly.sdk.server.ModelBuilders.clause; import static com.launchdarkly.sdk.server.ModelBuilders.segmentBuilder; @@ -114,6 +115,6 @@ private static boolean segmentMatchesUser(DataModel.Segment segment, LDUser user DataModel.Clause clause = clause(null, DataModel.Operator.segmentMatch, LDValue.of(segment.getKey())); DataModel.FeatureFlag flag = booleanFlagWithClauses("flag", clause); Evaluator e = evaluatorBuilder().withStoredSegments(segment).build(); - return e.evaluate(flag, user, EventFactory.DEFAULT).getValue().booleanValue(); + return e.evaluate(flag, user, expectNoPrerequisiteEvals()).getValue().booleanValue(); } } \ No newline at end of file diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java index 2c3ecbbdb..9ddd3411d 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java @@ -1,7 +1,6 @@ package com.launchdarkly.sdk.server; import com.google.common.collect.Iterables; -import com.launchdarkly.sdk.EvaluationDetail; import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; @@ -10,24 +9,24 @@ import com.launchdarkly.sdk.server.DataModel.RolloutKind; import com.launchdarkly.sdk.server.DataModel.VariationOrRollout; import com.launchdarkly.sdk.server.DataModel.WeightedVariation; +import com.launchdarkly.sdk.server.EvaluatorTestUtil.PrereqEval; +import com.launchdarkly.sdk.server.EvaluatorTestUtil.PrereqRecorder; import com.launchdarkly.sdk.server.ModelBuilders.FlagBuilder; -import com.launchdarkly.sdk.server.interfaces.Event; + +import org.junit.Test; import java.util.ArrayList; import java.util.List; -import org.junit.Test; import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; -import static com.launchdarkly.sdk.EvaluationDetail.fromValue; import static com.launchdarkly.sdk.server.EvaluatorTestUtil.BASE_EVALUATOR; import static com.launchdarkly.sdk.server.EvaluatorTestUtil.evaluatorBuilder; +import static com.launchdarkly.sdk.server.EvaluatorTestUtil.expectNoPrerequisiteEvals; import static com.launchdarkly.sdk.server.ModelBuilders.clause; import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; import static com.launchdarkly.sdk.server.ModelBuilders.prerequisite; import static com.launchdarkly.sdk.server.ModelBuilders.ruleBuilder; import static com.launchdarkly.sdk.server.ModelBuilders.target; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.emptyIterable; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotSame; import static org.junit.Assert.assertNull; @@ -87,19 +86,17 @@ private static int versionFromKey(String flagKey) { @Test public void evaluationReturnsErrorIfUserIsNull() throws Exception { DataModel.FeatureFlag f = flagBuilder("feature").build(); - Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, null, EventFactory.DEFAULT); + EvalResult result = BASE_EVALUATOR.evaluate(f, null, expectNoPrerequisiteEvals()); - assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.USER_NOT_SPECIFIED, null), result.getDetails()); - assertThat(result.getPrerequisiteEvents(), emptyIterable()); + assertEquals(EvalResult.error(EvaluationReason.ErrorKind.USER_NOT_SPECIFIED), result); } @Test public void evaluationReturnsErrorIfUserKeyIsNull() throws Exception { DataModel.FeatureFlag f = flagBuilder("feature").build(); - Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, new LDUser(null), EventFactory.DEFAULT); + EvalResult result = BASE_EVALUATOR.evaluate(f, new LDUser(null), expectNoPrerequisiteEvals()); - assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.USER_NOT_SPECIFIED, null), result.getDetails()); - assertThat(result.getPrerequisiteEvents(), emptyIterable()); + assertEquals(EvalResult.error(EvaluationReason.ErrorKind.USER_NOT_SPECIFIED), result); } @Test @@ -107,10 +104,9 @@ public void flagReturnsOffVariationIfFlagIsOff() throws Exception { DataModel.FeatureFlag f = buildThreeWayFlag("feature") .on(false) .build(); - Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, expectNoPrerequisiteEvals()); - assertEquals(fromValue(OFF_VALUE, OFF_VARIATION, EvaluationReason.off()), result.getDetails()); - assertThat(result.getPrerequisiteEvents(), emptyIterable()); + assertEquals(EvalResult.of(OFF_VALUE, OFF_VARIATION, EvaluationReason.off()), result); } @Test @@ -119,10 +115,9 @@ public void flagReturnsNullIfFlagIsOffAndOffVariationIsUnspecified() throws Exce .on(false) .offVariation(null) .build(); - Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, expectNoPrerequisiteEvals()); - assertEquals(fromValue(LDValue.ofNull(), NO_VARIATION, EvaluationReason.off()), result.getDetails()); - assertThat(result.getPrerequisiteEvents(), emptyIterable()); + assertEquals(EvalResult.of(LDValue.ofNull(), NO_VARIATION, EvaluationReason.off()), result); } @Test @@ -131,10 +126,9 @@ public void flagReturnsErrorIfFlagIsOffAndOffVariationIsTooHigh() throws Excepti .on(false) .offVariation(999) .build(); - Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, expectNoPrerequisiteEvals()); - assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); - assertThat(result.getPrerequisiteEvents(), emptyIterable()); + assertEquals(EvalResult.error(EvaluationReason.ErrorKind.MALFORMED_FLAG), result); } @Test @@ -143,10 +137,9 @@ public void flagReturnsErrorIfFlagIsOffAndOffVariationIsNegative() throws Except .on(false) .offVariation(-1) .build(); - Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, expectNoPrerequisiteEvals()); - assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); - assertThat(result.getPrerequisiteEvents(), emptyIterable()); + assertEquals(EvalResult.error(EvaluationReason.ErrorKind.MALFORMED_FLAG), result); } @Test @@ -158,7 +151,7 @@ public void flagReturnsInExperimentForFallthroughWhenInExperimentVariation() thr .on(true) .fallthrough(vr) .build(); - Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, expectNoPrerequisiteEvals()); assert(result.getReason().isInExperiment()); } @@ -172,7 +165,7 @@ public void flagReturnsNotInExperimentForFallthroughWhenNotInExperimentVariation .on(true) .fallthrough(vr) .build(); - Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, expectNoPrerequisiteEvals()); assert(!result.getReason().isInExperiment()); } @@ -186,7 +179,7 @@ public void flagReturnsNotInExperimentForFallthrougWhenInExperimentVariationButN .on(true) .fallthrough(vr) .build(); - Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, expectNoPrerequisiteEvals()); assert(!result.getReason().isInExperiment()); } @@ -202,7 +195,7 @@ public void flagReturnsInExperimentForRuleMatchWhenInExperimentVariation() throw .on(true) .rules(rule) .build(); - Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, expectNoPrerequisiteEvals()); assert(result.getReason().isInExperiment()); } @@ -218,7 +211,7 @@ public void flagReturnsNotInExperimentForRuleMatchWhenNotInExperimentVariation() .on(true) .rules(rule) .build(); - Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, expectNoPrerequisiteEvals()); assert(!result.getReason().isInExperiment()); } @@ -228,10 +221,23 @@ public void flagReturnsFallthroughIfFlagIsOnAndThereAreNoRules() throws Exceptio DataModel.FeatureFlag f = buildThreeWayFlag("feature") .on(true) .build(); - Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, expectNoPrerequisiteEvals()); + + assertEquals(EvalResult.of(FALLTHROUGH_VALUE, FALLTHROUGH_VARIATION, EvaluationReason.fallthrough()), result); + } + + @Test + public void fallthroughResultHasForceReasonTrackingTrueIfTrackEventsFallthroughIstrue() throws Exception { + DataModel.FeatureFlag f = buildThreeWayFlag("feature") + .on(true) + .trackEventsFallthrough(true) + .build(); + EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, expectNoPrerequisiteEvals()); - assertEquals(fromValue(FALLTHROUGH_VALUE, FALLTHROUGH_VARIATION, EvaluationReason.fallthrough()), result.getDetails()); - assertThat(result.getPrerequisiteEvents(), emptyIterable()); + assertEquals( + EvalResult.of(FALLTHROUGH_VALUE, FALLTHROUGH_VARIATION, EvaluationReason.fallthrough()) + .withForceReasonTracking(true), + result); } @Test @@ -240,10 +246,9 @@ public void flagReturnsErrorIfFallthroughHasTooHighVariation() throws Exception .on(true) .fallthroughVariation(999) .build(); - Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, expectNoPrerequisiteEvals()); - assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); - assertThat(result.getPrerequisiteEvents(), emptyIterable()); + assertEquals(EvalResult.error(EvaluationReason.ErrorKind.MALFORMED_FLAG), result); } @Test @@ -252,10 +257,9 @@ public void flagReturnsErrorIfFallthroughHasNegativeVariation() throws Exception .on(true) .fallthroughVariation(-1) .build(); - Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, expectNoPrerequisiteEvals()); - assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); - assertThat(result.getPrerequisiteEvents(), emptyIterable()); + assertEquals(EvalResult.error(EvaluationReason.ErrorKind.MALFORMED_FLAG), result); } @Test @@ -264,10 +268,9 @@ public void flagReturnsErrorIfFallthroughHasNeitherVariationNorRollout() throws .on(true) .fallthrough(new DataModel.VariationOrRollout(null, null)) .build(); - Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, expectNoPrerequisiteEvals()); - assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); - assertThat(result.getPrerequisiteEvents(), emptyIterable()); + assertEquals(EvalResult.error(EvaluationReason.ErrorKind.MALFORMED_FLAG), result); } @Test @@ -276,10 +279,9 @@ public void flagReturnsErrorIfFallthroughHasEmptyRolloutVariationList() throws E .on(true) .fallthrough(new DataModel.VariationOrRollout(null, ModelBuilders.emptyRollout())) .build(); - Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, expectNoPrerequisiteEvals()); - assertEquals(EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, null), result.getDetails()); - assertThat(result.getPrerequisiteEvents(), emptyIterable()); + assertEquals(EvalResult.error(EvaluationReason.ErrorKind.MALFORMED_FLAG), result); } @Test @@ -289,11 +291,10 @@ public void flagReturnsOffVariationIfPrerequisiteIsNotFound() throws Exception { .prerequisites(prerequisite("feature1", 1)) .build(); Evaluator e = evaluatorBuilder().withNonexistentFlag("feature1").build(); - Evaluator.EvalResult result = e.evaluate(f0, BASE_USER, EventFactory.DEFAULT); + EvalResult result = e.evaluate(f0, BASE_USER, expectNoPrerequisiteEvals()); EvaluationReason expectedReason = EvaluationReason.prerequisiteFailed("feature1"); - assertEquals(fromValue(OFF_VALUE, OFF_VARIATION, expectedReason), result.getDetails()); - assertThat(result.getPrerequisiteEvents(), emptyIterable()); + assertEquals(EvalResult.of(OFF_VALUE, OFF_VARIATION, expectedReason), result); } @Test @@ -308,18 +309,18 @@ public void flagReturnsOffVariationAndEventIfPrerequisiteIsOff() throws Exceptio // note that even though it returns the desired variation, it is still off and therefore not a match .build(); Evaluator e = evaluatorBuilder().withStoredFlags(f1).build(); - Evaluator.EvalResult result = e.evaluate(f0, BASE_USER, EventFactory.DEFAULT); + PrereqRecorder recordPrereqs = new PrereqRecorder(); + EvalResult result = e.evaluate(f0, BASE_USER, recordPrereqs); EvaluationReason expectedReason = EvaluationReason.prerequisiteFailed("feature1"); - assertEquals(fromValue(OFF_VALUE, OFF_VARIATION, expectedReason), result.getDetails()); - - assertEquals(1, Iterables.size(result.getPrerequisiteEvents())); - Event.FeatureRequest event = Iterables.get(result.getPrerequisiteEvents(), 0); - assertEquals(f1.getKey(), event.getKey()); - assertEquals(GREEN_VARIATION, event.getVariation()); - assertEquals(GREEN_VALUE, event.getValue()); - assertEquals(f1.getVersion(), event.getVersion()); - assertEquals(f0.getKey(), event.getPrereqOf()); + assertEquals(EvalResult.of(OFF_VALUE, OFF_VARIATION, expectedReason), result); + + assertEquals(1, Iterables.size(recordPrereqs.evals)); + PrereqEval eval = recordPrereqs.evals.get(0); + assertEquals(f1, eval.flag); + assertEquals(f0, eval.prereqOfFlag); + assertEquals(GREEN_VARIATION, eval.result.getVariationIndex()); + assertEquals(GREEN_VALUE, eval.result.getValue()); } @Test @@ -333,33 +334,33 @@ public void flagReturnsOffVariationAndEventIfPrerequisiteIsNotMet() throws Excep .fallthroughVariation(RED_VARIATION) .build(); Evaluator e = evaluatorBuilder().withStoredFlags(f1).build(); - Evaluator.EvalResult result = e.evaluate(f0, BASE_USER, EventFactory.DEFAULT); + PrereqRecorder recordPrereqs = new PrereqRecorder(); + EvalResult result = e.evaluate(f0, BASE_USER, recordPrereqs); EvaluationReason expectedReason = EvaluationReason.prerequisiteFailed("feature1"); - assertEquals(fromValue(OFF_VALUE, OFF_VARIATION, expectedReason), result.getDetails()); - - assertEquals(1, Iterables.size(result.getPrerequisiteEvents())); - Event.FeatureRequest event = Iterables.get(result.getPrerequisiteEvents(), 0); - assertEquals(f1.getKey(), event.getKey()); - assertEquals(RED_VARIATION, event.getVariation()); - assertEquals(RED_VALUE, event.getValue()); - assertEquals(f1.getVersion(), event.getVersion()); - assertEquals(f0.getKey(), event.getPrereqOf()); + assertEquals(EvalResult.of(OFF_VALUE, OFF_VARIATION, expectedReason), result); + + assertEquals(1, Iterables.size(recordPrereqs.evals)); + PrereqEval eval = recordPrereqs.evals.get(0); + assertEquals(f1, eval.flag); + assertEquals(f0, eval.prereqOfFlag); + assertEquals(RED_VARIATION, eval.result.getVariationIndex()); + assertEquals(RED_VALUE, eval.result.getValue()); } @Test - public void prerequisiteFailedReasonInstanceIsReusedForSamePrerequisite() throws Exception { + public void prerequisiteFailedResultInstanceIsReusedForSamePrerequisite() throws Exception { DataModel.FeatureFlag f0 = buildThreeWayFlag("feature") .on(true) .prerequisites(prerequisite("feature1", GREEN_VARIATION)) .build(); Evaluator e = evaluatorBuilder().withNonexistentFlag("feature1").build(); - Evaluator.EvalResult result0 = e.evaluate(f0, BASE_USER, EventFactory.DEFAULT); - Evaluator.EvalResult result1 = e.evaluate(f0, BASE_USER, EventFactory.DEFAULT); + EvalResult result0 = e.evaluate(f0, BASE_USER, expectNoPrerequisiteEvals()); + EvalResult result1 = e.evaluate(f0, BASE_USER, expectNoPrerequisiteEvals()); EvaluationReason expectedReason = EvaluationReason.prerequisiteFailed("feature1"); - assertEquals(expectedReason, result0.getDetails().getReason()); - assertSame(result0.getDetails().getReason(), result1.getDetails().getReason()); + assertEquals(expectedReason, result0.getReason()); + assertSame(result0, result1); } @Test @@ -374,13 +375,13 @@ public void prerequisiteFailedReasonInstanceCanBeCreatedFromScratch() throws Exc assertNull(f0.getPrerequisites().get(0).preprocessed); Evaluator e = evaluatorBuilder().withNonexistentFlag("feature1").build(); - Evaluator.EvalResult result0 = e.evaluate(f0, BASE_USER, EventFactory.DEFAULT); - Evaluator.EvalResult result1 = e.evaluate(f0, BASE_USER, EventFactory.DEFAULT); + EvalResult result0 = e.evaluate(f0, BASE_USER, expectNoPrerequisiteEvals()); + EvalResult result1 = e.evaluate(f0, BASE_USER, expectNoPrerequisiteEvals()); EvaluationReason expectedReason = EvaluationReason.prerequisiteFailed("feature1"); - assertEquals(expectedReason, result0.getDetails().getReason()); - assertNotSame(result0.getDetails().getReason(), result1.getDetails().getReason()); // they were created individually - assertEquals(result0.getDetails().getReason(), result1.getDetails().getReason()); // but they're equal + assertEquals(expectedReason, result0.getReason()); + assertNotSame(result0.getReason(), result1.getReason()); // they were created individually + assertEquals(result0.getReason(), result1.getReason()); // but they're equal } @Test @@ -395,17 +396,17 @@ public void flagReturnsFallthroughVariationAndEventIfPrerequisiteIsMetAndThereAr .version(2) .build(); Evaluator e = evaluatorBuilder().withStoredFlags(f1).build(); - Evaluator.EvalResult result = e.evaluate(f0, BASE_USER, EventFactory.DEFAULT); + PrereqRecorder recordPrereqs = new PrereqRecorder(); + EvalResult result = e.evaluate(f0, BASE_USER, recordPrereqs); - assertEquals(fromValue(FALLTHROUGH_VALUE, FALLTHROUGH_VARIATION, EvaluationReason.fallthrough()), result.getDetails()); + assertEquals(EvalResult.of(FALLTHROUGH_VALUE, FALLTHROUGH_VARIATION, EvaluationReason.fallthrough()), result); - assertEquals(1, Iterables.size(result.getPrerequisiteEvents())); - Event.FeatureRequest event = Iterables.get(result.getPrerequisiteEvents(), 0); - assertEquals(f1.getKey(), event.getKey()); - assertEquals(GREEN_VARIATION, event.getVariation()); - assertEquals(GREEN_VALUE, event.getValue()); - assertEquals(f1.getVersion(), event.getVersion()); - assertEquals(f0.getKey(), event.getPrereqOf()); + assertEquals(1, Iterables.size(recordPrereqs.evals)); + PrereqEval eval = recordPrereqs.evals.get(0); + assertEquals(f1, eval.flag); + assertEquals(f0, eval.prereqOfFlag); + assertEquals(GREEN_VARIATION, eval.result.getVariationIndex()); + assertEquals(GREEN_VALUE, eval.result.getValue()); } @Test @@ -424,24 +425,24 @@ public void multipleLevelsOfPrerequisitesProduceMultipleEvents() throws Exceptio .fallthroughVariation(GREEN_VARIATION) .build(); Evaluator e = evaluatorBuilder().withStoredFlags(f1, f2).build(); - Evaluator.EvalResult result = e.evaluate(f0, BASE_USER, EventFactory.DEFAULT); + PrereqRecorder recordPrereqs = new PrereqRecorder(); + EvalResult result = e.evaluate(f0, BASE_USER, recordPrereqs); - assertEquals(fromValue(FALLTHROUGH_VALUE, FALLTHROUGH_VARIATION, EvaluationReason.fallthrough()), result.getDetails()); - assertEquals(2, Iterables.size(result.getPrerequisiteEvents())); + assertEquals(EvalResult.of(FALLTHROUGH_VALUE, FALLTHROUGH_VARIATION, EvaluationReason.fallthrough()), result); + + assertEquals(2, Iterables.size(recordPrereqs.evals)); - Event.FeatureRequest event0 = Iterables.get(result.getPrerequisiteEvents(), 0); - assertEquals(f2.getKey(), event0.getKey()); - assertEquals(GREEN_VARIATION, event0.getVariation()); - assertEquals(GREEN_VALUE, event0.getValue()); - assertEquals(f2.getVersion(), event0.getVersion()); - assertEquals(f1.getKey(), event0.getPrereqOf()); + PrereqEval eval0 = recordPrereqs.evals.get(0); + assertEquals(f2, eval0.flag); + assertEquals(f1, eval0.prereqOfFlag); + assertEquals(GREEN_VARIATION, eval0.result.getVariationIndex()); + assertEquals(GREEN_VALUE, eval0.result.getValue()); - Event.FeatureRequest event1 = Iterables.get(result.getPrerequisiteEvents(), 1); - assertEquals(f1.getKey(), event1.getKey()); - assertEquals(GREEN_VARIATION, event1.getVariation()); - assertEquals(GREEN_VALUE, event1.getValue()); - assertEquals(f1.getVersion(), event1.getVersion()); - assertEquals(f0.getKey(), event1.getPrereqOf()); + PrereqEval eval1 = recordPrereqs.evals.get(1); + assertEquals(f1, eval1.flag); + assertEquals(f0, eval1.prereqOfFlag); + assertEquals(GREEN_VARIATION, eval1.result.getVariationIndex()); + assertEquals(GREEN_VALUE, eval1.result.getValue()); } @Test @@ -451,10 +452,9 @@ public void flagMatchesUserFromTargets() throws Exception { .targets(target(2, "whoever", "userkey")) .build(); LDUser user = new LDUser.Builder("userkey").build(); - Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); + EvalResult result = BASE_EVALUATOR.evaluate(f, user, expectNoPrerequisiteEvals()); - assertEquals(fromValue(MATCH_VALUE, MATCH_VARIATION, EvaluationReason.targetMatch()), result.getDetails()); - assertThat(result.getPrerequisiteEvents(), emptyIterable()); + assertEquals(EvalResult.of(MATCH_VALUE, MATCH_VARIATION, EvaluationReason.targetMatch()), result); } @Test @@ -470,16 +470,37 @@ public void flagMatchesUserFromRules() { .build(); LDUser user = new LDUser.Builder("userkey").build(); - Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, user, EventFactory.DEFAULT); + EvalResult result = BASE_EVALUATOR.evaluate(f, user, expectNoPrerequisiteEvals()); + + assertEquals(EvalResult.of(MATCH_VALUE, MATCH_VARIATION, EvaluationReason.ruleMatch(1, "ruleid1")), result); + } + + @Test + public void ruleMatchReasonHasTrackReasonTrueIfRuleLevelTrackEventsIsTrue() { + DataModel.Clause clause0 = clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of("wrongkey")); + DataModel.Clause clause1 = clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of("userkey")); + DataModel.Rule rule0 = ruleBuilder().id("ruleid0").clauses(clause0).variation(2).build(); + DataModel.Rule rule1 = ruleBuilder().id("ruleid1").clauses(clause1).variation(2) + .trackEvents(true).build(); + + DataModel.FeatureFlag f = buildThreeWayFlag("feature") + .on(true) + .rules(rule0, rule1) + .build(); + + LDUser user = new LDUser.Builder("userkey").build(); + EvalResult result = BASE_EVALUATOR.evaluate(f, user, expectNoPrerequisiteEvals()); - assertEquals(fromValue(MATCH_VALUE, MATCH_VARIATION, EvaluationReason.ruleMatch(1, "ruleid1")), result.getDetails()); - assertThat(result.getPrerequisiteEvents(), emptyIterable()); + assertEquals( + EvalResult.of(MATCH_VALUE, MATCH_VARIATION, EvaluationReason.ruleMatch(1, "ruleid1")) + .withForceReasonTracking(true), + result); } @Test(expected=RuntimeException.class) public void canSimulateErrorUsingTestInstrumentationFlagKey() { // Other tests rely on the ability to simulate an exception in this way DataModel.FeatureFlag badFlag = flagBuilder(Evaluator.INVALID_FLAG_KEY_THAT_THROWS_EXCEPTION).build(); - BASE_EVALUATOR.evaluate(badFlag, BASE_USER, EventFactory.DEFAULT); + BASE_EVALUATOR.evaluate(badFlag, BASE_USER, expectNoPrerequisiteEvals()); } } diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorTestUtil.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTestUtil.java index af105256a..4a71fcd71 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EvaluatorTestUtil.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTestUtil.java @@ -1,8 +1,12 @@ package com.launchdarkly.sdk.server; +import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.server.BigSegmentStoreWrapper.BigSegmentsQueryResult; +import com.launchdarkly.sdk.server.DataModel.FeatureFlag; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; @SuppressWarnings("javadoc") public abstract class EvaluatorTestUtil { @@ -71,4 +75,34 @@ public EvaluatorBuilder withBigSegmentQueryResult(final String userKey, BigSegme return this; } } + + public static Evaluator.PrerequisiteEvaluationSink expectNoPrerequisiteEvals() { + return (f1, f2, u, r) -> { + throw new AssertionError("did not expect any prerequisite evaluations, but got one"); + }; + } + + public static final class PrereqEval { + public final FeatureFlag flag; + public final FeatureFlag prereqOfFlag; + public final LDUser user; + public final EvalResult result; + + public PrereqEval(FeatureFlag flag, FeatureFlag prereqOfFlag, LDUser user, EvalResult result) { + this.flag = flag; + this.prereqOfFlag = prereqOfFlag; + this.user = user; + this.result = result; + } + } + + public static final class PrereqRecorder implements Evaluator.PrerequisiteEvaluationSink { + public final List evals = new ArrayList(); + + @Override + public void recordPrerequisiteEvaluation(FeatureFlag flag, FeatureFlag prereqOfFlag, LDUser user, + EvalResult result) { + evals.add(new PrereqEval(flag, prereqOfFlag, user, result)); + } + } } diff --git a/src/test/java/com/launchdarkly/sdk/server/EventFactoryTest.java b/src/test/java/com/launchdarkly/sdk/server/EventFactoryTest.java index ca50c76ca..213d710d0 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EventFactoryTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EventFactoryTest.java @@ -1,80 +1,113 @@ package com.launchdarkly.sdk.server; -import com.launchdarkly.sdk.server.DataModel.Rollout; -import com.launchdarkly.sdk.server.DataModel.RolloutKind; -import com.launchdarkly.sdk.server.DataModel.VariationOrRollout; -import com.launchdarkly.sdk.server.DataModel.WeightedVariation; +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.DataModel.FeatureFlag; import com.launchdarkly.sdk.server.interfaces.Event.FeatureRequest; import org.junit.Test; -import static com.launchdarkly.sdk.server.ModelBuilders.*; - -import java.util.ArrayList; -import java.util.List; - -import com.launchdarkly.sdk.EvaluationReason; -import com.launchdarkly.sdk.LDUser; -import com.launchdarkly.sdk.LDValue; -import com.launchdarkly.sdk.UserAttribute; +import static com.launchdarkly.sdk.server.ModelBuilders.flagBuilder; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; public class EventFactoryTest { private static final LDUser BASE_USER = new LDUser.Builder("x").build(); - private static Rollout buildRollout(boolean isExperiment, boolean untrackedVariations) { - List variations = new ArrayList<>(); - variations.add(new WeightedVariation(1, 50000, untrackedVariations)); - variations.add(new WeightedVariation(2, 50000, untrackedVariations)); - UserAttribute bucketBy = UserAttribute.KEY; - RolloutKind kind = isExperiment ? RolloutKind.experiment : RolloutKind.rollout; - Integer seed = 123; - Rollout rollout = new Rollout(variations, bucketBy, kind, seed); - return rollout; + private static final LDValue SOME_VALUE = LDValue.of("value"); + private static final int SOME_VARIATION = 11; + private static final EvaluationReason SOME_REASON = EvaluationReason.fallthrough(); + private static final EvalResult SOME_RESULT = EvalResult.of(SOME_VALUE, SOME_VARIATION, SOME_REASON); + private static final LDValue DEFAULT_VALUE = LDValue.of("default"); + + @Test + public void flagKeyIsSetInFeatureEvent() { + FeatureFlag flag = flagBuilder("flagkey").build(); + FeatureRequest fr = EventFactory.DEFAULT.newFeatureRequestEvent(flag, BASE_USER, SOME_RESULT, DEFAULT_VALUE); + + assertEquals(flag.getKey(), fr.getKey()); } @Test - public void trackEventFalseTest() { - DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(false).build(); - LDUser user = new LDUser("moniker"); - FeatureRequest fr = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, null, null); + public void flagVersionIsSetInFeatureEvent() { + FeatureFlag flag = flagBuilder("flagkey").build(); + FeatureRequest fr = EventFactory.DEFAULT.newFeatureRequestEvent(flag, BASE_USER, SOME_RESULT, DEFAULT_VALUE); - assert(!fr.isTrackEvents()); + assertEquals(flag.getVersion(), fr.getVersion()); } + + @Test + public void userIsSetInFeatureEvent() { + FeatureFlag flag = flagBuilder("flagkey").build(); + FeatureRequest fr = EventFactory.DEFAULT.newFeatureRequestEvent(flag, BASE_USER, SOME_RESULT, DEFAULT_VALUE); + assertEquals(BASE_USER, fr.getUser()); + } + @Test - public void trackEventTrueTest() { - DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); - LDUser user = new LDUser("moniker"); - FeatureRequest fr = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, null, null); + public void valueIsSetInFeatureEvent() { + FeatureFlag flag = flagBuilder("flagkey").build(); + FeatureRequest fr = EventFactory.DEFAULT.newFeatureRequestEvent(flag, BASE_USER, SOME_RESULT, DEFAULT_VALUE); - assert(fr.isTrackEvents()); + assertEquals(SOME_VALUE, fr.getValue()); + } + + @Test + public void variationIsSetInFeatureEvent() { + FeatureFlag flag = flagBuilder("flagkey").build(); + FeatureRequest fr = EventFactory.DEFAULT.newFeatureRequestEvent(flag, BASE_USER, SOME_RESULT, DEFAULT_VALUE); + + assertEquals(SOME_VARIATION, fr.getVariation()); + } + + @Test + public void reasonIsNormallyNotIncludedWithDefaultEventFactory() { + FeatureFlag flag = flagBuilder("flagkey").build(); + FeatureRequest fr = EventFactory.DEFAULT.newFeatureRequestEvent(flag, BASE_USER, SOME_RESULT, DEFAULT_VALUE); + + assertNull(fr.getReason()); + } + + @Test + public void reasonIsIncludedWithEventFactoryThatIsConfiguredToIncludedReasons() { + FeatureFlag flag = flagBuilder("flagkey").build(); + FeatureRequest fr = EventFactory.DEFAULT_WITH_REASONS.newFeatureRequestEvent( + flag, BASE_USER, SOME_RESULT, DEFAULT_VALUE); + + assertEquals(SOME_REASON, fr.getReason()); } @Test - public void trackEventTrueWhenTrackEventsFalseButExperimentFallthroughReasonTest() { - Rollout rollout = buildRollout(true, false); - VariationOrRollout vr = new VariationOrRollout(null, rollout); + public void reasonIsIncludedIfForceReasonTrackingIsTrue() { + FeatureFlag flag = flagBuilder("flagkey").build(); + FeatureRequest fr = EventFactory.DEFAULT.newFeatureRequestEvent(flag, BASE_USER, + SOME_RESULT.withForceReasonTracking(true), DEFAULT_VALUE); - DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(false) - .fallthrough(vr).build(); - LDUser user = new LDUser("moniker"); - FeatureRequest fr = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, null, 0, - EvaluationReason.fallthrough(true), null, null); + assertEquals(SOME_REASON, fr.getReason()); + } + @Test + public void trackEventsIsNormallyFalse() { + FeatureFlag flag = flagBuilder("flagkey").build(); + FeatureRequest fr = EventFactory.DEFAULT.newFeatureRequestEvent(flag, BASE_USER, SOME_RESULT, DEFAULT_VALUE); + + assert(!fr.isTrackEvents()); + } + + @Test + public void trackEventsIsTrueIfItIsTrueInFlag() { + FeatureFlag flag = flagBuilder("flagkey") + .trackEvents(true) + .build(); + FeatureRequest fr = EventFactory.DEFAULT.newFeatureRequestEvent(flag, BASE_USER, SOME_RESULT, DEFAULT_VALUE); assert(fr.isTrackEvents()); } @Test - public void trackEventTrueWhenTrackEventsFalseButExperimentRuleMatchReasonTest() { - Rollout rollout = buildRollout(true, false); - - DataModel.Clause clause = clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of(BASE_USER.getKey())); - DataModel.Rule rule = ruleBuilder().id("ruleid0").clauses(clause).rollout(rollout).build(); - - DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(false) - .rules(rule).build(); - LDUser user = new LDUser("moniker"); - FeatureRequest fr = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, null, 0, - EvaluationReason.ruleMatch(0, "something", true), null, null); + public void trackEventsIsTrueIfForceReasonTrackingIsTrue() { + FeatureFlag flag = flagBuilder("flagkey").build(); + FeatureRequest fr = EventFactory.DEFAULT.newFeatureRequestEvent(flag, BASE_USER, + SOME_RESULT.withForceReasonTracking(true), DEFAULT_VALUE); assert(fr.isTrackEvents()); } diff --git a/src/test/java/com/launchdarkly/sdk/server/EventOutputTest.java b/src/test/java/com/launchdarkly/sdk/server/EventOutputTest.java index 15165abac..3d0240595 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EventOutputTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EventOutputTest.java @@ -82,7 +82,7 @@ public void userKeyIsSetInsteadOfUserWhenNotInlined() throws Exception { Event.FeatureRequest featureEvent = EventFactory.DEFAULT.newFeatureRequestEvent( flagBuilder("flag").build(), user, - new Evaluator.EvalResult(LDValue.ofNull(), NO_VARIATION, EvaluationReason.off()), + EvalResult.of(LDValue.ofNull(), NO_VARIATION, EvaluationReason.off()), LDValue.ofNull()); LDValue outputEvent = getSingleOutputEvent(f, featureEvent); assertEquals(LDValue.ofNull(), outputEvent.get("user")); @@ -189,7 +189,7 @@ public void featureEventIsSerialized() throws Exception { EventOutputFormatter f = new EventOutputFormatter(defaultEventsConfig()); FeatureRequest feWithVariation = factory.newFeatureRequestEvent(flag, user, - new Evaluator.EvalResult(LDValue.of("flagvalue"), 1, EvaluationReason.off()), + EvalResult.of(LDValue.of("flagvalue"), 1, EvaluationReason.off()), LDValue.of("defaultvalue")); LDValue feJson1 = buildFeatureEventProps("flag") .put("version", 11) @@ -200,7 +200,7 @@ public void featureEventIsSerialized() throws Exception { assertEquals(feJson1, getSingleOutputEvent(f, feWithVariation)); FeatureRequest feWithoutVariationOrDefault = factory.newFeatureRequestEvent(flag, user, - new Evaluator.EvalResult(LDValue.of("flagvalue"), NO_VARIATION, EvaluationReason.off()), + EvalResult.of(LDValue.of("flagvalue"), NO_VARIATION, EvaluationReason.off()), LDValue.ofNull()); LDValue feJson2 = buildFeatureEventProps("flag") .put("version", 11) @@ -209,7 +209,7 @@ public void featureEventIsSerialized() throws Exception { assertEquals(feJson2, getSingleOutputEvent(f, feWithoutVariationOrDefault)); FeatureRequest feWithReason = factoryWithReason.newFeatureRequestEvent(flag, user, - new Evaluator.EvalResult(LDValue.of("flagvalue"), 1, EvaluationReason.fallthrough()), + EvalResult.of(LDValue.of("flagvalue"), 1, EvaluationReason.fallthrough()), LDValue.of("defaultvalue")); LDValue feJson3 = buildFeatureEventProps("flag") .put("version", 11) @@ -245,7 +245,7 @@ public void featureEventIsSerialized() throws Exception { DataModel.FeatureFlag parentFlag = flagBuilder("parent").build(); Event.FeatureRequest prereqEvent = factory.newPrerequisiteFeatureRequestEvent(flag, user, - new Evaluator.EvalResult(LDValue.of("flagvalue"), 1, EvaluationReason.fallthrough()), parentFlag); + EvalResult.of(LDValue.of("flagvalue"), 1, EvaluationReason.fallthrough()), parentFlag); LDValue feJson6 = buildFeatureEventProps("flag") .put("version", 11) .put("variation", 1) @@ -255,7 +255,7 @@ public void featureEventIsSerialized() throws Exception { assertEquals(feJson6, getSingleOutputEvent(f, prereqEvent)); Event.FeatureRequest prereqWithReason = factoryWithReason.newPrerequisiteFeatureRequestEvent(flag, user, - new Evaluator.EvalResult(LDValue.of("flagvalue"), 1, EvaluationReason.fallthrough()), parentFlag); + EvalResult.of(LDValue.of("flagvalue"), 1, EvaluationReason.fallthrough()), parentFlag); LDValue feJson7 = buildFeatureEventProps("flag") .put("version", 11) .put("variation", 1) @@ -274,7 +274,7 @@ public void featureEventIsSerialized() throws Exception { assertEquals(feJson8, getSingleOutputEvent(f, prereqWithoutResult)); FeatureRequest anonFeWithVariation = factory.newFeatureRequestEvent(flag, anon, - new Evaluator.EvalResult(LDValue.of("flagvalue"), 1, EvaluationReason.off()), + EvalResult.of(LDValue.of("flagvalue"), 1, EvaluationReason.off()), LDValue.of("defaultvalue")); LDValue anonFeJson1 = buildFeatureEventProps("flag", "anonymouskey") .put("version", 11) @@ -519,7 +519,7 @@ private void testInlineUserSerialization(LDUser user, LDValue expectedJsonValue, Event.FeatureRequest featureEvent = EventFactory.DEFAULT.newFeatureRequestEvent( flagBuilder("flag").build(), user, - new Evaluator.EvalResult(LDValue.ofNull(), NO_VARIATION, EvaluationReason.off()), + EvalResult.of(LDValue.ofNull(), NO_VARIATION, EvaluationReason.off()), LDValue.ofNull()); LDValue outputEvent = getSingleOutputEvent(f, featureEvent); assertEquals(LDValue.ofNull(), outputEvent.get("userKey")); diff --git a/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java b/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java index 23f660668..dc67b3242 100644 --- a/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/FeatureFlagsStateTest.java @@ -179,11 +179,11 @@ public void canConvertFromJson() throws SerializationException { } private static FeatureFlagsState makeInstanceForSerialization() { - Evaluator.EvalResult eval1 = new Evaluator.EvalResult(LDValue.of("value1"), 0, EvaluationReason.off()); + EvalResult eval1 = EvalResult.of(LDValue.of("value1"), 0, EvaluationReason.off()); DataModel.FeatureFlag flag1 = flagBuilder("key1").version(100).trackEvents(false).build(); - Evaluator.EvalResult eval2 = new Evaluator.EvalResult(LDValue.of("value2"), 1, EvaluationReason.fallthrough()); + EvalResult eval2 = EvalResult.of(LDValue.of("value2"), 1, EvaluationReason.fallthrough()); DataModel.FeatureFlag flag2 = flagBuilder("key2").version(200).trackEvents(true).debugEventsUntilDate(1000L).build(); - Evaluator.EvalResult eval3 = new Evaluator.EvalResult(LDValue.ofNull(), NO_VARIATION, EvaluationReason.error(MALFORMED_FLAG)); + EvalResult eval3 = EvalResult.of(LDValue.ofNull(), NO_VARIATION, EvaluationReason.error(MALFORMED_FLAG)); DataModel.FeatureFlag flag3 = flagBuilder("key3").version(300).build(); return FeatureFlagsState.builder(FlagsStateOption.WITH_REASONS) .addFlag(flag1, eval1).addFlag(flag2, eval2).addFlag(flag3, eval3).build(); diff --git a/src/test/java/com/launchdarkly/sdk/server/RolloutRandomizationConsistencyTest.java b/src/test/java/com/launchdarkly/sdk/server/RolloutRandomizationConsistencyTest.java index 6614c5108..687aeacef 100644 --- a/src/test/java/com/launchdarkly/sdk/server/RolloutRandomizationConsistencyTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/RolloutRandomizationConsistencyTest.java @@ -7,7 +7,6 @@ import com.launchdarkly.sdk.server.DataModel.Rollout; import com.launchdarkly.sdk.server.DataModel.RolloutKind; import com.launchdarkly.sdk.server.DataModel.WeightedVariation; -import com.launchdarkly.sdk.server.Evaluator.EvalResult; import org.junit.Test; @@ -15,6 +14,7 @@ import java.util.List; import static com.launchdarkly.sdk.server.EvaluatorTestUtil.BASE_EVALUATOR; +import static com.launchdarkly.sdk.server.EvaluatorTestUtil.expectNoPrerequisiteEvals; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.junit.Assert.assertEquals; @@ -75,7 +75,7 @@ private static void assertVariationIndexAndExperimentStateForRollout( .fallthrough(rollout) .salt(salt) .build(); - EvalResult result = BASE_EVALUATOR.evaluate(flag, user, EventFactory.DEFAULT); + EvalResult result = BASE_EVALUATOR.evaluate(flag, user, expectNoPrerequisiteEvals()); assertThat(result.getVariationIndex(), equalTo(expectedVariation)); assertThat(result.getReason().getKind(), equalTo(EvaluationReason.Kind.FALLTHROUGH)); assertThat(result.getReason().isInExperiment(), equalTo(expectedInExperiment)); diff --git a/src/test/java/com/launchdarkly/sdk/server/TestUtil.java b/src/test/java/com/launchdarkly/sdk/server/TestUtil.java index 37eaea5ef..5f47ffce5 100644 --- a/src/test/java/com/launchdarkly/sdk/server/TestUtil.java +++ b/src/test/java/com/launchdarkly/sdk/server/TestUtil.java @@ -25,13 +25,13 @@ import java.net.Socket; import java.net.SocketAddress; import java.time.Duration; -import java.util.function.BiConsumer; -import java.util.function.Function; -import java.util.function.Supplier; import java.util.HashSet; import java.util.Set; import java.util.concurrent.BlockingQueue; import java.util.concurrent.TimeUnit; +import java.util.function.BiConsumer; +import java.util.function.Function; +import java.util.function.Supplier; import javax.net.SocketFactory; @@ -152,8 +152,8 @@ public static void expectEvents(BlockingQueue eve assertNoMoreValues(events, 100, TimeUnit.MILLISECONDS); } - public static Evaluator.EvalResult simpleEvaluation(int variation, LDValue value) { - return new Evaluator.EvalResult(value, variation, EvaluationReason.fallthrough()); + public static EvalResult simpleEvaluation(int variation, LDValue value) { + return EvalResult.of(value, variation, EvaluationReason.fallthrough()); } // returns a socket factory that creates sockets that only connect to host and port From c6383a878e173fd8402d885ba74a7b386a43b738 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 7 Jul 2022 16:27:06 -0700 Subject: [PATCH 648/673] comment --- src/main/java/com/launchdarkly/sdk/server/LDClient.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClient.java b/src/main/java/com/launchdarkly/sdk/server/LDClient.java index ceebec328..b7213c383 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClient.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClient.java @@ -255,6 +255,8 @@ public BigSegmentStoreWrapper.BigSegmentsQueryResult getBigSegments(String key) this.prereqEvalsDefault = makePrerequisiteEventSender(false); this.prereqEvalsWithReasons = makePrerequisiteEventSender(true); + // We pre-create those two callback objects, rather than using inline lambdas when we call the Evaluator, + // because using lambdas would cause a new closure object to be allocated every time. Future startFuture = dataSource.start(); if (!config.startWait.isZero() && !config.startWait.isNegative()) { From 3cad5809904ede0a33539fa90d9a545a164ee8ba Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 7 Jul 2022 18:03:12 -0700 Subject: [PATCH 649/673] avoid creating List iterators during evaluations --- .../launchdarkly/sdk/server/Evaluator.java | 124 +++++++++++++----- 1 file changed, 90 insertions(+), 34 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/Evaluator.java b/src/main/java/com/launchdarkly/sdk/server/Evaluator.java index d0943836b..3459ab23b 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Evaluator.java +++ b/src/main/java/com/launchdarkly/sdk/server/Evaluator.java @@ -5,8 +5,15 @@ import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.LDValueType; +import com.launchdarkly.sdk.server.DataModel.Clause; import com.launchdarkly.sdk.server.DataModel.FeatureFlag; +import com.launchdarkly.sdk.server.DataModel.Operator; +import com.launchdarkly.sdk.server.DataModel.Prerequisite; +import com.launchdarkly.sdk.server.DataModel.Rollout; import com.launchdarkly.sdk.server.DataModel.Rule; +import com.launchdarkly.sdk.server.DataModel.Segment; +import com.launchdarkly.sdk.server.DataModel.SegmentRule; +import com.launchdarkly.sdk.server.DataModel.Target; import com.launchdarkly.sdk.server.DataModel.VariationOrRollout; import com.launchdarkly.sdk.server.DataModel.WeightedVariation; import com.launchdarkly.sdk.server.DataModelPreprocessing.ClausePreprocessed; @@ -22,10 +29,34 @@ /** * Encapsulates the feature flag evaluation logic. The Evaluator has no knowledge of the rest of the SDK environment; * if it needs to retrieve flags or segments that are referenced by a flag, it does so through a read-only interface - * that is provided in the constructor. It also produces feature requests as appropriate for any referenced prerequisite - * flags, but does not send them. + * that is provided in the constructor. It also produces evaluation records (to be used in event data) as appropriate + * for any referenced prerequisite flags. */ class Evaluator { + // + // IMPLEMENTATION NOTES ABOUT THIS FILE + // + // Flag evaluation is the hottest code path in the SDK; large applications may evaluate flags at a VERY high + // volume, so every little bit of optimization we can achieve here could add up to quite a bit of overhead we + // are not making the customer incur. Strategies that are used here for that purpose include: + // + // 1. Whenever possible, we are reusing precomputed instances of EvalResult; see DataModelPreprocessing and + // EvaluatorHelpers. + // + // 2. If prerequisite evaluations happen as a side effect of an evaluation, rather than building and returning + // a list of these, we deliver them one at a time via the PrerequisiteEvaluationSink callback mechanism. + // + // 3. If there's a piece of state that needs to be tracked across multiple methods during an evaluation, and + // it's not feasible to just pass it as a method parameter, consider adding it as a field in the mutable + // EvaluatorState object (which we will always have one of) rather than creating a new object to contain it. + // + // 4. Whenever possible, avoid using "for (variable: list)" here because it always creates an iterator object. + // Instead, use the tedious old "get the size, iterate with a counter" approach. + // + // 5. Avoid using lambdas/closures here, because these generally cause a heap object to be allocated for + // variables captured in the closure each time they are used. + // + private final static Logger logger = Loggers.EVALUATION; /** @@ -43,8 +74,8 @@ class Evaluator { * and simplifies testing. */ static interface Getters { - DataModel.FeatureFlag getFlag(String key); - DataModel.Segment getSegment(String key); + FeatureFlag getFlag(String key); + Segment getSegment(String key); BigSegmentStoreWrapper.BigSegmentsQueryResult getBigSegments(String key); } @@ -82,7 +113,7 @@ private static class EvaluatorState { * @param eventFactory produces feature request events * @return an {@link EvalResult} - guaranteed non-null */ - EvalResult evaluate(DataModel.FeatureFlag flag, LDUser user, PrerequisiteEvaluationSink prereqEvals) { + EvalResult evaluate(FeatureFlag flag, LDUser user, PrerequisiteEvaluationSink prereqEvals) { if (flag.getKey() == INVALID_FLAG_KEY_THAT_THROWS_EXCEPTION) { throw EXPECTED_EXCEPTION_FROM_INVALID_FLAG; } @@ -105,7 +136,7 @@ EvalResult evaluate(DataModel.FeatureFlag flag, LDUser user, PrerequisiteEvaluat return result; } - private EvalResult evaluateInternal(DataModel.FeatureFlag flag, LDUser user, + private EvalResult evaluateInternal(FeatureFlag flag, LDUser user, PrerequisiteEvaluationSink prereqEvals, EvaluatorState state) { if (!flag.isOn()) { return EvaluatorHelpers.offResult(flag); @@ -117,15 +148,20 @@ private EvalResult evaluateInternal(DataModel.FeatureFlag flag, LDUser user, } // Check to see if targets match - for (DataModel.Target target: flag.getTargets()) { // getTargets() and getValues() are guaranteed non-null - if (target.getValues().contains(user.getKey())) { + List targets = flag.getTargets(); // guaranteed non-null + int nTargets = targets.size(); + for (int i = 0; i < nTargets; i++) { + Target target = targets.get(i); + if (target.getValues().contains(user.getKey())) { // getValues() is guaranteed non-null return EvaluatorHelpers.targetMatchResult(flag, target); } } + // Now walk through the rules and see if any match - List rules = flag.getRules(); // guaranteed non-null - for (int i = 0; i < rules.size(); i++) { - DataModel.Rule rule = rules.get(i); + List rules = flag.getRules(); // guaranteed non-null + int nRules = rules.size(); + for (int i = 0; i < nRules; i++) { + Rule rule = rules.get(i); if (ruleMatchesUser(flag, rule, user, state)) { return computeRuleMatch(flag, user, rule, i); } @@ -138,11 +174,14 @@ private EvalResult evaluateInternal(DataModel.FeatureFlag flag, LDUser user, // Checks prerequisites if any; returns null if successful, or an EvalResult if we have to // short-circuit due to a prerequisite failure. - private EvalResult checkPrerequisites(DataModel.FeatureFlag flag, LDUser user, + private EvalResult checkPrerequisites(FeatureFlag flag, LDUser user, PrerequisiteEvaluationSink prereqEvals, EvaluatorState state) { - for (DataModel.Prerequisite prereq: flag.getPrerequisites()) { // getPrerequisites() is guaranteed non-null + List prerequisites = flag.getPrerequisites(); // guaranteed non-null + int nPrerequisites = prerequisites.size(); + for (int i = 0; i < nPrerequisites; i++) { + Prerequisite prereq = prerequisites.get(i); boolean prereqOk = true; - DataModel.FeatureFlag prereqFeatureFlag = getters.getFlag(prereq.getKey()); + FeatureFlag prereqFeatureFlag = getters.getFlag(prereq.getKey()); if (prereqFeatureFlag == null) { logger.error("Could not retrieve prerequisite flag \"{}\" when evaluating \"{}\"", prereq.getKey(), flag.getKey()); prereqOk = false; @@ -177,11 +216,14 @@ private static EvalResult getValueForVariationOrRollout( if (maybeVariation != null) { variation = maybeVariation.intValue(); } else { - DataModel.Rollout rollout = vr.getRollout(); + Rollout rollout = vr.getRollout(); if (rollout != null && !rollout.getVariations().isEmpty()) { float bucket = bucketUser(rollout.getSeed(), user, flag.getKey(), rollout.getBucketBy(), flag.getSalt()); float sum = 0F; - for (DataModel.WeightedVariation wv : rollout.getVariations()) { + List variations = rollout.getVariations(); // guaranteed non-null + int nVariations = variations.size(); + for (int i = 0; i < nVariations; i++) { + WeightedVariation wv = variations.get(i); sum += (float) wv.getWeight() / 100000F; if (bucket < sum) { variation = wv.getVariation(); @@ -224,8 +266,11 @@ private static EvaluationReason experimentize(EvaluationReason reason) { return reason; } - private boolean ruleMatchesUser(DataModel.FeatureFlag flag, DataModel.Rule rule, LDUser user, EvaluatorState state) { - for (DataModel.Clause clause: rule.getClauses()) { // getClauses() is guaranteed non-null + private boolean ruleMatchesUser(FeatureFlag flag, Rule rule, LDUser user, EvaluatorState state) { + List clauses = rule.getClauses(); // guaranteed non-null + int nClauses = clauses.size(); + for (int i = 0; i < nClauses; i++) { + Clause clause = clauses.get(i); if (!clauseMatchesUser(clause, user, state)) { return false; } @@ -233,13 +278,16 @@ private boolean ruleMatchesUser(DataModel.FeatureFlag flag, DataModel.Rule rule, return true; } - private boolean clauseMatchesUser(DataModel.Clause clause, LDUser user, EvaluatorState state) { + private boolean clauseMatchesUser(Clause clause, LDUser user, EvaluatorState state) { // In the case of a segment match operator, we check if the user is in any of the segments, // and possibly negate - if (clause.getOp() == DataModel.Operator.segmentMatch) { - for (LDValue j: clause.getValues()) { - if (j.isString()) { - DataModel.Segment segment = getters.getSegment(j.stringValue()); + if (clause.getOp() == Operator.segmentMatch) { + List values = clause.getValues(); // guaranteed non-null + int nValues = values.size(); + for (int i = 0; i < nValues; i++) { + LDValue clauseValue = values.get(i); + if (clauseValue.isString()) { + Segment segment = getters.getSegment(clauseValue.stringValue()); if (segment != null) { if (segmentMatchesUser(segment, user, state)) { return maybeNegate(clause, true); @@ -253,14 +301,16 @@ private boolean clauseMatchesUser(DataModel.Clause clause, LDUser user, Evaluato return clauseMatchesUserNoSegments(clause, user); } - private boolean clauseMatchesUserNoSegments(DataModel.Clause clause, LDUser user) { + private boolean clauseMatchesUserNoSegments(Clause clause, LDUser user) { LDValue userValue = user.getAttribute(clause.getAttribute()); if (userValue.isNull()) { return false; } if (userValue.getType() == LDValueType.ARRAY) { - for (LDValue value: userValue.values()) { + int nValues = userValue.size(); + for (int i = 0; i < nValues; i++) { + LDValue value = userValue.get(i); 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; @@ -278,11 +328,11 @@ private boolean clauseMatchesUserNoSegments(DataModel.Clause clause, LDUser user return false; } - static boolean clauseMatchAny(DataModel.Clause clause, LDValue userValue) { - DataModel.Operator op = clause.getOp(); + static boolean clauseMatchAny(Clause clause, LDValue userValue) { + Operator op = clause.getOp(); if (op != null) { ClausePreprocessed preprocessed = clause.preprocessed; - if (op == DataModel.Operator.in) { + if (op == Operator.in) { // see if we have precomputed a Set for fast equality matching Set vs = preprocessed == null ? null : preprocessed.valuesSet; if (vs != null) { @@ -305,11 +355,11 @@ static boolean clauseMatchAny(DataModel.Clause clause, LDValue userValue) { return false; } - private boolean maybeNegate(DataModel.Clause clause, boolean b) { + private boolean maybeNegate(Clause clause, boolean b) { return clause.isNegate() ? !b : b; } - private boolean segmentMatchesUser(DataModel.Segment segment, LDUser user, EvaluatorState state) { + private boolean segmentMatchesUser(Segment segment, LDUser user, EvaluatorState state) { String userKey = user.getKey(); // we've already verified that the key is non-null at the top of evaluate() if (segment.isUnbounded()) { if (segment.getGeneration() == null) { @@ -346,7 +396,10 @@ private boolean segmentMatchesUser(DataModel.Segment segment, LDUser user, Evalu return false; } } - for (DataModel.SegmentRule rule: segment.getRules()) { + List rules = segment.getRules(); // guaranteed non-null + int nRules = rules.size(); + for (int i = 0; i < nRules; i++) { + SegmentRule rule = rules.get(i); if (segmentRuleMatchesUser(rule, user, segment.getKey(), segment.getSalt())) { return true; } @@ -354,8 +407,11 @@ private boolean segmentMatchesUser(DataModel.Segment segment, LDUser user, Evalu return false; } - private boolean segmentRuleMatchesUser(DataModel.SegmentRule segmentRule, LDUser user, String segmentKey, String salt) { - for (DataModel.Clause c: segmentRule.getClauses()) { + private boolean segmentRuleMatchesUser(SegmentRule segmentRule, LDUser user, String segmentKey, String salt) { + List clauses = segmentRule.getClauses(); // guaranteed non-null + int nClauses = clauses.size(); + for (int i = 0; i < nClauses; i++) { + Clause c = clauses.get(i); if (!clauseMatchesUserNoSegments(c, user)) { return false; } @@ -380,7 +436,7 @@ private static EvalResult computeRuleMatch(FeatureFlag flag, LDUser user, Rule r return getValueForVariationOrRollout(flag, rule, user, null, reason); } - static String makeBigSegmentRef(DataModel.Segment segment) { + static String makeBigSegmentRef(Segment segment) { return String.format("%s.g%d", segment.getKey(), segment.getGeneration()); } } From 681337e795c28e9cfda632ac792d055d754596c5 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 7 Jul 2022 18:38:15 -0700 Subject: [PATCH 650/673] remove unnecessary copy --- src/main/java/com/launchdarkly/sdk/server/LDClient.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClient.java b/src/main/java/com/launchdarkly/sdk/server/LDClient.java index b7213c383..690331d44 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClient.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClient.java @@ -429,7 +429,7 @@ public EvaluationDetail stringVariationDetail(String featureKey, LDUser public EvaluationDetail jsonValueVariationDetail(String featureKey, LDUser user, LDValue defaultValue) { EvalResult result = evaluateInternal(featureKey, user, LDValue.normalize(defaultValue), null, true); - return EvaluationDetail.fromValue(result.getValue(), result.getVariationIndex(), result.getReason()); + return result.getAnyType(); } @Override From cc2eb7056a2959bfc512c596c3a65c1931beb773 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 20 Jul 2022 15:58:04 -0700 Subject: [PATCH 651/673] fix allFlagsState to not generate prereq eval events --- .../com/launchdarkly/sdk/server/LDClient.java | 5 +- .../sdk/server/LDClientEventTest.java | 48 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/LDClient.java b/src/main/java/com/launchdarkly/sdk/server/LDClient.java index 690331d44..48a3aa49b 100644 --- a/src/main/java/com/launchdarkly/sdk/server/LDClient.java +++ b/src/main/java/com/launchdarkly/sdk/server/LDClient.java @@ -361,7 +361,10 @@ public FeatureFlagsState allFlagsState(LDUser user, FlagsStateOption... options) continue; } try { - EvalResult result = evaluator.evaluate(flag, user, prereqEvalsDefault); + EvalResult result = evaluator.evaluate(flag, user, null); + // Note: the null parameter to evaluate() is for the PrerequisiteEvaluationSink; allFlagsState should + // not generate evaluation events, so we don't want the evaluator to generate any prerequisite evaluation + // events either. builder.addFlag(flag, result); } catch (Exception e) { Loggers.EVALUATION.error("Exception caught for feature flag \"{}\" when evaluating all flags: {}", entry.getKey(), e.toString()); diff --git a/src/test/java/com/launchdarkly/sdk/server/LDClientEventTest.java b/src/test/java/com/launchdarkly/sdk/server/LDClientEventTest.java index 4408af8b1..6f992ff59 100644 --- a/src/test/java/com/launchdarkly/sdk/server/LDClientEventTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/LDClientEventTest.java @@ -4,6 +4,7 @@ import com.launchdarkly.sdk.EvaluationReason.ErrorKind; import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.DataModel.Prerequisite; import com.launchdarkly.sdk.server.interfaces.DataStore; import com.launchdarkly.sdk.server.interfaces.Event; import com.launchdarkly.sdk.server.interfaces.LDClientInterface; @@ -21,6 +22,10 @@ import static com.launchdarkly.sdk.server.TestComponents.specificDataStore; import static com.launchdarkly.sdk.server.TestComponents.specificEventProcessor; import static com.launchdarkly.sdk.server.TestUtil.upsertFlag; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.hasKey; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; @@ -561,6 +566,49 @@ public void aliasEventIsCorrectlyGenerated() { assertEquals("anonymousUser", evt.getPreviousContextKind()); } + @Test + public void allFlagsStateGeneratesNoEvaluationEvents() { + DataModel.FeatureFlag flag = flagBuilder("flag") + .on(true) + .fallthrough(fallthroughVariation(0)) + .offVariation(1) + .variations(LDValue.of(true), LDValue.of(false)) + .version(1) + .build(); + upsertFlag(dataStore, flag); + + FeatureFlagsState state = client.allFlagsState(user); + assertThat(state.toValuesMap(), hasKey(flag.getKey())); + + assertThat(eventSink.events, empty()); + } + + @Test + public void allFlagsStateGeneratesNoPrerequisiteEvaluationEvents() { + DataModel.FeatureFlag flag1 = flagBuilder("flag1") + .on(true) + .fallthrough(fallthroughVariation(0)) + .offVariation(1) + .variations(LDValue.of(true), LDValue.of(false)) + .version(1) + .build(); + DataModel.FeatureFlag flag0 = flagBuilder("flag0") + .on(true) + .fallthrough(fallthroughVariation(0)) + .offVariation(1) + .variations(LDValue.of(true), LDValue.of(false)) + .prerequisites(new Prerequisite(flag1.getKey(), 0)) + .version(1) + .build(); + upsertFlag(dataStore, flag1); + upsertFlag(dataStore, flag0); + + FeatureFlagsState state = client.allFlagsState(user); + assertThat(state.toValuesMap(), allOf(hasKey(flag0.getKey()), hasKey(flag1.getKey()))); + + assertThat(eventSink.events, empty()); + } + private void checkFeatureEvent(Event e, DataModel.FeatureFlag flag, LDValue value, LDValue defaultVal, String prereqOf, EvaluationReason reason) { assertEquals(Event.FeatureRequest.class, e.getClass()); From 1f75a5c70aaa8526b2cb5967f644761f74ffb4fb Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 26 Jul 2022 17:32:23 -0700 Subject: [PATCH 652/673] add "...ForAll" TestData methods to replace "...ForAllUsers" --- .../sdk/server/integrations/TestData.java | 59 ++++++++++++++++--- .../sdk/server/integrations/TestDataTest.java | 4 +- 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/TestData.java b/src/main/java/com/launchdarkly/sdk/server/integrations/TestData.java index e0caeb418..488ad74cf 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/TestData.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/TestData.java @@ -323,17 +323,16 @@ public FlagBuilder offVariation(int variationIndex) { /** * Sets the flag to always return the specified boolean variation for all users. *

              - * VariationForAllUsers sets the flag to return the specified boolean variation by default for all users. - *

              * Targeting is switched on, any existing targets or rules are removed, and the flag's variations are * set to true and false. The fallthrough variation is set to the specified value. The off variation is * left unchanged. * * @param variation the desired true/false variation to be returned for all users * @return the builder + * @since 5.10.0 */ - public FlagBuilder variationForAllUsers(boolean variation) { - return booleanFlag().variationForAllUsers(variationForBoolean(variation)); + public FlagBuilder variationForAll(boolean variation) { + return booleanFlag().variationForAll(variationForBoolean(variation)); } /** @@ -345,11 +344,42 @@ public FlagBuilder variationForAllUsers(boolean variation) { * * @param variationIndex the desired variation: 0 for the first, 1 for the second, etc. * @return the builder + * @since 5.10.0 */ - public FlagBuilder variationForAllUsers(int variationIndex) { + public FlagBuilder variationForAll(int variationIndex) { return on(true).clearRules().clearUserTargets().fallthroughVariation(variationIndex); } + /** + * Deprecated name for {@link #variationForAll(boolean)}. + *

              + * This method name will be dropped in a future SDK version because "users" will not always be the + * only kind of input for an evaluation. + * + * @param variation the desired true/false variation to be returned for all users + * @return the builder + * @deprecated Use {@link #variationForAll(boolean)}. + */ + @Deprecated + public FlagBuilder variationForAllUsers(boolean variation) { + return variationForAll(variation); + } + + /** + * Deprecated name for {@link #variationForAll(int)}. + *

              + * This method name will be dropped in a future SDK version because "users" will not always be the + * only kind of input for an evaluation. + * + * @param variationIndex the desired variation: 0 for the first, 1 for the second, etc. + * @return the builder + * @deprecated Use {@link #variationForAll(int)}. + */ + @Deprecated + public FlagBuilder variationForAllUsers(int variationIndex) { + return variationForAll(variationIndex); + } + /** * Sets the flag to always return the specified variation value for all users. *

              @@ -361,10 +391,25 @@ public FlagBuilder variationForAllUsers(int variationIndex) { * @param value the desired value to be returned for all users * @return the builder */ - public FlagBuilder valueForAllUsers(LDValue value) { + public FlagBuilder valueForAll(LDValue value) { variations.clear(); variations.add(value); - return variationForAllUsers(0); + return variationForAll(0); + } + + /** + * Deprecated name for {@link #valueForAll(LDValue)}. + *

              + * This method name will be dropped in a future SDK version because "users" will not always be the + * only kind of input for an evaluation. + * + * @param value the desired value to be returned for all users + * @return the builder' + * @deprecated Use {@link #valueForAll(LDValue)}. + */ + @Deprecated + public FlagBuilder valueForAllUsers(LDValue value) { + return valueForAll(value); } /** diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataTest.java index 2379f2db3..329805360 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/TestDataTest.java @@ -152,8 +152,8 @@ public void flagConfigSimpleBoolean() throws Exception { verifyFlag(f -> f.booleanFlag(), onProps + fallthroughTrue); verifyFlag(f -> f.on(true), onProps + fallthroughTrue); verifyFlag(f -> f.on(false), offProps + fallthroughTrue); - verifyFlag(f -> f.variationForAllUsers(false), onProps + fallthroughFalse); - verifyFlag(f -> f.variationForAllUsers(true), onProps + fallthroughTrue); + verifyFlag(f -> f.variationForAll(false), onProps + fallthroughFalse); + verifyFlag(f -> f.variationForAll(true), onProps + fallthroughTrue); verifyFlag( f -> f.fallthroughVariation(true).offVariation(false), From d3b74875807618e8e380a2847126ab98a947384e Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 27 Jul 2022 18:13:14 -0700 Subject: [PATCH 653/673] bump okhttp & okhttp-eventsource dependencies --- build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index 4e36496a0..d50004a52 100644 --- a/build.gradle +++ b/build.gradle @@ -73,8 +73,8 @@ ext.versions = [ "guava": "30.1-jre", "jackson": "2.11.2", "launchdarklyJavaSdkCommon": "1.3.0", - "okhttp": "4.8.1", // specify this for the SDK build instead of relying on the transitive dependency from okhttp-eventsource - "okhttpEventsource": "2.6.1", + "okhttp": "4.9.3", // specify this for the SDK build instead of relying on the transitive dependency from okhttp-eventsource + "okhttpEventsource": "2.6.2", "slf4j": "1.7.21", "snakeyaml": "1.26", "jedis": "2.9.0" From 1cc6775aa9023d11bb4fc1f1e710ef99d3b717d1 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 27 Jul 2022 18:16:01 -0700 Subject: [PATCH 654/673] update comment to clarify that level() doesn't apply to SLF4J --- .../LoggingConfigurationBuilder.java | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/integrations/LoggingConfigurationBuilder.java b/src/main/java/com/launchdarkly/sdk/server/integrations/LoggingConfigurationBuilder.java index a344ed0a4..b321d7f7f 100644 --- a/src/main/java/com/launchdarkly/sdk/server/integrations/LoggingConfigurationBuilder.java +++ b/src/main/java/com/launchdarkly/sdk/server/integrations/LoggingConfigurationBuilder.java @@ -2,6 +2,7 @@ import com.launchdarkly.logging.LDLogAdapter; import com.launchdarkly.logging.LDLogLevel; +import com.launchdarkly.logging.Logs; import com.launchdarkly.sdk.server.Components; import com.launchdarkly.sdk.server.interfaces.LoggingConfigurationFactory; @@ -113,15 +114,15 @@ public LoggingConfigurationBuilder baseLoggerName(String name) { /** * Specifies the lowest level of logging to enable. *

              - * This adds a log level filter that is applied regardless of what implementation of logging is - * being used, so that log messages at lower levels are suppressed. For instance, setting the - * minimum level to {@link LDLogLevel#INFO} means that DEBUG-level output is disabled. + * This is only applicable when using an implementation of logging that does not have its own + * external configuration mechanism, such as {@link Logs#toConsole()}. It adds a log level filter + * so that log messages at lower levels are suppressed. For instance, setting the minimum level to + * {@link LDLogLevel#INFO} means that DEBUG-level output is disabled. If not specified, + * the default minimum level is {@link LDLogLevel#INFO}. *

              - * External logging frameworks may also have their own mechanisms for setting a minimum log level. - * For instance, in SLF4J, this can be set on a per-logger-name basis in the SLF4J configuration - * file. Therefore, if {@link #adapter(LDLogAdapter)} is set to the default destination of SLF4J, - * or to Logs.toJavaUtilLogging(), the setting of {@link #level(LDLogLevel)} is - * ignored. Otherwise, the default minimum level is {@link LDLogLevel#INFO}. + * When using a logging framework like SLF4J or {@code java.util.logging} that has its own + * separate mechanism for log filtering, you must use that framework's configuration options for + * log levels; calling {@link #level(LDLogLevel)} in that case has no effect. * * @param minimumLevel the lowest level of logging to enable * @return the builder From caf0e10d6b910d2a8ab96e28b1792be1cd5df1d0 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 27 Jul 2022 18:48:33 -0700 Subject: [PATCH 655/673] update readme to mention different logging examples in hello-java --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 94568334f..402fa37a2 100644 --- a/README.md +++ b/README.md @@ -27,10 +27,12 @@ Refer to the [SDK reference guide](https://docs.launchdarkly.com/sdk/server-side ## Logging -By default, the LaunchDarkly SDK uses [SLF4J](https://www.slf4j.org/). SLF4J has its own configuration mechanisms for determining where output will go, and filtering by level and/or logger name. For an example of using SLF4J with a simple console logging configuration, check out the [hello-java](https://github.com/launchdarkly/hello-java) project. +By default, the LaunchDarkly SDK uses [SLF4J](https://www.slf4j.org/). SLF4J has its own configuration mechanisms for determining where output will go, and filtering by level and/or logger name. The SDK can also be configured to use other adapters from the [com.launchdarkly.logging](https://github.com/launchdarkly/java-logging) facade instead of SLF4J. See `LoggingConfigurationBuilder`. This allows the logging behavior to be completely determined by the application, rather than by external SLF4J configuration. +For an example of using the default SLF4J behavior with a simple console logging configuration, check out the [`slf4j-logging` branch](https://github.com/launchdarkly/hello-java/tree/slf4j-logging) of the [`hello-java`](https://github.com/launchdarkly/hello-java) project. The [main branch](https://github.com/launchdarkly/hello-java) of `hello-java` uses console logging that is programmatically configured without SLF4J. + All loggers are namespaced under `com.launchdarkly`, if you are using name-based filtering. Be aware of two considerations when enabling the DEBUG log level: From 7617814459202fa3bcdc8553e15114eddd25870f Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 27 Jul 2022 18:58:08 -0700 Subject: [PATCH 656/673] switch to use snapshot build of java-logging, pending next release --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 058f6f179..51f3f32f3 100644 --- a/build.gradle +++ b/build.gradle @@ -73,7 +73,7 @@ ext.versions = [ "guava": "30.1-jre", "jackson": "2.11.2", "launchdarklyJavaSdkCommon": "1.3.0", - "launchdarklyLogging": "1.0.0", + "launchdarklyLogging": "1.1.0-SNAPSHOT", "okhttp": "4.8.1", // specify this for the SDK build instead of relying on the transitive dependency from okhttp-eventsource "okhttpEventsource": "2.3.2", "slf4j": "1.7.21", From bc926fa4247d20b0cf9c6b8921edf3e5f36621f6 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 27 Jul 2022 18:58:55 -0700 Subject: [PATCH 657/673] level setting does not apply to SLF4J and JUL --- .../sdk/server/ComponentsImpl.java | 10 ++--- .../LoggingConfigurationBuilderTest.java | 44 +++++++++++++++++++ 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java b/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java index 9e7219333..3e87d7103 100644 --- a/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java +++ b/src/main/java/com/launchdarkly/sdk/server/ComponentsImpl.java @@ -377,13 +377,11 @@ static final class LoggingConfigurationBuilderImpl extends LoggingConfigurationB @Override public LoggingConfiguration createLoggingConfiguration(BasicConfiguration basicConfiguration) { LDLogAdapter adapter = logAdapter == null ? LDSLF4J.adapter() : logAdapter; - LDLogAdapter filteredAdapter; - if (adapter == LDSLF4J.adapter() || adapter == Logs.toJavaUtilLogging()) { - filteredAdapter = adapter; // ignore level setting because these frameworks have their own config - } else { - filteredAdapter = Logs.level(adapter, + LDLogAdapter filteredAdapter = Logs.level(adapter, minimumLevel == null ? LDLogLevel.INFO : minimumLevel); - } + // If the adapter is for a framework like SLF4J or java.util.logging that has its own external + // configuration system, then calling Logs.level here has no effect and filteredAdapter will be + // just the same as adapter. String name = baseName == null ? Loggers.BASE_LOGGER_NAME : baseName; return new LoggingConfigurationImpl(name, filteredAdapter, logDataSourceOutageAsErrorAfter); } diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/LoggingConfigurationBuilderTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/LoggingConfigurationBuilderTest.java index 8100ad89c..cc547e55e 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/LoggingConfigurationBuilderTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/LoggingConfigurationBuilderTest.java @@ -1,5 +1,10 @@ package com.launchdarkly.sdk.server.integrations; +import com.launchdarkly.logging.LDLogLevel; +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.logging.LDSLF4J; +import com.launchdarkly.logging.LogCapture; +import com.launchdarkly.logging.Logs; import com.launchdarkly.sdk.server.Components; import com.launchdarkly.sdk.server.interfaces.BasicConfiguration; import com.launchdarkly.sdk.server.interfaces.LoggingConfiguration; @@ -8,6 +13,9 @@ import java.time.Duration; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.sameInstance; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; @@ -35,4 +43,40 @@ public void logDataSourceOutageAsErrorAfter() { .createLoggingConfiguration(BASIC_CONFIG); assertNull(c2.getLogDataSourceOutageAsErrorAfter()); } + + @Test + public void defaultLogAdapterIsSLF4J() { + LoggingConfiguration c = Components.logging() + .createLoggingConfiguration(BASIC_CONFIG); + assertThat(c.getLogAdapter(), sameInstance(LDSLF4J.adapter())); + } + + @Test + public void canSetLogAdapterAndLevel() { + LogCapture logSink = Logs.capture(); + LoggingConfiguration c = Components.logging() + .adapter(logSink) + .level(LDLogLevel.WARN) + .createLoggingConfiguration(BASIC_CONFIG); + LDLogger logger = LDLogger.withAdapter(c.getLogAdapter(), ""); + logger.debug("message 1"); + logger.info("message 2"); + logger.warn("message 3"); + logger.error("message 4"); + assertThat(logSink.getMessageStrings(), contains("WARN:message 3", "ERROR:message 4")); + } + + @Test + public void defaultLevelIsInfo() { + LogCapture logSink = Logs.capture(); + LoggingConfiguration c = Components.logging() + .adapter(logSink) + .createLoggingConfiguration(BASIC_CONFIG); + LDLogger logger = LDLogger.withAdapter(c.getLogAdapter(), ""); + logger.debug("message 1"); + logger.info("message 2"); + logger.warn("message 3"); + logger.error("message 4"); + assertThat(logSink.getMessageStrings(), contains("INFO:message 2", "WARN:message 3", "ERROR:message 4")); + } } From 3a4d018617cdaf9eb483d422db381f601746a043 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 28 Jul 2022 10:53:35 -0700 Subject: [PATCH 658/673] use java-logging 1.1.0 release --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 9935a366b..957d1c4fd 100644 --- a/build.gradle +++ b/build.gradle @@ -73,7 +73,7 @@ ext.versions = [ "guava": "30.1-jre", "jackson": "2.11.2", "launchdarklyJavaSdkCommon": "1.3.0", - "launchdarklyLogging": "1.1.0-SNAPSHOT", + "launchdarklyLogging": "1.1.0", "okhttp": "4.8.1", // specify this for the SDK build instead of relying on the transitive dependency from okhttp-eventsource "okhttpEventsource": "2.6.1", "slf4j": "1.7.21", From d5c7fdced3ce398a02899bda243139700764984e Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 28 Jul 2022 12:07:02 -0700 Subject: [PATCH 659/673] make sure META-INF files are never mistaken for classes and relocated --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index d50004a52..1a4a467d2 100644 --- a/build.gradle +++ b/build.gradle @@ -362,7 +362,7 @@ def getPackagesInDependencyJar(jarFile) { new java.util.zip.ZipFile(jarFile).withCloseable { zf -> zf.entries().findAll { !it.directory && it.name.endsWith(".class") }.collect { it.name.contains("/") ? it.name.substring(0, it.name.lastIndexOf("/")).replace("/", ".") : "" - }.findAll { !it.equals("") }.unique() + }.findAll { !it.equals("") && !it.startsWith("META-INF") }.unique() } } From 5ad2095de9122d367230b2ee9a6ca85945612bac Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 28 Jul 2022 15:51:29 -0700 Subject: [PATCH 660/673] update shared data store test logic to pass ClientContext with logger --- .../com/launchdarkly/sdk/server/BaseTest.java | 1 + .../integrations/BigSegmentStoreTestBase.java | 24 +++--- .../PersistentDataStoreGenericTest.java | 11 +-- .../PersistentDataStoreTestBase.java | 78 +++++++++++++++---- 4 files changed, 81 insertions(+), 33 deletions(-) diff --git a/src/test/java/com/launchdarkly/sdk/server/BaseTest.java b/src/test/java/com/launchdarkly/sdk/server/BaseTest.java index 132d77a3d..0aa5ac17b 100644 --- a/src/test/java/com/launchdarkly/sdk/server/BaseTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/BaseTest.java @@ -9,6 +9,7 @@ import org.junit.rules.TestWatcher; import org.junit.runner.Description; +@SuppressWarnings("javadoc") public class BaseTest { @Rule public DumpLogIfTestFails dumpLogIfTestFails; diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/BigSegmentStoreTestBase.java b/src/test/java/com/launchdarkly/sdk/server/integrations/BigSegmentStoreTestBase.java index e703f46e0..be6a9c79d 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/BigSegmentStoreTestBase.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/BigSegmentStoreTestBase.java @@ -1,16 +1,11 @@ package com.launchdarkly.sdk.server.integrations; -import static com.launchdarkly.sdk.server.TestComponents.clientContext; -import static com.launchdarkly.sdk.server.interfaces.BigSegmentStoreTypes.createMembershipFromSegmentRefs; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; - -import com.launchdarkly.sdk.server.LDConfig; +import com.launchdarkly.sdk.server.BaseTest; import com.launchdarkly.sdk.server.interfaces.BigSegmentStore; import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreFactory; import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreTypes.Membership; import com.launchdarkly.sdk.server.interfaces.BigSegmentStoreTypes.StoreMetadata; +import com.launchdarkly.sdk.server.interfaces.ClientContext; import org.junit.Assert; import org.junit.Test; @@ -20,6 +15,12 @@ import java.util.List; import java.util.Objects; +import static com.launchdarkly.sdk.server.TestComponents.clientContext; +import static com.launchdarkly.sdk.server.interfaces.BigSegmentStoreTypes.createMembershipFromSegmentRefs; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + /** * A configurable test class for all implementations of {@link BigSegmentStore}. *

              @@ -31,15 +32,18 @@ * {@link #setMetadata(String, StoreMetadata)}, and {@link #setSegments(String, String, Iterable, Iterable)}. */ @SuppressWarnings("javadoc") -public abstract class BigSegmentStoreTestBase { +public abstract class BigSegmentStoreTestBase extends BaseTest { private static final String prefix = "testprefix"; private static final String fakeUserHash = "userhash"; private static final String segmentRef1 = "key1", segmentRef2 = "key2", segmentRef3 = "key3"; private static final String[] allSegmentRefs = {segmentRef1, segmentRef2, segmentRef3}; + private ClientContext makeClientContext() { + return clientContext("", baseConfig().build()); + } + private BigSegmentStore makeEmptyStore() throws Exception { - LDConfig config = new LDConfig.Builder().build(); - BigSegmentStore store = makeStore(prefix).createBigSegmentStore(clientContext("sdk-key", config)); + BigSegmentStore store = makeStore(prefix).createBigSegmentStore(makeClientContext()); try { clearData(prefix); } catch (RuntimeException ex) { diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreGenericTest.java b/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreGenericTest.java index af4040c3a..f2066c834 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreGenericTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreGenericTest.java @@ -1,6 +1,8 @@ package com.launchdarkly.sdk.server.integrations; import com.google.common.collect.ImmutableList; +import com.launchdarkly.sdk.server.TestComponents; +import com.launchdarkly.sdk.server.interfaces.PersistentDataStoreFactory; import org.junit.runner.RunWith; import org.junit.runners.Parameterized; @@ -48,15 +50,10 @@ public PersistentDataStoreGenericTest(TestMode testMode) { } @Override - protected MockPersistentDataStore makeStore() { - return makeStoreWithPrefix(""); - } - - @Override - protected MockPersistentDataStore makeStoreWithPrefix(String prefix) { + protected PersistentDataStoreFactory buildStore(String prefix) { MockPersistentDataStore store = new MockPersistentDataStore(sharedData, prefix); store.persistOnlyAsString = testMode.persistOnlyAsString; - return store; + return TestComponents.specificPersistentDataStore(store); } @Override diff --git a/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreTestBase.java b/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreTestBase.java index df805b330..c83ee811f 100644 --- a/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreTestBase.java +++ b/src/test/java/com/launchdarkly/sdk/server/integrations/PersistentDataStoreTestBase.java @@ -1,11 +1,14 @@ package com.launchdarkly.sdk.server.integrations; +import com.launchdarkly.sdk.server.BaseTest; import com.launchdarkly.sdk.server.DataStoreTestTypes.DataBuilder; import com.launchdarkly.sdk.server.DataStoreTestTypes.TestItem; -import com.launchdarkly.sdk.server.interfaces.PersistentDataStore; +import com.launchdarkly.sdk.server.interfaces.ClientContext; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.FullDataSet; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.ItemDescriptor; import com.launchdarkly.sdk.server.interfaces.DataStoreTypes.SerializedItemDescriptor; +import com.launchdarkly.sdk.server.interfaces.PersistentDataStore; +import com.launchdarkly.sdk.server.interfaces.PersistentDataStoreFactory; import org.junit.After; import org.junit.Assume; @@ -18,6 +21,7 @@ import static com.launchdarkly.sdk.server.DataStoreTestTypes.TEST_ITEMS; import static com.launchdarkly.sdk.server.DataStoreTestTypes.toItemsMap; import static com.launchdarkly.sdk.server.DataStoreTestTypes.toSerialized; +import static com.launchdarkly.sdk.server.TestComponents.clientContext; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; @@ -30,7 +34,7 @@ * CachingStoreWrapperTest. */ @SuppressWarnings("javadoc") -public abstract class PersistentDataStoreTestBase { +public abstract class PersistentDataStoreTestBase extends BaseTest { protected T store; protected TestItem item1 = new TestItem("key1", "first", 10); @@ -39,17 +43,57 @@ public abstract class PersistentDataStoreTestBase protected TestItem otherItem1 = new TestItem("key1", "other-first", 11); + private ClientContext makeClientContext() { + return clientContext("", baseConfig().build()); + } + + @SuppressWarnings("unchecked") + private T makeConfiguredStore() { + PersistentDataStoreFactory builder = buildStore(null); + if (builder == null) { + return makeStore(); + } + return (T)builder.createPersistentDataStore(makeClientContext()); + } + + @SuppressWarnings("unchecked") + private T makeConfiguredStoreWithPrefix(String prefix) { + PersistentDataStoreFactory builder = buildStore(prefix); + if (builder == null) { + return makeStoreWithPrefix(prefix); + } + return (T)builder.createPersistentDataStore(makeClientContext()); + } + + /** + * Test subclasses can override either this method or {@link #buildStore(String)} to create an instance + * of the feature store class with default properties. + * @deprecated It is preferable to override {@link #buildStore(String)} instead. + */ + @Deprecated + protected T makeStore() { + throw new RuntimeException("test subclasses must override either makeStore or buildStore"); + } + /** - * Test subclasses must override this method to create an instance of the feature store class - * with default properties. + * Test subclasses can implement this (or override {@link #buildStore(String)}) if the feature store + * class supports a key prefix option for keeping data sets distinct within the same database. + * @deprecated It is preferable to override {@link #buildStore(String)} instead. */ - protected abstract T makeStore(); + protected T makeStoreWithPrefix(String prefix) { + return null; + } /** - * Test subclasses should implement this if the feature store class supports a key prefix option - * for keeping data sets distinct within the same database. + * Test subclasses should override this method to prepare an instance of the data store class. + * They are allowed to return null if {@code prefix} is non-null and they do not support prefixes. + * + * @param prefix a database prefix or null + * @return a factory for creating the data store */ - protected abstract T makeStoreWithPrefix(String prefix); + protected PersistentDataStoreFactory buildStore(String prefix) { + return null; + } /** * Test classes should override this to clear all data from the underlying database. @@ -87,12 +131,14 @@ private void assertEqualsDeletedItem(SerializedItemDescriptor expected, Serializ @Before public void setup() { - store = makeStore(); + store = makeConfiguredStore(); } @After public void teardown() throws Exception { - store.close(); + if (store != null) { + store.close(); + } } @Test @@ -127,7 +173,7 @@ public void initCompletelyReplacesPreviousData() { @Test public void oneInstanceCanDetectIfAnotherInstanceHasInitializedTheStore() { clearAllData(); - T store2 = makeStore(); + T store2 = makeConfiguredStore(); assertFalse(store.isInitialized()); @@ -139,7 +185,7 @@ public void oneInstanceCanDetectIfAnotherInstanceHasInitializedTheStore() { @Test public void oneInstanceCanDetectIfAnotherInstanceHasInitializedTheStoreEvenIfEmpty() { clearAllData(); - T store2 = makeStore(); + T store2 = makeConfiguredStore(); assertFalse(store.isInitialized()); @@ -243,7 +289,7 @@ public void upsertOlderVersionAfterDelete() { @Test public void handlesUpsertRaceConditionAgainstExternalClientWithLowerVersion() throws Exception { - final T store2 = makeStore(); + final T store2 = makeConfiguredStore(); int startVersion = 1; final int store2VersionStart = 2; @@ -279,7 +325,7 @@ public void run() { @Test public void handlesUpsertRaceConditionAgainstExternalClientWithHigherVersion() throws Exception { - final T store2 = makeStore(); + final T store2 = makeConfiguredStore(); int startVersion = 1; final int store2Version = 3; @@ -310,9 +356,9 @@ public void run() { @Test public void storesWithDifferentPrefixAreIndependent() throws Exception { - T store1 = makeStoreWithPrefix("aaa"); + T store1 = makeConfiguredStoreWithPrefix("aaa"); Assume.assumeNotNull(store1); - T store2 = makeStoreWithPrefix("bbb"); + T store2 = makeConfiguredStoreWithPrefix("bbb"); clearAllData(); try { From 3a54988b8bf361d63d5c68279e459f3de6ed611a Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 28 Jul 2022 16:28:38 -0700 Subject: [PATCH 661/673] enable external javadoc links for com.launchdarkly.logging types --- build.gradle | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/build.gradle b/build.gradle index 22e4cc8a7..49066c92f 100644 --- a/build.gradle +++ b/build.gradle @@ -324,6 +324,14 @@ javadoc { // Use test classpath so Javadoc won't complain about java-sdk-common classes that internally // reference stuff we don't use directly, like Jackson classpath = sourceSets.test.compileClasspath + + // The following should allow hyperlinks to com.launchdarkly.logging classes to go to + // the correct external URLs + if (options instanceof StandardJavadocDocletOptions) { + (options as StandardJavadocDocletOptions).links( + "https://javadoc.io/doc/com.launchdarkly/launchdarkly-logging/1.1.0" + ) + } } // Force the Javadoc build to fail if there are any Javadoc warnings. See: https://discuss.gradle.org/t/javadoc-fail-on-warning/18141/3 From bb26529d886ac8fd65a5da9b9386e9d351eaf5b7 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 28 Jul 2022 16:35:01 -0700 Subject: [PATCH 662/673] use variable for dependency version --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 49066c92f..606309d07 100644 --- a/build.gradle +++ b/build.gradle @@ -329,7 +329,7 @@ javadoc { // the correct external URLs if (options instanceof StandardJavadocDocletOptions) { (options as StandardJavadocDocletOptions).links( - "https://javadoc.io/doc/com.launchdarkly/launchdarkly-logging/1.1.0" + "https://javadoc.io/doc/com.launchdarkly/launchdarkly-logging/${versions.launchdarklyLogging}" ) } } From fa29cf2b05f639d69f1c0998d1eca90d1fb84eb1 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 29 Jul 2022 15:45:08 -0700 Subject: [PATCH 663/673] fix flaky big segment status polling tests --- .../sdk/server/BigSegmentStoreWrapperTest.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/test/java/com/launchdarkly/sdk/server/BigSegmentStoreWrapperTest.java b/src/test/java/com/launchdarkly/sdk/server/BigSegmentStoreWrapperTest.java index bec8e1bba..fc4354ab1 100644 --- a/src/test/java/com/launchdarkly/sdk/server/BigSegmentStoreWrapperTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/BigSegmentStoreWrapperTest.java @@ -227,6 +227,11 @@ public void pollingDetectsStoreUnavailability() throws Exception { storeUnavailable.set(true); Status status1 = statuses.take(); + // Depending on timing, the listener might or might not receive an initial status that is still + // available prior to the one that is unavailable. + if (status1.isAvailable()) { + status1 = statuses.take(); + } assertFalse(status1.isAvailable()); assertEquals(status1, wrapper.getStatus()); @@ -253,7 +258,12 @@ public void pollingDetectsStaleStatus() throws Exception { eventBroadcaster.register(statuses::add); storeMetadata.set(new StoreMetadata(System.currentTimeMillis() - 1000)); + // Depending on timing, the listener might or might not receive an initial status that is not + // stale prior to the one that is stale. Status status1 = statuses.take(); + if (!status1.isStale()) { + status1 = statuses.take(); + } assertTrue(status1.isStale()); assertEquals(status1, wrapper.getStatus()); From 7ad115b7d0a97742a2b02c48217180561ff9db19 Mon Sep 17 00:00:00 2001 From: Alex Engelberg Date: Mon, 29 Aug 2022 18:42:33 -0700 Subject: [PATCH 664/673] Update Windows orb, fix Windows JDK install in CI (#372) --- .circleci/config.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 3f2bc8df9..8503da459 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,7 +1,7 @@ version: 2.1 orbs: - win: circleci/windows@1.0.0 + win: circleci/windows@5.0.0 workflows: test: @@ -108,10 +108,12 @@ jobs: openjdk-version: type: string executor: - name: win/vs2019 - shell: powershell.exe + name: win/server-2019 steps: - checkout + - run: + name: uninstall previous openjdk + command: choco uninstall openjdk - run: name: install OpenJDK command: | From f7ad37b51c9813245df6a3a248dd32438c519efe Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 2 Sep 2022 10:08:02 -0700 Subject: [PATCH 665/673] update snakeyaml for CVE-CVE-2022-25857 --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 606309d07..9a2bf13a8 100644 --- a/build.gradle +++ b/build.gradle @@ -77,7 +77,7 @@ ext.versions = [ "okhttp": "4.9.3", // specify this for the SDK build instead of relying on the transitive dependency from okhttp-eventsource "okhttpEventsource": "2.6.2", "slf4j": "1.7.21", - "snakeyaml": "1.26", + "snakeyaml": "1.32", "jedis": "2.9.0" ] From 658cdc394b088b3ff9b1e5fbae445ffa4f6857b3 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 2 Sep 2022 10:31:55 -0700 Subject: [PATCH 666/673] latest snakeyaml is 1.31 --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 9a2bf13a8..4a0bdab3c 100644 --- a/build.gradle +++ b/build.gradle @@ -77,7 +77,7 @@ ext.versions = [ "okhttp": "4.9.3", // specify this for the SDK build instead of relying on the transitive dependency from okhttp-eventsource "okhttpEventsource": "2.6.2", "slf4j": "1.7.21", - "snakeyaml": "1.32", + "snakeyaml": "1.31", "jedis": "2.9.0" ] From decdb319b167d0fa6af507730d311e0cd5923475 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 12 Sep 2022 14:45:41 -0700 Subject: [PATCH 667/673] bump snakeyaml version for CVE-2022-38752 --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 4a0bdab3c..9a2bf13a8 100644 --- a/build.gradle +++ b/build.gradle @@ -77,7 +77,7 @@ ext.versions = [ "okhttp": "4.9.3", // specify this for the SDK build instead of relying on the transitive dependency from okhttp-eventsource "okhttpEventsource": "2.6.2", "slf4j": "1.7.21", - "snakeyaml": "1.31", + "snakeyaml": "1.32", "jedis": "2.9.0" ] From cd4a9bdc2014dfcaf65c2ea058074e17e7f2588d Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 3 Oct 2022 12:01:34 -0700 Subject: [PATCH 668/673] disable Windows Java 11 build --- .circleci/config.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 8503da459..cdc8a7dbb 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -29,9 +29,10 @@ workflows: - benchmarks: requires: - build-linux - - build-test-windows: - name: Java 11 - Windows - OpenJDK - openjdk-version: 11.0.2.01 +# Windows Java 11 build is temporarily disabled - see story 171428 +# - test-windows: +# name: Java 11 - Windows - OpenJDK +# openjdk-version: 11.0.2.01 - build-test-windows: name: Java 17 - Windows - OpenJDK openjdk-version: 17.0.1 From 85b972022f612b83d0c6512c82bef3b539a39126 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 13 Oct 2022 18:21:46 -0700 Subject: [PATCH 669/673] fix packaging of com.launchdarkly.logging classes --- README.md | 4 ++-- build.gradle | 21 +++++++++++++++------ packaging-test/Makefile | 4 +++- packaging-test/test-app/build.gradle | 1 + 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 402fa37a2..a0273d270 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,8 @@ This version of the LaunchDarkly SDK works with Java 8 and above. Three variants of the SDK jar are published to Maven: -* 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/sdk/server-side/java#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 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 SLF4J is also bundled, without shading (and is exported in OSGi). +* 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/sdk/server-side/java#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 SLF4J and [`launchdarkly-logging`](https://github.com/launchdarkly/java-logging), which are assumed to be brought in automatically as Maven dependencies or otherwise made available in the classpath of 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 SLF4J and `launchdarkly-logging` 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. ## Getting started diff --git a/build.gradle b/build.gradle index 9a2bf13a8..be0b4e62e 100644 --- a/build.gradle +++ b/build.gradle @@ -238,6 +238,7 @@ shadowJar { configurations = [ project.configurations.internal ] dependencies { + exclude(dependency('com.launchdarkly:launchdarkly-logging:.*')) exclude(dependency('org.slf4j:.*:.*')) } @@ -265,11 +266,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). +// Gson, SLF4J, and com.launchdarkly.logging are bundled and exposed (unshaded). task shadowJarAll(type: com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { classifier = 'all' group = "shadow" - description = "Builds a Shaded fat jar including SLF4J" + description = "Builds a Shaded fat jar including Gson, SLF4J, and com.launchdarkly.logging" from(project.convention.getPlugin(JavaPluginConvention).sourceSets.main.output) configurations = [ project.configurations.internal, project.configurations.external ] @@ -290,9 +291,9 @@ task shadowJarAll(type: com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJ // configuration phase; this is necessary because they access the build products doFirst { shadeDependencies(project.tasks.shadowJarAll) - // The "all" jar exposes its bundled SLF4j dependency as an export - but, like the - // default jar, it *also* imports it ("self-wiring"), which allows the bundle to use a - // higher version if one is provided by another bundle. + // The "all" jar exposes its bundled SLF4j and launchdarkly-logging 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.imports ], [ project.configurations.imports ]) } @@ -369,7 +370,15 @@ def getAllSdkPackages() { // 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) { + // System.out.println("*** getPackagesInDependencyJar: " + jarFile) new java.util.zip.ZipFile(jarFile).withCloseable { zf -> + // def a = zf.entries().findAll { !it.directory && it.name.endsWith(".class") }.collect { + // it.name.contains("/") ? it.name.substring(0, it.name.lastIndexOf("/")).replace("/", ".") : "" + // } + // System.out.println("***** a = " + a) + // def b = a.findAll { !it.equals("") && !it.startsWith("META-INF") }.unique() + // System.out.println("***** b = " + b) + // b zf.entries().findAll { !it.directory && it.name.endsWith(".class") }.collect { it.name.contains("/") ? it.name.substring(0, it.name.lastIndexOf("/")).replace("/", ".") : "" }.findAll { !it.equals("") && !it.startsWith("META-INF") }.unique() @@ -505,7 +514,7 @@ def forEachArtifactAndVisiblePackage(configs, closure) { configs.collectMany { it.resolvedConfiguration.resolvedArtifacts } .collectMany { a -> def exportedPackages = getOsgiPackageExportsFromJar(a.file) - if (exportedPackages == null) { + if (exportedPackages == null || exportedPackages.size == 0) { // 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). diff --git a/packaging-test/Makefile b/packaging-test/Makefile index 972a82506..d734e82e0 100644 --- a/packaging-test/Makefile +++ b/packaging-test/Makefile @@ -81,7 +81,7 @@ clean: test-all-jar test-default-jar test-thin-jar: $$@-classes $(TEST_APP_JAR) get-sdk-dependencies $$(RUN_JARS_$$@) $(FELIX_DIR) @$(call caption,$@) @./run-non-osgi-test.sh $(RUN_JARS_$@) - @./run-osgi-test.sh $(RUN_JARS_$@) +# @./run-osgi-test.sh $(RUN_JARS_$@) test-all-jar-classes: $(SDK_ALL_JAR) $(TEMP_DIR) @$(call caption,$@) @@ -101,6 +101,7 @@ test-default-jar-classes: $(SDK_DEFAULT_JAR) $(TEMP_DIR) @$(call caption,$@) @$(call classes_prepare,$<) @$(call verify_sdk_classes) + @$(call classes_should_not_contain,com/launchdarkly/logging,com.launchdarkly.logging classes) @$(call classes_should_not_contain,com/launchdarkly/shaded/com/launchdarkly/sdk,shaded SDK classes) @$(call classes_should_contain,com/launchdarkly/shaded/com/google/gson,shaded Gson) @$(call classes_should_not_contain,com/launchdarkly/shaded/org/slf4j,shaded SLF4j) @@ -151,6 +152,7 @@ $(TEMP_DIR)/dependencies-external: $(TEMP_DIR)/dependencies-all @cp $(TEMP_DIR)/dependencies-all/slf4j*.jar $@ @cp $(TEMP_DIR)/dependencies-all/gson*.jar $@ @cp $(TEMP_DIR)/dependencies-all/jackson*.jar $@ + @cp $(TEMP_DIR)/dependencies-all/launchdarkly-logging*.jar $@ $(TEMP_DIR)/dependencies-internal: $(TEMP_DIR)/dependencies-all [ -d $@ ] || mkdir -p $@ diff --git a/packaging-test/test-app/build.gradle b/packaging-test/test-app/build.gradle index 53b26c0b3..14b4550fe 100644 --- a/packaging-test/test-app/build.gradle +++ b/packaging-test/test-app/build.gradle @@ -35,6 +35,7 @@ dependencies { implementation "com.fasterxml.jackson.core:jackson-core:${versions.jackson}" implementation "com.fasterxml.jackson.core:jackson-databind:${versions.jackson}" implementation "com.google.code.gson:gson:${versions.gson}" + implementation "com.launchdarkly:launchdarkly-logging:1.1.0" implementation "org.slf4j:slf4j-api:1.7.22" implementation "org.osgi:osgi_R4_core:1.0" osgiRuntime "org.slf4j:slf4j-simple:1.7.22" From 52ba6b36889c2cae7cef15018ffde4eb765e82bb Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 14 Oct 2022 09:17:56 -0700 Subject: [PATCH 670/673] rm debugging --- build.gradle | 8 -------- packaging-test/Makefile | 2 +- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/build.gradle b/build.gradle index be0b4e62e..548d6b366 100644 --- a/build.gradle +++ b/build.gradle @@ -370,15 +370,7 @@ def getAllSdkPackages() { // 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) { - // System.out.println("*** getPackagesInDependencyJar: " + jarFile) new java.util.zip.ZipFile(jarFile).withCloseable { zf -> - // def a = zf.entries().findAll { !it.directory && it.name.endsWith(".class") }.collect { - // it.name.contains("/") ? it.name.substring(0, it.name.lastIndexOf("/")).replace("/", ".") : "" - // } - // System.out.println("***** a = " + a) - // def b = a.findAll { !it.equals("") && !it.startsWith("META-INF") }.unique() - // System.out.println("***** b = " + b) - // b zf.entries().findAll { !it.directory && it.name.endsWith(".class") }.collect { it.name.contains("/") ? it.name.substring(0, it.name.lastIndexOf("/")).replace("/", ".") : "" }.findAll { !it.equals("") && !it.startsWith("META-INF") }.unique() diff --git a/packaging-test/Makefile b/packaging-test/Makefile index d734e82e0..b42b4d36e 100644 --- a/packaging-test/Makefile +++ b/packaging-test/Makefile @@ -81,7 +81,7 @@ clean: test-all-jar test-default-jar test-thin-jar: $$@-classes $(TEST_APP_JAR) get-sdk-dependencies $$(RUN_JARS_$$@) $(FELIX_DIR) @$(call caption,$@) @./run-non-osgi-test.sh $(RUN_JARS_$@) -# @./run-osgi-test.sh $(RUN_JARS_$@) + @./run-osgi-test.sh $(RUN_JARS_$@) test-all-jar-classes: $(SDK_ALL_JAR) $(TEMP_DIR) @$(call caption,$@) From f4cc631a052117497467cccf1da3aec42eeb60c1 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 14 Oct 2022 12:24:50 -0700 Subject: [PATCH 671/673] reconsidered - let's include the logging classes in the jars --- README.md | 4 ++-- build.gradle | 13 +++++++------ packaging-test/Makefile | 1 - packaging-test/test-app/build.gradle | 1 - 4 files changed, 9 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index a0273d270..8d08e225e 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,8 @@ This version of the LaunchDarkly SDK works with Java 8 and above. Three variants of the SDK jar are published to Maven: -* 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/sdk/server-side/java#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 SLF4J and [`launchdarkly-logging`](https://github.com/launchdarkly/java-logging), which are assumed to be brought in automatically as Maven dependencies or otherwise made available in the classpath of 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 SLF4J and `launchdarkly-logging` are also bundled, without shading (and are exported in OSGi). +* 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/sdk/server-side/java#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 SLF4J (the SLF4J API is assumed to be brought in automatically as Maven dependencies, or otherwise made available in the classpath of the host application). All third-party 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 SLF4J is also bundled, without shading (and is exported in OSGi). * The "thin" jar - add `thin` in Maven, or `:thin` in Gradle. This contains _only_ the SDK classes. ## Getting started diff --git a/build.gradle b/build.gradle index 548d6b366..f7c7ed22e 100644 --- a/build.gradle +++ b/build.gradle @@ -81,15 +81,17 @@ ext.versions = [ "jedis": "2.9.0" ] -// Add dependencies to "libraries.internal" that we use internally but do not expose in -// our public API. Putting dependencies here has the following effects: +// Add dependencies to "libraries.internal" that we use internally but do not necessarily +// expose in our public API. Putting dependencies here has the following effects: // // 1. Those classes will be embedded in the default uberjar // (launchdarkly-java-server-sdk-n.n.n.jar), and also in the "all" jar // (launchdarkly-java-server-sdk-n.n.n.jar). // // 2. The classes are renamed (shaded) within those jars, and all references to them are -// updated to use the shaded names. +// updated to use the shaded names. The only exception to this is classes from +// launchdarkly-java-sdk-common and launchdarkly-logging, which are meant to be part of +// the public API. // // 3. The "thin" jar does not contain those classes, and references to them from the code // in the "thin" jar are *not* renamed. If an application is using the "thin" jar, it is @@ -110,6 +112,7 @@ ext.versions = [ // headers for it. libraries.internal = [ "com.launchdarkly:launchdarkly-java-sdk-common:${versions.launchdarklyJavaSdkCommon}", + "com.launchdarkly:launchdarkly-logging:${versions.launchdarklyLogging}", "commons-codec:commons-codec:${versions.commonsCodec}", "com.google.code.gson:gson:${versions.gson}", "com.google.guava:guava:${versions.guava}", @@ -132,7 +135,6 @@ libraries.internal = [ // also as package exports (i.e. it provides them if a newer version is not available // from an import). libraries.external = [ - "com.launchdarkly:launchdarkly-logging:${versions.launchdarklyLogging}", "org.slf4j:slf4j-api:${versions.slf4j}" ] @@ -238,7 +240,6 @@ shadowJar { configurations = [ project.configurations.internal ] dependencies { - exclude(dependency('com.launchdarkly:launchdarkly-logging:.*')) exclude(dependency('org.slf4j:.*:.*')) } @@ -352,7 +353,7 @@ if (JavaVersion.current().isJava8Compatible()) { // enclosing packages like "com" that don't have any classes in them. def getAllSdkPackages() { // base package classes come from launchdarkly-java-sdk-common - def names = [ "com.launchdarkly.sdk", "com.launchdarkly.sdk.json" ] + def names = [ "com.launchdarkly.sdk", "com.launchdarkly.sdk.json", "com.launchdarkly.logging" ] project.convention.getPlugin(JavaPluginConvention).sourceSets.main.output.each { baseDir -> if (baseDir.getPath().contains("classes" + File.separator + "java" + File.separator + "main")) { baseDir.eachFileRecurse { f -> diff --git a/packaging-test/Makefile b/packaging-test/Makefile index b42b4d36e..55efe1c13 100644 --- a/packaging-test/Makefile +++ b/packaging-test/Makefile @@ -152,7 +152,6 @@ $(TEMP_DIR)/dependencies-external: $(TEMP_DIR)/dependencies-all @cp $(TEMP_DIR)/dependencies-all/slf4j*.jar $@ @cp $(TEMP_DIR)/dependencies-all/gson*.jar $@ @cp $(TEMP_DIR)/dependencies-all/jackson*.jar $@ - @cp $(TEMP_DIR)/dependencies-all/launchdarkly-logging*.jar $@ $(TEMP_DIR)/dependencies-internal: $(TEMP_DIR)/dependencies-all [ -d $@ ] || mkdir -p $@ diff --git a/packaging-test/test-app/build.gradle b/packaging-test/test-app/build.gradle index 14b4550fe..53b26c0b3 100644 --- a/packaging-test/test-app/build.gradle +++ b/packaging-test/test-app/build.gradle @@ -35,7 +35,6 @@ dependencies { implementation "com.fasterxml.jackson.core:jackson-core:${versions.jackson}" implementation "com.fasterxml.jackson.core:jackson-databind:${versions.jackson}" implementation "com.google.code.gson:gson:${versions.gson}" - implementation "com.launchdarkly:launchdarkly-logging:1.1.0" implementation "org.slf4j:slf4j-api:1.7.22" implementation "org.osgi:osgi_R4_core:1.0" osgiRuntime "org.slf4j:slf4j-simple:1.7.22" From 78d1540f2f254d7658f3521f5a44314c688b9216 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 14 Oct 2022 15:03:02 -0700 Subject: [PATCH 672/673] fix packaging test logic --- packaging-test/Makefile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packaging-test/Makefile b/packaging-test/Makefile index 55efe1c13..32dafe245 100644 --- a/packaging-test/Makefile +++ b/packaging-test/Makefile @@ -87,6 +87,7 @@ test-all-jar-classes: $(SDK_ALL_JAR) $(TEMP_DIR) @$(call caption,$@) @$(call classes_prepare,$<) @$(call verify_sdk_classes) + @$(call classes_should_contain,com/launchdarkly/logging,unshaded com.launchdarkly.logging classes) @$(call classes_should_contain,org/slf4j,unshaded SLF4j) @$(call classes_should_not_contain,com/launchdarkly/shaded/com/launchdarkly/sdk,shaded SDK classes) @$(call classes_should_contain,com/launchdarkly/shaded/com/google/gson,shaded Gson) @@ -101,7 +102,7 @@ test-default-jar-classes: $(SDK_DEFAULT_JAR) $(TEMP_DIR) @$(call caption,$@) @$(call classes_prepare,$<) @$(call verify_sdk_classes) - @$(call classes_should_not_contain,com/launchdarkly/logging,com.launchdarkly.logging classes) + @$(call classes_should_contain,com/launchdarkly/logging,unshaded com.launchdarkly.logging classes) @$(call classes_should_not_contain,com/launchdarkly/shaded/com/launchdarkly/sdk,shaded SDK classes) @$(call classes_should_contain,com/launchdarkly/shaded/com/google/gson,shaded Gson) @$(call classes_should_not_contain,com/launchdarkly/shaded/org/slf4j,shaded SLF4j) From b787f6095058745675087642e8294aa5e3d1a8af Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 20 Oct 2022 11:17:28 -0700 Subject: [PATCH 673/673] correct documentation --- README.md | 2 +- build.gradle | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 8d08e225e..d24105a2e 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Three variants of the SDK jar are published to Maven: * 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/sdk/server-side/java#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 SLF4J (the SLF4J API is assumed to be brought in automatically as Maven dependencies, or otherwise made available in the classpath of the host application). All third-party 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 SLF4J is also bundled, without shading (and is exported in OSGi). -* The "thin" jar - add `thin` in Maven, or `:thin` in Gradle. This contains _only_ the SDK classes. +* The "thin" jar - add `thin` in Maven, or `:thin` in Gradle. This contains _only_ the SDK classes. Applications using this jar must provide all of the dependencies that are in the SDK's `build.gradle`, so it is intended for use only in special cases. ## Getting started diff --git a/build.gradle b/build.gradle index f7c7ed22e..acac74318 100644 --- a/build.gradle +++ b/build.gradle @@ -231,8 +231,9 @@ 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. +// This builds the default uberjar that contains all of our dependencies in shaded form, +// as well as com.launchdarkly.logging in unshaded form, but does not contain SLF4J. The +// user is expected to provide SLF4J. shadowJar { // No classifier means that the shaded jar becomes the default artifact classifier = '' @@ -267,11 +268,11 @@ shadowJar { } // This builds the "-all"/"fat" jar, which is the same as the default uberjar except that -// Gson, SLF4J, and com.launchdarkly.logging are bundled and exposed (unshaded). +// SLF4J is also bundled and exposed (unshaded). task shadowJarAll(type: com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { classifier = 'all' group = "shadow" - description = "Builds a Shaded fat jar including Gson, SLF4J, and com.launchdarkly.logging" + description = "Builds a Shaded fat jar including SLF4J" from(project.convention.getPlugin(JavaPluginConvention).sourceSets.main.output) configurations = [ project.configurations.internal, project.configurations.external ]